@tantainnovative/create-ndpr 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,325 +1,314 @@
1
- /**
2
- * Next.js App Router — Cross-Border Transfer Route
3
- * Generated by create-ndpr for: {{ORG_NAME}}
4
- *
5
- * NDPA §41–43 — transfers of personal data outside Nigeria require
6
- * adequate safeguards and, in some cases, NDPC approval.
7
- *
8
- * Endpoints:
9
- * GET /api/cross-border — List transfers (optional ?riskLevel= or ?destinationCountry= filter)
10
- * GET /api/cross-border?id=xxx — Get a single transfer record
11
- * POST /api/cross-border — Register a new cross-border transfer
12
- * PUT /api/cross-border — Update an existing transfer record
13
- * DELETE /api/cross-border?id=xxx — Delete a transfer record
14
- */
15
-
1
+ /** Next.js App Router — staff-only, tenant-scoped transfer register for {{ORG_NAME_COMMENT}}. */
16
2
  import { NextRequest, NextResponse } from 'next/server';
3
+ import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
17
4
  // {{#if ORM=prisma}}
18
5
  import { PrismaClient } from '@prisma/client';
19
-
20
6
  const prisma = new PrismaClient();
21
7
  // {{/if}}
22
8
  // {{#if ORM=drizzle}}
23
- import { db } from '@/drizzle';
24
- import { crossBorderTransferRecords, complianceAuditLog } from '@/drizzle/ndpr-schema';
25
- import { eq, and, desc, type SQL } from 'drizzle-orm';
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';
26
12
  // {{/if}}
27
- // {{#if ORM=none}}
28
- // TODO ({{ORG_NAME}}): wire this to your persistent store of choice. The
29
- // in-memory map below is enough to develop against locally but DOES NOT
30
- // satisfy NDPA Section 44 (record-keeping) or the cross-border register
31
- // NDPC inspects under Sections 41–43. Replace `crossBorderStore`/`auditLog`
32
- // with your DB / KV / API.
33
- interface CrossBorderTransferRecord {
34
- id: string;
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 {
35
25
  destinationCountry: string;
36
26
  recipientName: string;
37
27
  transferMechanism: string;
38
28
  safeguards: string;
39
- dataCategories: string[];
29
+ dataCategories: unknown;
40
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
41
  ndpcApprovalRequired: boolean;
42
42
  ndpcApprovalReference: string | null;
43
- riskLevel: string;
44
- createdAt: Date;
43
+ riskLevel: RiskLevel;
44
+ status: TransferStatus;
45
45
  updatedAt: Date;
46
46
  }
47
- const crossBorderStore = new Map<string, CrossBorderTransferRecord>();
48
- const auditLog: Array<{ id: string; module: string; action: string; entityId: string; at: Date }> = [];
49
- function newId() { return crypto.randomUUID(); }
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 }> = [];
50
53
  // {{/if}}
51
54
 
52
- // GET /api/cross-border?riskLevel=high OR /api/cross-border?id=xxx
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
+
53
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 });
54
150
  const id = req.nextUrl.searchParams.get('id');
55
-
56
151
  if (id) {
57
152
  // {{#if ORM=prisma}}
58
- const record = await prisma.crossBorderTransferRecord.findUnique({ where: { id } });
153
+ const record = await prisma.crossBorderTransferRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
59
154
  // {{/if}}
60
155
  // {{#if ORM=drizzle}}
61
- const [record] = await db.select().from(crossBorderTransferRecords).where(eq(crossBorderTransferRecords.id, id)).limit(1);
156
+ const [record] = await db.select().from(crossBorderTransferRecords).where(and(eq(crossBorderTransferRecords.id, id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).limit(1);
62
157
  // {{/if}}
63
158
  // {{#if ORM=none}}
64
- const record = crossBorderStore.get(id) ?? null;
159
+ const candidate = transferStore.get(id);
160
+ const record = candidate?.tenantId === context.tenantId && candidate.removedAt === null ? candidate : null;
65
161
  // {{/if}}
66
-
67
- if (!record) {
68
- return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
69
- }
70
- return NextResponse.json(record);
162
+ return record ? NextResponse.json(record) : NextResponse.json({ error: 'Transfer record not found' }, { status: 404 });
71
163
  }
72
-
73
- const riskLevel = req.nextUrl.searchParams.get('riskLevel');
74
- const destinationCountry = req.nextUrl.searchParams.get('destinationCountry');
75
-
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;
76
173
  // {{#if ORM=prisma}}
77
- const where: Record<string, string> = {};
78
- if (riskLevel) where.riskLevel = riskLevel;
79
- if (destinationCountry) where.destinationCountry = destinationCountry;
80
-
81
- const records = await prisma.crossBorderTransferRecord.findMany({
82
- where: Object.keys(where).length > 0 ? where : undefined,
83
- orderBy: { createdAt: 'desc' },
84
- });
174
+ const records = await prisma.crossBorderTransferRecord.findMany({ where: { tenantId: context.tenantId, removedAt: null, ...(riskLevel ? { riskLevel } : {}), ...(status ? { status } : {}), ...(destinationCountry ? { destinationCountry } : {}) }, orderBy: { createdAt: 'desc' } });
85
175
  // {{/if}}
86
176
  // {{#if ORM=drizzle}}
87
- const filters: SQL[] = [];
177
+ const filters: SQL[] = [eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt)];
88
178
  if (riskLevel) filters.push(eq(crossBorderTransferRecords.riskLevel, riskLevel));
179
+ if (status) filters.push(eq(crossBorderTransferRecords.status, status));
89
180
  if (destinationCountry) filters.push(eq(crossBorderTransferRecords.destinationCountry, destinationCountry));
90
-
91
- const records = filters.length > 0
92
- ? await db.select().from(crossBorderTransferRecords).where(and(...filters)).orderBy(desc(crossBorderTransferRecords.createdAt))
93
- : await db.select().from(crossBorderTransferRecords).orderBy(desc(crossBorderTransferRecords.createdAt));
181
+ const records = await db.select().from(crossBorderTransferRecords).where(and(...filters)).orderBy(desc(crossBorderTransferRecords.createdAt));
94
182
  // {{/if}}
95
183
  // {{#if ORM=none}}
96
- const records = [...crossBorderStore.values()]
97
- .filter((r) => (riskLevel ? r.riskLevel === riskLevel : true))
98
- .filter((r) => (destinationCountry ? r.destinationCountry === destinationCountry : true))
99
- .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
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());
100
185
  // {{/if}}
