@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,314 @@
|
|
|
1
|
+
/** Next.js App Router — staff-only, tenant-scoped transfer 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, crossBorderTransferRecords } from '{{NDPR_SCHEMA_IMPORT}}';
|
|
11
|
+
import { and, desc, eq, isNull, type SQL } from 'drizzle-orm';
|
|
12
|
+
// {{/if}}
|
|
13
|
+
|
|
14
|
+
const TRANSFER_MECHANISMS = ['adequacy_decision', 'standard_clauses', 'binding_corporate_rules', 'ndpc_authorization', 'explicit_consent', 'contract_performance', 'public_interest', 'legal_claims', 'vital_interests'] as const;
|
|
15
|
+
const ADEQUACY_STATUSES = ['adequate', 'inadequate', 'pending_review', 'unknown'] as const;
|
|
16
|
+
const RISK_LEVELS = ['low', 'medium', 'high'] as const;
|
|
17
|
+
const TRANSFER_STATUSES = ['active', 'suspended', 'terminated', 'pending_approval'] as const;
|
|
18
|
+
type TransferMechanism = (typeof TRANSFER_MECHANISMS)[number];
|
|
19
|
+
type AdequacyStatus = (typeof ADEQUACY_STATUSES)[number];
|
|
20
|
+
type RiskLevel = (typeof RISK_LEVELS)[number];
|
|
21
|
+
type TransferStatus = (typeof TRANSFER_STATUSES)[number];
|
|
22
|
+
type Validation<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
23
|
+
|
|
24
|
+
interface TransferExisting {
|
|
25
|
+
destinationCountry: string;
|
|
26
|
+
recipientName: string;
|
|
27
|
+
transferMechanism: string;
|
|
28
|
+
safeguards: string;
|
|
29
|
+
dataCategories: unknown;
|
|
30
|
+
adequacyStatus: string;
|
|
31
|
+
ndpcApprovalReference: string | null;
|
|
32
|
+
status: string;
|
|
33
|
+
}
|
|
34
|
+
interface TransferMutation {
|
|
35
|
+
destinationCountry: string;
|
|
36
|
+
recipientName: string;
|
|
37
|
+
transferMechanism: TransferMechanism;
|
|
38
|
+
safeguards: string;
|
|
39
|
+
dataCategories: string[];
|
|
40
|
+
adequacyStatus: AdequacyStatus;
|
|
41
|
+
ndpcApprovalRequired: boolean;
|
|
42
|
+
ndpcApprovalReference: string | null;
|
|
43
|
+
riskLevel: RiskLevel;
|
|
44
|
+
status: TransferStatus;
|
|
45
|
+
updatedAt: Date;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// {{#if ORM=none}}
|
|
49
|
+
// DEVELOPMENT ONLY: replace both stores with one durable transactional store.
|
|
50
|
+
interface TransferRow extends TransferMutation { id: string; tenantId: string; createdAt: Date; removedAt: Date | null }
|
|
51
|
+
const transferStore = new Map<string, TransferRow>();
|
|
52
|
+
const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string; changes?: unknown; at: Date }> = [];
|
|
53
|
+
// {{/if}}
|
|
54
|
+
|
|
55
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
function hasOwn(value: Record<string, unknown>, key: string): boolean {
|
|
59
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
60
|
+
}
|
|
61
|
+
function rejectUnknownFields(value: Record<string, unknown>, allowed: readonly string[]): string | null {
|
|
62
|
+
const allowedSet = new Set(allowed);
|
|
63
|
+
const unknown = Object.keys(value).filter((key) => !allowedSet.has(key));
|
|
64
|
+
return unknown.length > 0 ? `Unsupported field(s): ${unknown.join(', ')}` : null;
|
|
65
|
+
}
|
|
66
|
+
function nonEmptyText(value: unknown, field: string, maxLength = 10_000): Validation<string> {
|
|
67
|
+
if (typeof value !== 'string' || !value.trim() || value.trim().length > maxLength) return { ok: false, error: `${field} must be non-empty text of at most ${maxLength} characters` };
|
|
68
|
+
return { ok: true, value: value.trim() };
|
|
69
|
+
}
|
|
70
|
+
function nonEmptyStringArray(value: unknown, field: string): Validation<string[]> {
|
|
71
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 100) return { ok: false, error: `${field} must be a non-empty array with at most 100 items` };
|
|
72
|
+
const result: string[] = [];
|
|
73
|
+
for (const item of value) {
|
|
74
|
+
if (typeof item !== 'string' || !item.trim()) return { ok: false, error: `${field} must contain only non-empty strings` };
|
|
75
|
+
if (!result.includes(item.trim())) result.push(item.trim());
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, value: result };
|
|
78
|
+
}
|
|
79
|
+
function isMechanism(value: unknown): value is TransferMechanism { return typeof value === 'string' && (TRANSFER_MECHANISMS as readonly string[]).includes(value); }
|
|
80
|
+
function isAdequacyStatus(value: unknown): value is AdequacyStatus { return typeof value === 'string' && (ADEQUACY_STATUSES as readonly string[]).includes(value); }
|
|
81
|
+
function isRiskLevel(value: unknown): value is RiskLevel { return typeof value === 'string' && (RISK_LEVELS as readonly string[]).includes(value); }
|
|
82
|
+
function isTransferStatus(value: unknown): value is TransferStatus { return typeof value === 'string' && (TRANSFER_STATUSES as readonly string[]).includes(value); }
|
|
83
|
+
function approvalRequired(mechanism: TransferMechanism): boolean {
|
|
84
|
+
return mechanism === 'binding_corporate_rules' || mechanism === 'ndpc_authorization';
|
|
85
|
+
}
|
|
86
|
+
function deriveRiskLevel(adequacy: AdequacyStatus, mechanism: TransferMechanism, required: boolean, reference: string | null): RiskLevel {
|
|
87
|
+
if ((required && !reference) || adequacy === 'inadequate') return 'high';
|
|
88
|
+
const derogation = ['explicit_consent', 'contract_performance', 'public_interest', 'legal_claims', 'vital_interests'].includes(mechanism);
|
|
89
|
+
if (adequacy === 'unknown' || adequacy === 'pending_review' || derogation) return 'medium';
|
|
90
|
+
return 'low';
|
|
91
|
+
}
|
|
92
|
+
const STATUS_TRANSITIONS: Record<TransferStatus, readonly TransferStatus[]> = {
|
|
93
|
+
active: ['active', 'suspended', 'terminated', 'pending_approval'],
|
|
94
|
+
suspended: ['suspended', 'active', 'terminated', 'pending_approval'],
|
|
95
|
+
pending_approval: ['pending_approval', 'active', 'suspended', 'terminated'],
|
|
96
|
+
terminated: ['terminated'],
|
|
97
|
+
};
|
|
98
|
+
function buildTransferMutation(input: Record<string, unknown>, existing?: TransferExisting, now = new Date()): Validation<TransferMutation> {
|
|
99
|
+
const destination = hasOwn(input, 'destinationCountry') ? nonEmptyText(input.destinationCountry, 'destinationCountry', 200) : existing ? nonEmptyText(existing.destinationCountry, 'stored destinationCountry', 200) : { ok: false as const, error: 'destinationCountry is required' };
|
|
100
|
+
if (!destination.ok) return destination;
|
|
101
|
+
const recipient = hasOwn(input, 'recipientName') ? nonEmptyText(input.recipientName, 'recipientName', 500) : existing ? nonEmptyText(existing.recipientName, 'stored recipientName', 500) : { ok: false as const, error: 'recipientName is required' };
|
|
102
|
+
if (!recipient.ok) return recipient;
|
|
103
|
+
const mechanismValue = hasOwn(input, 'transferMechanism') ? input.transferMechanism : existing?.transferMechanism;
|
|
104
|
+
if (!isMechanism(mechanismValue)) return { ok: false, error: 'transferMechanism is not supported by the NDPA transfer register' };
|
|
105
|
+
const safeguards = hasOwn(input, 'safeguards') ? nonEmptyText(input.safeguards, 'safeguards') : existing ? nonEmptyText(existing.safeguards, 'stored safeguards') : { ok: false as const, error: 'safeguards is required' };
|
|
106
|
+
if (!safeguards.ok) return safeguards;
|
|
107
|
+
const categories = nonEmptyStringArray(hasOwn(input, 'dataCategories') ? input.dataCategories : existing?.dataCategories, 'dataCategories');
|
|
108
|
+
if (!categories.ok) return categories;
|
|
109
|
+
const adequacyValue = hasOwn(input, 'adequacyStatus') ? input.adequacyStatus : existing?.adequacyStatus;
|
|
110
|
+
if (!isAdequacyStatus(adequacyValue)) return { ok: false, error: 'adequacyStatus is not supported' };
|
|
111
|
+
if (mechanismValue === 'adequacy_decision' && adequacyValue !== 'adequate') return { ok: false, error: 'adequacy_decision can only be used for a destination marked adequate' };
|
|
112
|
+
let approvalReference: string | null;
|
|
113
|
+
const referenceValue = hasOwn(input, 'ndpcApprovalReference') ? input.ndpcApprovalReference : existing?.ndpcApprovalReference ?? null;
|
|
114
|
+
if (referenceValue === null || referenceValue === '') approvalReference = null;
|
|
115
|
+
else {
|
|
116
|
+
const reference = nonEmptyText(referenceValue, 'ndpcApprovalReference', 500);
|
|
117
|
+
if (!reference.ok) return reference;
|
|
118
|
+
approvalReference = reference.value;
|
|
119
|
+
}
|
|
120
|
+
const required = approvalRequired(mechanismValue);
|
|
121
|
+
if (!required && approvalReference) return { ok: false, error: 'ndpcApprovalReference is only accepted for a mechanism that requires NDPC approval' };
|
|
122
|
+
const explicitStatus = hasOwn(input, 'status');
|
|
123
|
+
if (explicitStatus && !isTransferStatus(input.status)) return { ok: false, error: 'status is not supported' };
|
|
124
|
+
let status: TransferStatus;
|
|
125
|
+
if (isTransferStatus(input.status)) status = input.status;
|
|
126
|
+
else if (required && !approvalReference) status = 'pending_approval';
|
|
127
|
+
else if (existing && isTransferStatus(existing.status) && existing.status !== 'pending_approval') status = existing.status;
|
|
128
|
+
else status = 'active';
|
|
129
|
+
if (required && !approvalReference && status === 'active') return { ok: false, error: 'An approval-dependent transfer cannot be active without an NDPC approval reference' };
|
|
130
|
+
if (!required && status === 'pending_approval') return { ok: false, error: 'pending_approval is only valid when the selected mechanism requires NDPC approval' };
|
|
131
|
+
if (approvalReference && status === 'pending_approval') return { ok: false, error: 'A transfer with an NDPC approval reference cannot remain pending_approval' };
|
|
132
|
+
if (existing) {
|
|
133
|
+
if (!isTransferStatus(existing.status)) return { ok: false, error: 'Stored transfer status is invalid' };
|
|
134
|
+
if (!STATUS_TRANSITIONS[existing.status].includes(status)) return { ok: false, error: `Transfer status cannot transition from ${existing.status} to ${status}` };
|
|
135
|
+
if (existing.status === 'terminated' && ['destinationCountry', 'recipientName', 'transferMechanism', 'safeguards', 'dataCategories', 'adequacyStatus', 'ndpcApprovalReference'].some((field) => hasOwn(input, field))) return { ok: false, error: 'A terminated transfer cannot have its compliance evidence changed' };
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, value: { destinationCountry: destination.value, recipientName: recipient.value, transferMechanism: mechanismValue, safeguards: safeguards.value, dataCategories: categories.value, adequacyStatus: adequacyValue, ndpcApprovalRequired: required, ndpcApprovalReference: approvalReference, riskLevel: deriveRiskLevel(adequacyValue, mechanismValue, required, approvalReference), status, updatedAt: now } };
|
|
138
|
+
}
|
|
139
|
+
function auditChanges(data: TransferMutation) {
|
|
140
|
+
return { destinationCountry: data.destinationCountry, recipientName: data.recipientName, transferMechanism: data.transferMechanism, adequacyStatus: data.adequacyStatus, ndpcApprovalRequired: data.ndpcApprovalRequired, riskLevel: data.riskLevel, status: data.status };
|
|
141
|
+
}
|
|
142
|
+
async function staffContext(req: NextRequest) {
|
|
143
|
+
const context = await resolveNDPRRequestContext(req);
|
|
144
|
+
return { context, problem: getNDPRContextProblem(context, 'staff') };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function GET(req: NextRequest) {
|
|
148
|
+
const { context, problem } = await staffContext(req);
|
|
149
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
150
|
+
const id = req.nextUrl.searchParams.get('id');
|
|
151
|
+
if (id) {
|
|
152
|
+
// {{#if ORM=prisma}}
|
|
153
|
+
const record = await prisma.crossBorderTransferRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
154
|
+
// {{/if}}
|
|
155
|
+
// {{#if ORM=drizzle}}
|
|
156
|
+
const [record] = await db.select().from(crossBorderTransferRecords).where(and(eq(crossBorderTransferRecords.id, id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).limit(1);
|
|
157
|
+
// {{/if}}
|
|
158
|
+
// {{#if ORM=none}}
|
|
159
|
+
const candidate = transferStore.get(id);
|
|
160
|
+
const record = candidate?.tenantId === context.tenantId && candidate.removedAt === null ? candidate : null;
|
|
161
|
+
// {{/if}}
|
|
162
|
+
return record ? NextResponse.json(record) : NextResponse.json({ error: 'Transfer record not found' }, { status: 404 });
|
|
163
|
+
}
|
|
164
|
+
const riskValue = req.nextUrl.searchParams.get('riskLevel');
|
|
165
|
+
const statusValue = req.nextUrl.searchParams.get('status');
|
|
166
|
+
const destinationValue = req.nextUrl.searchParams.get('destinationCountry');
|
|
167
|
+
if (riskValue !== null && !isRiskLevel(riskValue)) return NextResponse.json({ error: 'riskLevel filter is not supported' }, { status: 400 });
|
|
168
|
+
if (statusValue !== null && !isTransferStatus(statusValue)) return NextResponse.json({ error: 'status filter is not supported' }, { status: 400 });
|
|
169
|
+
if (destinationValue !== null && !destinationValue.trim()) return NextResponse.json({ error: 'destinationCountry filter cannot be empty' }, { status: 400 });
|
|
170
|
+
const riskLevel = riskValue ?? undefined;
|
|
171
|
+
const status = statusValue ?? undefined;
|
|
172
|
+
const destinationCountry = destinationValue?.trim() || undefined;
|
|
173
|
+
// {{#if ORM=prisma}}
|
|
174
|
+
const records = await prisma.crossBorderTransferRecord.findMany({ where: { tenantId: context.tenantId, removedAt: null, ...(riskLevel ? { riskLevel } : {}), ...(status ? { status } : {}), ...(destinationCountry ? { destinationCountry } : {}) }, orderBy: { createdAt: 'desc' } });
|
|
175
|
+
// {{/if}}
|
|
176
|
+
// {{#if ORM=drizzle}}
|
|
177
|
+
const filters: SQL[] = [eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt)];
|
|
178
|
+
if (riskLevel) filters.push(eq(crossBorderTransferRecords.riskLevel, riskLevel));
|
|
179
|
+
if (status) filters.push(eq(crossBorderTransferRecords.status, status));
|
|
180
|
+
if (destinationCountry) filters.push(eq(crossBorderTransferRecords.destinationCountry, destinationCountry));
|
|
181
|
+
const records = await db.select().from(crossBorderTransferRecords).where(and(...filters)).orderBy(desc(crossBorderTransferRecords.createdAt));
|
|
182
|
+
// {{/if}}
|
|
183
|
+
// {{#if ORM=none}}
|
|
184
|
+
const records = [...transferStore.values()].filter((row) => row.tenantId === context.tenantId && row.removedAt === null && (!riskLevel || row.riskLevel === riskLevel) && (!status || row.status === status) && (!destinationCountry || row.destinationCountry === destinationCountry)).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
185
|
+
// {{/if}}
|
|
186
|
+
return NextResponse.json(records);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function POST(req: NextRequest) {
|
|
190
|
+
const { context, problem } = await staffContext(req);
|
|
191
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
192
|
+
const actorId = context.actorId as string;
|
|
193
|
+
const body: unknown = await req.json().catch(() => null);
|
|
194
|
+
if (!isRecord(body)) return NextResponse.json({ error: 'A JSON object is required' }, { status: 400 });
|
|
195
|
+
const unknown = rejectUnknownFields(body, ['destinationCountry', 'recipientName', 'transferMechanism', 'safeguards', 'dataCategories', 'adequacyStatus', 'ndpcApprovalReference', 'status']);
|
|
196
|
+
if (unknown) return NextResponse.json({ error: unknown }, { status: 400 });
|
|
197
|
+
const normalized = buildTransferMutation(body);
|
|
198
|
+
if (!normalized.ok) return NextResponse.json({ error: normalized.error }, { status: 400 });
|
|
199
|
+
const data = normalized.value;
|
|
200
|
+
// {{#if ORM=prisma}}
|
|
201
|
+
const record = await prisma.$transaction(async (tx) => {
|
|
202
|
+
const created = await tx.crossBorderTransferRecord.create({ data: { ...data, tenantId: context.tenantId, removedAt: null } });
|
|
203
|
+
await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'cross-border', action: 'created', entityId: created.id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: auditChanges(data) } });
|
|
204
|
+
return created;
|
|
205
|
+
});
|
|
206
|
+
// {{/if}}
|
|
207
|
+
// {{#if ORM=drizzle}}
|
|
208
|
+
const record = await db.transaction(async (tx) => {
|
|
209
|
+
const [created] = await tx.insert(crossBorderTransferRecords).values({ ...data, tenantId: context.tenantId, removedAt: null }).returning();
|
|
210
|
+
await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'cross-border', action: 'created', entityId: created.id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: auditChanges(data) });
|
|
211
|
+
return created;
|
|
212
|
+
});
|
|
213
|
+
// {{/if}}
|
|
214
|
+
// {{#if ORM=none}}
|
|
215
|
+
const record: TransferRow = { id: crypto.randomUUID(), ...data, tenantId: context.tenantId, createdAt: data.updatedAt, removedAt: null };
|
|
216
|
+
transferStore.set(record.id, record);
|
|
217
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'created', entityId: record.id, performedBy: actorId, changes: auditChanges(data), at: data.updatedAt });
|
|
218
|
+
// {{/if}}
|
|
219
|
+
return NextResponse.json(record, { status: 201 });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function PUT(req: NextRequest) {
|
|
223
|
+
const { context, problem } = await staffContext(req);
|
|
224
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
225
|
+
const actorId = context.actorId as string;
|
|
226
|
+
const body: unknown = await req.json().catch(() => null);
|
|
227
|
+
if (!isRecord(body)) return NextResponse.json({ error: 'A JSON object is required' }, { status: 400 });
|
|
228
|
+
const fields = ['destinationCountry', 'recipientName', 'transferMechanism', 'safeguards', 'dataCategories', 'adequacyStatus', 'ndpcApprovalReference', 'status'] as const;
|
|
229
|
+
const unknown = rejectUnknownFields(body, ['id', ...fields]);
|
|
230
|
+
if (unknown) return NextResponse.json({ error: unknown }, { status: 400 });
|
|
231
|
+
const idResult = nonEmptyText(body.id, 'id', 200);
|
|
232
|
+
if (!idResult.ok) return NextResponse.json({ error: idResult.error }, { status: 400 });
|
|
233
|
+
if (!fields.some((field) => hasOwn(body, field))) return NextResponse.json({ error: 'Provide at least one allowlisted transfer field to update' }, { status: 400 });
|
|
234
|
+
const id = idResult.value;
|
|
235
|
+
let validationError: string | null = null;
|
|
236
|
+
// {{#if ORM=prisma}}
|
|
237
|
+
const record = await prisma.$transaction(async (tx) => {
|
|
238
|
+
const existing = await tx.crossBorderTransferRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
239
|
+
if (!existing) return null;
|
|
240
|
+
const normalized = buildTransferMutation(body, existing);
|
|
241
|
+
if (!normalized.ok) { validationError = normalized.error; return null; }
|
|
242
|
+
const data = normalized.value;
|
|
243
|
+
await tx.crossBorderTransferRecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data });
|
|
244
|
+
const updated = await tx.crossBorderTransferRecord.findFirstOrThrow({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
245
|
+
await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'cross-border', action: 'updated', entityId: id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: auditChanges(data) } });
|
|
246
|
+
return updated;
|
|
247
|
+
});
|
|
248
|
+
// {{/if}}
|
|
249
|
+
// {{#if ORM=drizzle}}
|
|
250
|
+
const record = await db.transaction(async (tx) => {
|
|
251
|
+
const [existing] = await tx.select().from(crossBorderTransferRecords).where(and(eq(crossBorderTransferRecords.id, id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).limit(1);
|
|
252
|
+
if (!existing) return null;
|
|
253
|
+
const normalized = buildTransferMutation(body, existing);
|
|
254
|
+
if (!normalized.ok) { validationError = normalized.error; return null; }
|
|
255
|
+
const data = normalized.value;
|
|
256
|
+
const [updated] = await tx.update(crossBorderTransferRecords).set(data).where(and(eq(crossBorderTransferRecords.id, id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).returning();
|
|
257
|
+
await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'cross-border', action: 'updated', entityId: id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: auditChanges(data) });
|
|
258
|
+
return updated;
|
|
259
|
+
});
|
|
260
|
+
// {{/if}}
|
|
261
|
+
// {{#if ORM=none}}
|
|
262
|
+
const existing = transferStore.get(id);
|
|
263
|
+
let record: TransferRow | null = null;
|
|
264
|
+
if (existing?.tenantId === context.tenantId && existing.removedAt === null) {
|
|
265
|
+
const normalized = buildTransferMutation(body, existing);
|
|
266
|
+
if (!normalized.ok) validationError = normalized.error;
|
|
267
|
+
else {
|
|
268
|
+
record = { ...existing, ...normalized.value, id };
|
|
269
|
+
transferStore.set(id, record);
|
|
270
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'updated', entityId: id, performedBy: actorId, changes: auditChanges(normalized.value), at: normalized.value.updatedAt });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// {{/if}}
|
|
274
|
+
if (validationError) return NextResponse.json({ error: validationError }, { status: 400 });
|
|
275
|
+
return record ? NextResponse.json(record) : NextResponse.json({ error: 'Transfer record not found' }, { status: 404 });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function DELETE(req: NextRequest) {
|
|
279
|
+
const { context, problem } = await staffContext(req);
|
|
280
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
281
|
+
const actorId = context.actorId as string;
|
|
282
|
+
const id = req.nextUrl.searchParams.get('id');
|
|
283
|
+
if (!id?.trim()) return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
|
284
|
+
const archivedAt = new Date();
|
|
285
|
+
// {{#if ORM=prisma}}
|
|
286
|
+
const archived = await prisma.$transaction(async (tx) => {
|
|
287
|
+
const existing = await tx.crossBorderTransferRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
288
|
+
if (!existing) return false;
|
|
289
|
+
const result = await tx.crossBorderTransferRecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data: { removedAt: archivedAt, updatedAt: archivedAt } });
|
|
290
|
+
if (result.count !== 1) return false;
|
|
291
|
+
await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'cross-border', action: 'archived', entityId: id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: { destinationCountry: existing.destinationCountry, recipientName: existing.recipientName, removedAt: archivedAt.toISOString() } } });
|
|
292
|
+
return true;
|
|
293
|
+
});
|
|
294
|
+
// {{/if}}
|
|
295
|
+
// {{#if ORM=drizzle}}
|
|
296
|
+
const archived = await db.transaction(async (tx) => {
|
|
297
|
+
const [existing] = await tx.select().from(crossBorderTransferRecords).where(and(eq(crossBorderTransferRecords.id, id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).limit(1);
|
|
298
|
+
if (!existing) return false;
|
|
299
|
+
const [updated] = await tx.update(crossBorderTransferRecords).set({ removedAt: archivedAt, updatedAt: archivedAt }).where(and(eq(crossBorderTransferRecords.id, id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).returning();
|
|
300
|
+
if (!updated) return false;
|
|
301
|
+
await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'cross-border', action: 'archived', entityId: id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: { destinationCountry: existing.destinationCountry, recipientName: existing.recipientName, removedAt: archivedAt.toISOString() } });
|
|
302
|
+
return true;
|
|
303
|
+
});
|
|
304
|
+
// {{/if}}
|
|
305
|
+
// {{#if ORM=none}}
|
|
306
|
+
const existing = transferStore.get(id);
|
|
307
|
+
const archived = existing?.tenantId === context.tenantId && existing.removedAt === null;
|
|
308
|
+
if (archived && existing) {
|
|
309
|
+
transferStore.set(id, { ...existing, removedAt: archivedAt, updatedAt: archivedAt });
|
|
310
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'archived', entityId: id, performedBy: actorId, changes: { destinationCountry: existing.destinationCountry, recipientName: existing.recipientName, removedAt: archivedAt.toISOString() }, at: archivedAt });
|
|
311
|
+
}
|
|
312
|
+
// {{/if}}
|
|
313
|
+
return archived ? NextResponse.json({ success: true, archived: true }) : NextResponse.json({ error: 'Transfer record not found' }, { status: 404 });
|
|
314
|
+
}
|