{
+ // Resolve the recipient: explicit override beats primary contact.
+ let recipient = input.emailOverride;
+ if (!recipient) {
+ const primary = await db.query.clientContacts.findFirst({
+ where: and(
+ eq(clientContacts.clientId, input.clientId),
+ eq(clientContacts.channel, 'email'),
+ eq(clientContacts.isPrimary, true),
+ ),
+ });
+ recipient = primary?.value ?? null;
+ }
+ if (!recipient) {
+ logger.warn(
+ { exportId: input.exportId, clientId: input.clientId },
+ 'GDPR export ready but no email recipient — skipping send',
+ );
+ return;
+ }
+
+ const url = await getPresignedUrl(storageKey, PRESIGN_EXPIRY_SECONDS);
+ const client = await db.query.clients.findFirst({ where: eq(clients.id, input.clientId) });
+ const name = client?.fullName ?? 'there';
+ const expiry = new Date(Date.now() + PRESIGN_EXPIRY_SECONDS * 1000).toUTCString();
+
+ const subject = 'Your personal data export is ready';
+ const html = `
+ Hello ${escapeHtml(name)},
+ You requested a copy of the personal data we hold about you. The export is ready and contains:
+
+ client.json — machine-readable data dump
+ client.html — same data as a printable web page
+
+ Download the export (ZIP, expires ${escapeHtml(expiry)})
+ If you have any questions, reply to this email.
+ `;
+ const text = `Your personal data export is ready: ${url}\nThe link expires ${expiry}.`;
+
+ const { sendEmail } = await import('@/lib/email/index');
+ await sendEmail(recipient, subject, html, undefined, text, input.portId);
+
+ await db
+ .update(gdprExports)
+ .set({ status: 'sent', sentAt: new Date(), sentTo: recipient })
+ .where(eq(gdprExports.id, input.exportId));
+}
+
+function escapeHtml(s: unknown): string {
+ if (s === null || s === undefined) return '';
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/** Lists exports for a client (most-recent first) — feeds the admin "history" UI. */
+export async function listClientExports(clientId: string, portId: string) {
+ const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) });
+ if (!client || client.portId !== portId) throw new NotFoundError('Client');
+
+ return db.query.gdprExports.findMany({
+ where: eq(gdprExports.clientId, clientId),
+ orderBy: (t, { desc }) => [desc(t.createdAt)],
+ limit: 25,
+ });
+}
+
+/** Generates a fresh signed URL for an existing ready/sent export. */
+export async function getExportDownloadUrl(exportId: string, portId: string): Promise {
+ const row = await db.query.gdprExports.findFirst({
+ where: and(eq(gdprExports.id, exportId), eq(gdprExports.portId, portId)),
+ });
+ if (!row) throw new NotFoundError('Export');
+ if (!row.storageKey || (row.status !== 'ready' && row.status !== 'sent')) {
+ throw new ValidationError('Export is not ready to download');
+ }
+ return getPresignedUrl(row.storageKey, PRESIGN_EXPIRY_SECONDS);
+}
diff --git a/tests/integration/gdpr-export.test.ts b/tests/integration/gdpr-export.test.ts
new file mode 100644
index 0000000..692e667
--- /dev/null
+++ b/tests/integration/gdpr-export.test.ts
@@ -0,0 +1,200 @@
+import { describe, it, expect, vi, beforeAll } from 'vitest';
+import { eq } from 'drizzle-orm';
+
+import { db } from '@/lib/db';
+import {
+ clientAddresses,
+ clientContacts,
+ clientNotes,
+ clientTags,
+ tags,
+ gdprExports,
+} from '@/lib/db/schema';
+import { user } from '@/lib/db/schema/users';
+import { buildClientBundle, renderBundleHtml } from '@/lib/services/gdpr-bundle-builder';
+import { NotFoundError, ValidationError } from '@/lib/errors';
+import { makePort, makeClient, makeYacht } from '../helpers/factories';
+
+let TEST_USER_ID = '';
+
+beforeAll(async () => {
+ // Pull any existing user — gdpr_exports.requested_by has an FK that needs
+ // to resolve. Tests don't need the user to be specific; they just need it
+ // to exist.
+ const [u] = await db.select({ id: user.id }).from(user).limit(1);
+ if (!u) {
+ throw new Error('No user available; run pnpm db:seed first');
+ }
+ TEST_USER_ID = u.id;
+});
+
+const META = (portId: string) => ({
+ userId: TEST_USER_ID,
+ portId,
+ ipAddress: '127.0.0.1',
+ userAgent: 'vitest',
+});
+
+describe('buildClientBundle', () => {
+ it('aggregates client + contacts + addresses + tags + yachts', async () => {
+ const port = await makePort();
+ const client = await makeClient({
+ portId: port.id,
+ overrides: { fullName: 'Alice Test', nationalityIso: 'GB' },
+ });
+
+ await db.insert(clientContacts).values({
+ clientId: client.id,
+ channel: 'email',
+ value: 'alice@example.com',
+ isPrimary: true,
+ });
+ await db.insert(clientAddresses).values({
+ clientId: client.id,
+ portId: port.id,
+ label: 'Home',
+ streetAddress: '1 Pier Way',
+ countryIso: 'GB',
+ isPrimary: true,
+ });
+ await db.insert(clientNotes).values({
+ clientId: client.id,
+ authorId: 'tester',
+ content: 'Met at boat show',
+ });
+ const [tagRow] = await db
+ .insert(tags)
+ .values({ portId: port.id, name: 'VIP', color: '#ff0000' })
+ .returning();
+ await db.insert(clientTags).values({ clientId: client.id, tagId: tagRow!.id });
+ await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id });
+
+ const bundle = await buildClientBundle(client.id, port.id);
+
+ expect(bundle.client.fullName).toBe('Alice Test');
+ expect(bundle.contacts).toHaveLength(1);
+ expect(bundle.addresses).toHaveLength(1);
+ expect(bundle.notes).toHaveLength(1);
+ expect(bundle.tags).toHaveLength(1);
+ expect(bundle.tags[0]?.name).toBe('VIP');
+ expect(bundle.ownedYachts).toHaveLength(1);
+ expect(bundle.meta.clientId).toBe(client.id);
+ expect(bundle.meta.portId).toBe(port.id);
+ expect(bundle.meta.schemaVersion).toBe(1);
+ });
+
+ it('throws NotFoundError when accessed cross-tenant', async () => {
+ const portA = await makePort();
+ const portB = await makePort();
+ const client = await makeClient({ portId: portA.id });
+
+ await expect(buildClientBundle(client.id, portB.id)).rejects.toThrow(NotFoundError);
+ });
+
+ it('returns empty arrays when the client has no related rows', async () => {
+ const port = await makePort();
+ const client = await makeClient({ portId: port.id });
+
+ const bundle = await buildClientBundle(client.id, port.id);
+ expect(bundle.contacts).toEqual([]);
+ expect(bundle.addresses).toEqual([]);
+ expect(bundle.tags).toEqual([]);
+ expect(bundle.notes).toEqual([]);
+ expect(bundle.ownedYachts).toEqual([]);
+ expect(bundle.companyMemberships).toEqual([]);
+ expect(bundle.invoices).toEqual([]);
+ });
+});
+
+describe('renderBundleHtml', () => {
+ it('produces a self-contained HTML doc with section headings', async () => {
+ const port = await makePort();
+ const client = await makeClient({
+ portId: port.id,
+ overrides: { fullName: 'Render Test' },
+ });
+ const bundle = await buildClientBundle(client.id, port.id);
+ const html = renderBundleHtml(bundle);
+ expect(html.startsWith('')).toBe(true);
+ expect(html).toContain('Render Test');
+ expect(html).toContain('Personal data export');
+ expect(html).toContain('Contacts');
+ expect(html).toContain('Addresses');
+ expect(html).toContain('Audit trail');
+ // No external requests.
+ expect(html).not.toMatch(/https?:\/\/[^"'\s]+\.(?:js|css)/);
+ });
+
+ it('escapes HTML in client field values to prevent injection', async () => {
+ const port = await makePort();
+ const client = await makeClient({
+ portId: port.id,
+ overrides: { fullName: '' },
+ });
+ const bundle = await buildClientBundle(client.id, port.id);
+ const html = renderBundleHtml(bundle);
+ // The literal "');
+ expect(html).toContain('<script>');
+ });
+});
+
+describe('requestGdprExport', () => {
+ it('creates a pending row and queues a job', async () => {
+ // Stub the BullMQ queue so we don't actually push jobs to Redis here.
+ const add = vi.fn().mockResolvedValue({ id: 'mock-job' });
+ vi.doMock('@/lib/queue', () => ({ getQueue: () => ({ add }) }));
+ const { requestGdprExport } = await import('@/lib/services/gdpr-export.service');
+
+ const port = await makePort();
+ const client = await makeClient({ portId: port.id });
+ await db.insert(clientContacts).values({
+ clientId: client.id,
+ channel: 'email',
+ value: 'p@example.com',
+ isPrimary: true,
+ });
+
+ const { export: row } = await requestGdprExport({
+ ...META(port.id),
+ clientId: client.id,
+ requestedBy: TEST_USER_ID,
+ emailToClient: true,
+ });
+ expect(row.status).toBe('pending');
+ expect(row.clientId).toBe(client.id);
+ expect(add).toHaveBeenCalledWith(
+ 'gdpr-export',
+ expect.objectContaining({ exportId: row.id, emailToClient: true }),
+ );
+
+ // Cleanup the mock so other tests don't see a stubbed queue.
+ vi.doUnmock('@/lib/queue');
+
+ const persisted = await db.query.gdprExports.findFirst({
+ where: eq(gdprExports.id, row.id),
+ });
+ expect(persisted?.requestedBy).toBe(TEST_USER_ID);
+ });
+
+ it('refuses when emailToClient=true but no primary email exists and no override', async () => {
+ vi.doMock('@/lib/queue', () => ({
+ getQueue: () => ({ add: vi.fn().mockResolvedValue({ id: 'mock' }) }),
+ }));
+ const { requestGdprExport } = await import('@/lib/services/gdpr-export.service');
+
+ const port = await makePort();
+ const client = await makeClient({ portId: port.id });
+
+ await expect(
+ requestGdprExport({
+ ...META(port.id),
+ clientId: client.id,
+ requestedBy: TEST_USER_ID,
+ emailToClient: true,
+ }),
+ ).rejects.toThrow(ValidationError);
+
+ vi.doUnmock('@/lib/queue');
+ });
+});