101
-
102
186
  return NextResponse.json(records);
103
187
  }
104
188
 
105
- // POST /api/cross-border
106
189
  export async function POST(req: NextRequest) {
107
- const body = await req.json();
108
- const {
109
- destinationCountry,
110
- recipientName,
111
- transferMechanism,
112
- safeguards,
113
- dataCategories,
114
- adequacyStatus,
115
- ndpcApprovalRequired,
116
- ndpcApprovalReference,
117
- riskLevel,
118
- } = body;
119
-
120
- if (
121
- !destinationCountry ||
122
- !recipientName ||
123
- !transferMechanism ||
124
- !safeguards ||
125
- !dataCategories ||
126
- !adequacyStatus ||
127
- !riskLevel
128
- ) {
129
- return NextResponse.json(
130
- {
131
- error:
132
- 'destinationCountry, recipientName, transferMechanism, safeguards, dataCategories, adequacyStatus, and riskLevel are required',
133
- },
134
- { status: 400 },
135
- );
136
- }
137
-
138
- if (!Array.isArray(dataCategories)) {
139
- return NextResponse.json(
140
- { error: 'dataCategories must be an array' },
141
- { status: 400 },
142
- );
143
- }
144
-
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;
145
200
  // {{#if ORM=prisma}}
146
- const record = await prisma.crossBorderTransferRecord.create({
147
- data: {
148
- destinationCountry,
149
- recipientName,
150
- transferMechanism,
151
- safeguards,
152
- dataCategories,
153
- adequacyStatus,
154
- ndpcApprovalRequired: ndpcApprovalRequired ?? false,
155
- ndpcApprovalReference: ndpcApprovalReference ?? null,
156
- riskLevel,
157
- },
158
- });
159
-
160
- await prisma.complianceAuditLog.create({
161
- data: {
162
- module: 'cross-border',
163
- action: 'created',
164
- entityId: record.id,
165
- entityType: 'CrossBorderTransferRecord',
166
- changes: { destinationCountry, recipientName, riskLevel },
167
- },
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;
168
205
  });
169
206
  // {{/if}}
170
207
  // {{#if ORM=drizzle}}
171
- const [record] = await db.insert(crossBorderTransferRecords).values({
172
- destinationCountry,
173
- recipientName,
174
- transferMechanism,
175
- safeguards,
176
- dataCategories,
177
- adequacyStatus,
178
- ndpcApprovalRequired: ndpcApprovalRequired ?? false,
179
- ndpcApprovalReference: ndpcApprovalReference ?? null,
180
- riskLevel,
181
- }).returning();
182
-
183
- await db.insert(complianceAuditLog).values({
184
- module: 'cross-border',
185
- action: 'created',
186
- entityId: record.id,
187
- entityType: 'CrossBorderTransferRecord',
188
- changes: { destinationCountry, recipientName, riskLevel },
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;
189
212
  });
190
213
  // {{/if}}
191
214
  // {{#if ORM=none}}
192
- const now = new Date();
193
- const record: CrossBorderTransferRecord = {
194
- id: newId(),
195
- destinationCountry,
196
- recipientName,
197
- transferMechanism,
198
- safeguards,
199
- dataCategories,
200
- adequacyStatus,
201
- ndpcApprovalRequired: ndpcApprovalRequired ?? false,
202
- ndpcApprovalReference: ndpcApprovalReference ?? null,
203
- riskLevel,
204
- createdAt: now,
205
- updatedAt: now,
206
- };
207
- crossBorderStore.set(record.id, record);
208
- auditLog.push({ id: newId(), module: 'cross-border', action: 'created', entityId: record.id, at: new Date() });
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 });
209
218
  // {{/if}}
