feat(gdpr): staff-triggered client-data export bundle (Article 15)
Adds a full GDPR Article 15 (right of access) workflow. Staff trigger an export from the client detail; a BullMQ worker assembles every row keyed to that client (profile, contacts, addresses, notes, tags, yachts, company memberships, interests, reservations, invoices, documents, last 500 audit events) into JSON + a self-contained HTML report, ZIPs them, uploads to MinIO, and optionally emails the client a 7-day signed download link. - New table gdpr_exports tracks lifecycle (pending → building → ready → sent / failed) with a 30-day cleanup target - Bundle builder (gdpr-bundle-builder.ts) — pure read-side, tenant- scoped, with HTML escaping to block injection from rogue field values - Worker hook in export queue dispatches on job name 'gdpr-export' - New audit actions: 'request_gdpr_export', 'send_gdpr_export' - API: POST/GET /api/v1/clients/:id/gdpr-export (admin-gated, exports rate-limit, Article-15 audit on POST); GET /:exportId returns a fresh signed URL - UI: <GdprExportButton> dialog on client detail header — admin-only, shows recent exports, supports email-to-client + override recipient, polls every 5s while open - Validation: refuses email-to-client when no primary email + no override (rather than silently dropping the send) Tests: 778/778 vitest (was 771) — +7 covering builder happy path, HTML escaping, tenant isolation, empty client, request-flow validation, and audit / queue interaction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
24
src/app/api/v1/clients/[id]/gdpr-export/[exportId]/route.ts
Normal file
24
src/app/api/v1/clients/[id]/gdpr-export/[exportId]/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getExportDownloadUrl } from '@/lib/services/gdpr-export.service';
|
||||
|
||||
/**
|
||||
* Returns a fresh signed URL for an existing GDPR export. Staff use this
|
||||
* from the admin UI; the email path embeds its own signed URL.
|
||||
*/
|
||||
export const GET = withAuth(
|
||||
withPermission(
|
||||
'admin',
|
||||
'manage_settings',
|
||||
withRateLimit('exports', async (req, ctx, params) => {
|
||||
try {
|
||||
const url = await getExportDownloadUrl(params.exportId!, ctx.portId);
|
||||
return NextResponse.json({ data: { url } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
49
src/app/api/v1/clients/[id]/gdpr-export/route.ts
Normal file
49
src/app/api/v1/clients/[id]/gdpr-export/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { requestGdprExport, listClientExports } from '@/lib/services/gdpr-export.service';
|
||||
|
||||
const requestSchema = z.object({
|
||||
/** When true, the bundle is emailed to the client once it finishes building. */
|
||||
emailToClient: z.boolean().optional().default(false),
|
||||
/** Optional override recipient (e.g. legal counsel). Skips the primary-email lookup. */
|
||||
emailOverride: z.string().email().optional().nullable(),
|
||||
});
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('clients', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const rows = await listClientExports(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: rows });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission(
|
||||
'admin',
|
||||
'manage_settings',
|
||||
withRateLimit('exports', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, requestSchema);
|
||||
const result = await requestGdprExport({
|
||||
clientId: params.id!,
|
||||
portId: ctx.portId,
|
||||
requestedBy: ctx.userId,
|
||||
emailToClient: body.emailToClient,
|
||||
emailOverride: body.emailOverride ?? null,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: result.export }, { status: 202 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user