Files
pn-new-crm/src/lib/services/gdpr-bundle-builder.ts

268 lines
9.4 KiB
TypeScript
Raw Normal View History

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>
2026-04-28 20:06:31 +02:00
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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>`;
}