268 lines
9.4 KiB
TypeScript
268 lines
9.4 KiB
TypeScript
|
|
/**
|
||
|
|
* 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>`;
|
||
|
|
}
|