/** * 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, inArray, or, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { NotFoundError } from '@/lib/errors'; import { clients, clientContacts, clientAddresses, clientNotes, clientRelationships, clientTags, clientMergeLog, } from '@/lib/db/schema/clients'; import { tags, scratchpadNotes } 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, files, formSubmissions } from '@/lib/db/schema/documents'; import { auditLogs } from '@/lib/db/schema/system'; import { portalUsers } from '@/lib/db/schema/portal'; import { emailThreads, emailMessages } from '@/lib/db/schema/email'; import { reminders, interestContactLog } from '@/lib/db/schema/operations'; import { documentSends } from '@/lib/db/schema/brochures'; import { websiteSubmissions } from '@/lib/db/schema/website-submissions'; export interface GdprBundle { /** Bundle metadata for traceability. */ meta: { generatedAt: string; portId: string; clientId: string; schemaVersion: 1; }; client: Record; contacts: Record[]; addresses: Record[]; tags: Array<{ id: string; name: string; color: string }>; relationships: Record[]; notes: Record[]; ownedYachts: Record[]; companyMemberships: Array<{ membership: Record; company: Record; }>; interests: Record[]; contactLog: Record[]; reservations: Record[]; invoices: Record[]; documents: Record[]; files: Record[]; formSubmissions: Record[]; websiteSubmissions: Record[]; documentSends: Record[]; emailThreads: Array<{ thread: Record; messages: Record[]; }>; reminders: Record[]; scratchpadNotes: Record[]; portalUsers: Record[]; mergeLog: Record[]; auditTrail: Record[]; } /** * 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 { 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, fileRows, formSubmissionRows, documentSendRows, threadRows, reminderRows, scratchpadRows, portalUserRows, mergeLogRows, 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.files.findMany({ where: and(eq(files.portId, portId), eq(files.clientId, clientId)), }), db.query.formSubmissions.findMany({ where: eq(formSubmissions.clientId, clientId), }), db.query.documentSends.findMany({ where: and(eq(documentSends.portId, portId), eq(documentSends.clientId, clientId)), }), db.query.emailThreads.findMany({ where: and(eq(emailThreads.portId, portId), eq(emailThreads.clientId, clientId)), }), db.query.reminders.findMany({ where: and(eq(reminders.portId, portId), eq(reminders.clientId, clientId)), }), db.query.scratchpadNotes.findMany({ where: eq(scratchpadNotes.linkedClientId, clientId), }), db.query.portalUsers.findMany({ where: eq(portalUsers.clientId, clientId) }), db.query.clientMergeLog.findMany({ where: and( eq(clientMergeLog.portId, portId), or( eq(clientMergeLog.survivingClientId, clientId), eq(clientMergeLog.mergedClientId, 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, }), ]); // Email messages are linked through threads (no direct clientId column). const threadIds = threadRows.map((t) => t.id); const messageRows = threadIds.length ? await db.query.emailMessages.findMany({ where: inArray(emailMessages.threadId, threadIds), orderBy: (t, { asc }) => [asc(t.sentAt)], }) : []; const messagesByThread = new Map[]>(); for (const m of messageRows) { const list = messagesByThread.get(m.threadId) ?? []; list.push(toJsonRow(m)); messagesByThread.set(m.threadId, list); } const emailThreadBundle = threadRows.map((t) => ({ thread: toJsonRow(t), messages: messagesByThread.get(t.id) ?? [], })); // Interest contact-log has no clientId - fetch via the client's interests. const interestIds = interestRows.map((i) => i.id); const contactLogRows = interestIds.length ? await db.query.interestContactLog.findMany({ where: and( eq(interestContactLog.portId, portId), inArray(interestContactLog.interestId, interestIds), ), orderBy: (t, { desc }) => [desc(t.occurredAt)], }) : []; // Website submissions pre-date the client record (no FK). Match by any // of the client's email contacts against payload->>'email' (case- // insensitive) so the bundle includes inquiry forms that became this // client. const emailValues = contacts .filter((c) => c.channel === 'email' && c.value) .map((c) => c.value.toLowerCase()); const websiteSubmissionRows = emailValues.length ? await db .select() .from(websiteSubmissions) .where( and( eq(websiteSubmissions.portId, portId), inArray(sql`LOWER(${websiteSubmissions.payload}->>'email')`, emailValues), ), ) : []; return { meta: { generatedAt: new Date().toISOString(), portId, clientId, schemaVersion: 1, }, // Drizzle row types contain non-`unknown` value types (Date, branded // strings). The bundle is exported as JSON, so we widen to plain // `Record` here. `toJsonRow` performs the narrow → wide // widening in a single, locally-typed step instead of the prior // `as unknown as Record` double-cast. client: toJsonRow(client), contacts: contacts.map(toJsonRow), addresses: addresses.map(toJsonRow), tags: tagJoins, 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), contactLog: contactLogRows.map(toJsonRow), reservations: reservationRows.map(toJsonRow), invoices: invoiceRows.map(toJsonRow), documents: documentRows.map(toJsonRow), files: fileRows.map(toJsonRow), formSubmissions: formSubmissionRows.map(toJsonRow), websiteSubmissions: websiteSubmissionRows.map(toJsonRow), documentSends: documentSendRows.map(toJsonRow), emailThreads: emailThreadBundle, reminders: reminderRows.map(toJsonRow), scratchpadNotes: scratchpadRows.map(toJsonRow), portalUsers: portalUserRows.map(toJsonRow), mergeLog: mergeLogRows.map(toJsonRow), auditTrail: auditRows.map(toJsonRow), }; } /** * Widens a Drizzle row type to `Record` 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(row: T): Record { return row as Record; } // ─── HTML rendering ────────────────────────────────────────────────────────── function escapeHtml(s: unknown): string { if (s === null || s === undefined) return ''; return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function tableSection(title: string, rows: Record[]): string { if (rows.length === 0) { return `

${escapeHtml(title)}

No records.

`; } const headers = Array.from( rows.reduce>((set, r) => { Object.keys(r).forEach((k) => set.add(k)); return set; }, new Set()), ); const headerHtml = headers.map((h) => `${escapeHtml(h)}`).join(''); const bodyHtml = rows .map( (r) => `${headers .map((h) => { const v = r[h]; const cell = typeof v === 'object' && v !== null ? JSON.stringify(v) : v; return `${escapeHtml(cell)}`; }) .join('')}`, ) .join(''); return `

${escapeHtml(title)} (${rows.length})

${headerHtml}${bodyHtml}
`; } /** * 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.map(toJsonRow)), 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('Contact log', bundle.contactLog), tableSection('Reservations', bundle.reservations), tableSection('Invoices', bundle.invoices), tableSection('Documents', bundle.documents), tableSection('Files', bundle.files), tableSection('Form submissions', bundle.formSubmissions), tableSection('Website submissions (inquiry forms)', bundle.websiteSubmissions), tableSection('Document sends (PDFs / brochures emailed)', bundle.documentSends), tableSection( 'Email threads', bundle.emailThreads.map((t) => ({ ...t.thread, messageCount: t.messages.length, })), ), tableSection( 'Email messages', bundle.emailThreads.flatMap((t) => t.messages.map((m) => ({ threadId: t.thread.id, ...m }))), ), tableSection('Reminders', bundle.reminders), tableSection('Scratchpad notes', bundle.scratchpadNotes), tableSection('Portal users', bundle.portalUsers), tableSection('Merge log', bundle.mergeLog), tableSection('Audit trail (last 500 events)', bundle.auditTrail), ].join('\n'); return ` Personal data export - ${escapeHtml(clientName)}

Personal data export

Client: ${escapeHtml(clientName)} (${escapeHtml(bundle.meta.clientId)})
Generated: ${escapeHtml(bundle.meta.generatedAt)}
Schema version: ${escapeHtml(bundle.meta.schemaVersion)}
${sections} `; }