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:
267
src/lib/services/gdpr-bundle-builder.ts
Normal file
267
src/lib/services/gdpr-bundle-builder.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Builds the structured payload that becomes the JSON + HTML inside a
|
||||
* GDPR client-data export. Pure read-side — no writes, no I/O outside
|
||||
* Drizzle. The worker pairs this with the actual ZIP/upload/email work.
|
||||
*
|
||||
* GDPR Article 15 (right of access) requires that we hand the data
|
||||
* subject everything we hold about them. This builder enumerates every
|
||||
* table that carries a `clientId` foreign key plus the polymorphic
|
||||
* yacht ownership rows that resolve to this client.
|
||||
*/
|
||||
|
||||
import { and, eq, or } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { NotFoundError } from '@/lib/errors';
|
||||
import {
|
||||
clients,
|
||||
clientContacts,
|
||||
clientAddresses,
|
||||
clientNotes,
|
||||
clientRelationships,
|
||||
clientTags,
|
||||
} from '@/lib/db/schema/clients';
|
||||
import { tags } from '@/lib/db/schema/system';
|
||||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { invoices } from '@/lib/db/schema/financial';
|
||||
import { documents } from '@/lib/db/schema/documents';
|
||||
import { auditLogs } from '@/lib/db/schema/system';
|
||||
|
||||
export interface GdprBundle {
|
||||
/** Bundle metadata for traceability. */
|
||||
meta: {
|
||||
generatedAt: string;
|
||||
portId: string;
|
||||
clientId: string;
|
||||
schemaVersion: 1;
|
||||
};
|
||||
client: Record<string, unknown>;
|
||||
contacts: Record<string, unknown>[];
|
||||
addresses: Record<string, unknown>[];
|
||||
tags: Array<{ id: string; name: string; color: string }>;
|
||||
relationships: Record<string, unknown>[];
|
||||
notes: Record<string, unknown>[];
|
||||
ownedYachts: Record<string, unknown>[];
|
||||
companyMemberships: Array<{
|
||||
membership: Record<string, unknown>;
|
||||
company: Record<string, unknown>;
|
||||
}>;
|
||||
interests: Record<string, unknown>[];
|
||||
reservations: Record<string, unknown>[];
|
||||
invoices: Record<string, unknown>[];
|
||||
documents: Record<string, unknown>[];
|
||||
auditTrail: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads every row that references this client across all tenant-scoped
|
||||
* tables. Every query is filtered by `portId` as well, so a stale FK
|
||||
* to another tenant never leaks across.
|
||||
*/
|
||||
export async function buildClientBundle(clientId: string, portId: string): Promise<GdprBundle> {
|
||||
const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) });
|
||||
if (!client || client.portId !== portId) {
|
||||
throw new NotFoundError('Client');
|
||||
}
|
||||
|
||||
const [
|
||||
contacts,
|
||||
addresses,
|
||||
relationships,
|
||||
notes,
|
||||
tagJoins,
|
||||
ownedYachts,
|
||||
membershipRows,
|
||||
interestRows,
|
||||
reservationRows,
|
||||
invoiceRows,
|
||||
documentRows,
|
||||
auditRows,
|
||||
] = await Promise.all([
|
||||
db.query.clientContacts.findMany({ where: eq(clientContacts.clientId, clientId) }),
|
||||
db.query.clientAddresses.findMany({ where: eq(clientAddresses.clientId, clientId) }),
|
||||
db.query.clientRelationships.findMany({
|
||||
where: or(
|
||||
eq(clientRelationships.clientAId, clientId),
|
||||
eq(clientRelationships.clientBId, clientId),
|
||||
),
|
||||
}),
|
||||
db.query.clientNotes.findMany({ where: eq(clientNotes.clientId, clientId) }),
|
||||
db
|
||||
.select({
|
||||
id: tags.id,
|
||||
name: tags.name,
|
||||
color: tags.color,
|
||||
})
|
||||
.from(clientTags)
|
||||
.innerJoin(tags, eq(clientTags.tagId, tags.id))
|
||||
.where(eq(clientTags.clientId, clientId)),
|
||||
db.query.yachts.findMany({
|
||||
where: and(
|
||||
eq(yachts.portId, portId),
|
||||
eq(yachts.currentOwnerType, 'client'),
|
||||
eq(yachts.currentOwnerId, clientId),
|
||||
),
|
||||
}),
|
||||
db
|
||||
.select({ membership: companyMemberships, company: companies })
|
||||
.from(companyMemberships)
|
||||
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
|
||||
.where(and(eq(companyMemberships.clientId, clientId), eq(companies.portId, portId))),
|
||||
db.query.interests.findMany({
|
||||
where: and(eq(interests.clientId, clientId), eq(interests.portId, portId)),
|
||||
}),
|
||||
db.query.berthReservations.findMany({
|
||||
where: and(eq(berthReservations.clientId, clientId), eq(berthReservations.portId, portId)),
|
||||
}),
|
||||
db.query.invoices.findMany({
|
||||
where: and(
|
||||
eq(invoices.portId, portId),
|
||||
eq(invoices.billingEntityType, 'client'),
|
||||
eq(invoices.billingEntityId, clientId),
|
||||
),
|
||||
}),
|
||||
db.query.documents.findMany({
|
||||
where: and(eq(documents.portId, portId), eq(documents.clientId, clientId)),
|
||||
}),
|
||||
db.query.auditLogs.findMany({
|
||||
where: and(
|
||||
eq(auditLogs.portId, portId),
|
||||
eq(auditLogs.entityType, 'client'),
|
||||
eq(auditLogs.entityId, clientId),
|
||||
),
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
limit: 500,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
meta: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
portId,
|
||||
clientId,
|
||||
schemaVersion: 1,
|
||||
},
|
||||
client: client as unknown as Record<string, unknown>,
|
||||
contacts: contacts as unknown as Record<string, unknown>[],
|
||||
addresses: addresses as unknown as Record<string, unknown>[],
|
||||
tags: tagJoins,
|
||||
relationships: relationships as unknown as Record<string, unknown>[],
|
||||
notes: notes as unknown as Record<string, unknown>[],
|
||||
ownedYachts: ownedYachts as unknown as Record<string, unknown>[],
|
||||
companyMemberships: membershipRows as unknown as Array<{
|
||||
membership: Record<string, unknown>;
|
||||
company: Record<string, unknown>;
|
||||
}>,
|
||||
interests: interestRows as unknown as Record<string, unknown>[],
|
||||
reservations: reservationRows as unknown as Record<string, unknown>[],
|
||||
invoices: invoiceRows as unknown as Record<string, unknown>[],
|
||||
documents: documentRows as unknown as Record<string, unknown>[],
|
||||
auditTrail: auditRows as unknown as Record<string, unknown>[],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── HTML rendering ──────────────────────────────────────────────────────────
|
||||
|
||||
function escapeHtml(s: unknown): string {
|
||||
if (s === null || s === undefined) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function tableSection(title: string, rows: Record<string, unknown>[]): string {
|
||||
if (rows.length === 0) {
|
||||
return `<section><h2>${escapeHtml(title)}</h2><p class="empty">No records.</p></section>`;
|
||||
}
|
||||
const headers = Array.from(
|
||||
rows.reduce<Set<string>>((set, r) => {
|
||||
Object.keys(r).forEach((k) => set.add(k));
|
||||
return set;
|
||||
}, new Set()),
|
||||
);
|
||||
const headerHtml = headers.map((h) => `<th>${escapeHtml(h)}</th>`).join('');
|
||||
const bodyHtml = rows
|
||||
.map(
|
||||
(r) =>
|
||||
`<tr>${headers
|
||||
.map((h) => {
|
||||
const v = r[h];
|
||||
const cell = typeof v === 'object' && v !== null ? JSON.stringify(v) : v;
|
||||
return `<td>${escapeHtml(cell)}</td>`;
|
||||
})
|
||||
.join('')}</tr>`,
|
||||
)
|
||||
.join('');
|
||||
return `
|
||||
<section>
|
||||
<h2>${escapeHtml(title)} <small>(${rows.length})</small></h2>
|
||||
<table><thead><tr>${headerHtml}</tr></thead><tbody>${bodyHtml}</tbody></table>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the bundle as a self-contained HTML document — no external
|
||||
* resources, no JS — so it opens in any browser including offline.
|
||||
*/
|
||||
export function renderBundleHtml(bundle: GdprBundle): string {
|
||||
const clientName = String(bundle.client.fullName ?? bundle.meta.clientId ?? 'Unknown');
|
||||
const sections = [
|
||||
tableSection('Client', [bundle.client]),
|
||||
tableSection('Contacts', bundle.contacts),
|
||||
tableSection('Addresses', bundle.addresses),
|
||||
tableSection('Tags', bundle.tags as unknown as Record<string, unknown>[]),
|
||||
tableSection('Relationships', bundle.relationships),
|
||||
tableSection('Notes', bundle.notes),
|
||||
tableSection('Owned yachts', bundle.ownedYachts),
|
||||
tableSection(
|
||||
'Company memberships',
|
||||
bundle.companyMemberships.map((m) => ({
|
||||
...m.membership,
|
||||
companyName: m.company.name,
|
||||
companyLegalName: m.company.legalName,
|
||||
})),
|
||||
),
|
||||
tableSection('Interests', bundle.interests),
|
||||
tableSection('Reservations', bundle.reservations),
|
||||
tableSection('Invoices', bundle.invoices),
|
||||
tableSection('Documents', bundle.documents),
|
||||
tableSection('Audit trail (last 500 events)', bundle.auditTrail),
|
||||
].join('\n');
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Personal data export — ${escapeHtml(clientName)}</title>
|
||||
<style>
|
||||
body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif; margin: 2rem; max-width: 1200px; }
|
||||
h1 { border-bottom: 2px solid #333; padding-bottom: 0.5rem; }
|
||||
h2 { color: #1a3a5c; margin-top: 2rem; }
|
||||
small { font-weight: normal; color: #666; }
|
||||
.empty { color: #888; font-style: italic; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 0.5rem 0; font-size: 12px; }
|
||||
th, td { border: 1px solid #ddd; padding: 4px 8px; vertical-align: top; word-break: break-word; }
|
||||
th { background: #f0f4f8; text-align: left; }
|
||||
tr:nth-child(even) { background: #fafbfc; }
|
||||
.meta { background: #f0f4f8; padding: 1rem; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Personal data export</h1>
|
||||
<div class="meta">
|
||||
<div><strong>Client:</strong> ${escapeHtml(clientName)} <code>(${escapeHtml(bundle.meta.clientId)})</code></div>
|
||||
<div><strong>Generated:</strong> ${escapeHtml(bundle.meta.generatedAt)}</div>
|
||||
<div><strong>Schema version:</strong> ${escapeHtml(bundle.meta.schemaVersion)}</div>
|
||||
</div>
|
||||
${sections}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
Reference in New Issue
Block a user