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
|
2026-05-04 22:57:01 +02:00
|
|
|
* GDPR client-data export. Pure read-side - no writes, no I/O outside
|
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
|
|
|
* 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,
|
|
|
|
|
},
|
2026-04-29 02:03:10 +02:00
|
|
|
// Drizzle row types contain non-`unknown` value types (Date, branded
|
|
|
|
|
// strings). The bundle is exported as JSON, so we widen to plain
|
|
|
|
|
// `Record<string, unknown>` here. `toJsonRow` performs the narrow → wide
|
|
|
|
|
// widening in a single, locally-typed step instead of the prior
|
|
|
|
|
// `as unknown as Record<string, unknown>` double-cast.
|
|
|
|
|
client: toJsonRow(client),
|
|
|
|
|
contacts: contacts.map(toJsonRow),
|
|
|
|
|
addresses: addresses.map(toJsonRow),
|
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
|
|
|
tags: tagJoins,
|
2026-04-29 02:03:10 +02:00
|
|
|
relationships: relationships.map(toJsonRow),
|
|
|
|
|
notes: notes.map(toJsonRow),
|
|
|
|
|
ownedYachts: ownedYachts.map(toJsonRow),
|
|
|
|
|
companyMemberships: membershipRows.map((row) => ({
|
|
|
|
|
membership: toJsonRow(row.membership),
|
|
|
|
|
company: toJsonRow(row.company),
|
|
|
|
|
})),
|
|
|
|
|
interests: interestRows.map(toJsonRow),
|
|
|
|
|
reservations: reservationRows.map(toJsonRow),
|
|
|
|
|
invoices: invoiceRows.map(toJsonRow),
|
|
|
|
|
documents: documentRows.map(toJsonRow),
|
|
|
|
|
auditTrail: auditRows.map(toJsonRow),
|
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
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 02:03:10 +02:00
|
|
|
/**
|
|
|
|
|
* Widens a Drizzle row type to `Record<string, unknown>` for inclusion in
|
|
|
|
|
* the JSON bundle. Drizzle row types are narrower than the open record
|
|
|
|
|
* shape we want; this helper does the widening in one place rather than
|
|
|
|
|
* scattering double-casts across the call sites.
|
|
|
|
|
*/
|
|
|
|
|
function toJsonRow<T extends object>(row: T): Record<string, unknown> {
|
|
|
|
|
return row as Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// ─── 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>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-04 22:57:01 +02:00
|
|
|
* Renders the bundle as a self-contained HTML document - no external
|
|
|
|
|
* resources, no JS - so it opens in any browser including offline.
|
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
|
|
|
*/
|
|
|
|
|
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),
|
2026-04-29 02:03:10 +02:00
|
|
|
tableSection('Tags', bundle.tags.map(toJsonRow)),
|
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
|
|
|
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" />
|
2026-05-04 22:57:01 +02:00
|
|
|
<title>Personal data export - ${escapeHtml(clientName)}</title>
|
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
|
|
|
<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>`;
|
|
|
|
|
}
|