210
-
211
219
  return NextResponse.json(record, { status: 201 });
212
220
  }
213
221
 
214
- // PUT /api/cross-border
215
222
  export async function PUT(req: NextRequest) {
216
- const body = await req.json();
217
- const { id, ...data } = body;
218
-
219
- if (!id) {
220
- return NextResponse.json({ error: 'id is required' }, { status: 400 });
221
- }
222
-
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;
223
236
  // {{#if ORM=prisma}}
224
- const existing = await prisma.crossBorderTransferRecord.findUnique({ where: { id } });
225
- if (!existing) {
226
- return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
227
- }
228
-
229
- const record = await prisma.crossBorderTransferRecord.update({
230
- where: { id },
231
- data,
232
- });
233
-
234
- await prisma.complianceAuditLog.create({
235
- data: {
236
- module: 'cross-border',
237
- action: 'updated',
238
- entityId: record.id,
239
- entityType: 'CrossBorderTransferRecord',
240
- changes: data,
241
- },
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;
242
247
  });
243
248
  // {{/if}}
244
249
  // {{#if ORM=drizzle}}
245
- const [existing] = await db.select().from(crossBorderTransferRecords).where(eq(crossBorderTransferRecords.id, id)).limit(1);
246
- if (!existing) {
247
- return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
248
- }
249
-
250
- const [record] = await db.update(crossBorderTransferRecords).set(data).where(eq(crossBorderTransferRecords.id, id)).returning();
251
-
252
- await db.insert(complianceAuditLog).values({
253
- module: 'cross-border',
254
- action: 'updated',
255
- entityId: record.id,
256
- entityType: 'CrossBorderTransferRecord',
257
- changes: data,
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;
258
259
  });
259
260
  // {{/if}}
260
261
  // {{#if ORM=none}}
261
- const existing = crossBorderStore.get(id);
262
- if (!existing) {
263
- return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
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
+ }
264
272
  }
265
- const record: CrossBorderTransferRecord = { ...existing, ...data, id, updatedAt: new Date() };
266
- crossBorderStore.set(id, record);
267
- auditLog.push({ id: newId(), module: 'cross-border', action: 'updated', entityId: record.id, at: new Date() });
268
273
  // {{/if}}
269
-
270
- return NextResponse.json(record);
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 });
271
276
  }
272
277
 
273
- // DELETE /api/cross-border?id=xxx
274
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;
275
282
  const id = req.nextUrl.searchParams.get('id');
276
-
277
- if (!id) {
278
- return NextResponse.json({ error: 'id is required' }, { status: 400 });
279
- }
280
-
283
+ if (!id?.trim()) return NextResponse.json({ error: 'id is required' }, { status: 400 });
284
+ const archivedAt = new Date();
281
285
  // {{#if ORM=prisma}}
282
- const existing = await prisma.crossBorderTransferRecord.findUnique({ where: { id } });
283
- if (!existing) {
284
- return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
285
- }
286
-
287
- await prisma.crossBorderTransferRecord.delete({ where: { id } });
288
-
289
- await prisma.complianceAuditLog.create({
290
- data: {
291
- module: 'cross-border',
292
- action: 'deleted',
293
- entityId: id,
294
- entityType: 'CrossBorderTransferRecord',
295
- changes: { destinationCountry: existing.destinationCountry, recipientName: existing.recipientName },
296
- },
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;
297
293
  });
298
294
  // {{/if}}
299
295
  // {{#if ORM=drizzle}}
300
- const [existing] = await db.select().from(crossBorderTransferRecords).where(eq(crossBorderTransferRecords.id, id)).limit(1);
301
- if (!existing) {
302
- return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
303
- }
304
-
305
- await db.delete(crossBorderTransferRecords).where(eq(crossBorderTransferRecords.id, id));
306
-
307
- await db.insert(complianceAuditLog).values({
308
- module: 'cross-border',
309
- action: 'deleted',
310
- entityId: id,
311
- entityType: 'CrossBorderTransferRecord',
312
- changes: { destinationCountry: existing.destinationCountry, recipientName: existing.recipientName },
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;
313
303
  });
314
304
  // {{/if}}
315
305
  // {{#if ORM=none}}
316
- const existing = crossBorderStore.get(id);
317
- if (!existing) {
318
- return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
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 });
319
311
  }
320
- crossBorderStore.delete(id);
321
- auditLog.push({ id: newId(), module: 'cross-border', action: 'deleted', entityId: id, at: new Date() });
322
312
  // {{/if}}
323
-
324
- return NextResponse.json({ success: true });
313
+ return archived ? NextResponse.json({ success: true, archived: true }) : NextResponse.json({ error: 'Transfer record not found' }, { status: 404 });
325
314
  }