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:
Matt Ciaccio
2026-04-28 20:06:31 +02:00
parent 9dfa04094b
commit a3305a94f3
16 changed files with 11786 additions and 5 deletions

View 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);
}
}),
),
);

View 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);
}
}),
),
);