Files
pn-new-crm/src/lib/services/gdpr-bundle-builder.ts
Matt ccc775dc66 feat(tenancies-p2): rename berth_reservations → berth_tenancies (schema + perms + UI)
73-file atomic rename per docs/tenancies-design.md:

- Migration 0085: rename table + indexes + FK constraints; rename
  documents.reservation_id → tenancy_id; migrate jsonb permission maps
  (reservations resource → tenancies; collapse create+activate → manage);
  rewrite historical audit_logs.entity_type='berth_reservation' →
  'berth_tenancy'. FK renames wrapped in DO blocks so dev DBs that pre-date
  the FK additions don't abort.
- Schema: berthReservations → berthTenancies; BerthReservation type →
  BerthTenancy; indexes idx_br_* / idx_brr_* → idx_bt_*.
- RolePermissions: resource { view, create, activate, cancel } collapses to
  { view, manage, cancel }; all 8 default seed bundles + role-form + matrix
  updated.
- Service: berth-reservations.service.ts → berth-tenancies.service.ts;
  endReservation → endTenancy; listReservations → listTenancies.
- API: /api/v1/berth-reservations → /api/v1/tenancies (+ nested [id]);
  /api/v1/berths/[id]/reservations → /api/v1/berths/[id]/tenancies.
- Validators: reservations.ts → tenancies.ts; RESERVATION_STATUSES →
  TENANCY_STATUSES; endReservationSchema → endTenancySchema.
- Routes: /{portSlug}/berth-reservations → /{portSlug}/tenancies;
  /portal/my-reservations → /portal/my-tenancies.
- Components: src/components/reservations/* → src/components/tenancies/*;
  BerthReservationsTab → BerthTenanciesTab; ClientReservationsTab →
  ClientTenanciesTab; ReservationList → TenancyList.
- Socket events: berth_reservation:* → berth_tenancy:*; payload
  reservationId → tenancyId.
- Webhook events: berth_reservation.* → berth_tenancy.*.
- Portal: getPortalUserReservations → getPortalUserTenancies;
  PortalReservation → PortalTenancy; PortalDashboard.counts.activeReservations
  → activeTenancies; PortalNav label "Reservations" → "Tenancies".
- Dossier: DossierReservation → DossierTenancy; reservationDecisions →
  tenancyDecisions across smart-archive-dialog + bulk-archive routes.
- Documents schema: documents.reservationId → documents.tenancyId
  (TS + DB column + index + FK constraint).
- Activity feed label berth_reservation → berth_tenancy (matched against
  migrated historical audit rows).

KEPT (separate concepts):
- Reservation Agreement document type (the contract sent to clients).
- "Reservation" pipeline stage name.
- {{reservation.*}} merge tokens in template authoring.
- interest.reservationStatus / reservationDocStatus / dateReservationSent
  fields (track agreement signing on the deal).
- reservation-agreement-context.ts service (builds merge context for the
  Reservation Agreement doc; only its DB imports were renamed).

Verified: tsc clean, 1480/1480 vitest passing, migration applied.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:09:35 +02:00

418 lines
15 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, 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 { berthTenancies } from '@/lib/db/schema/tenancies';
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<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>[];
contactLog: Record<string, unknown>[];
reservations: Record<string, unknown>[];
invoices: Record<string, unknown>[];
documents: Record<string, unknown>[];
files: Record<string, unknown>[];
formSubmissions: Record<string, unknown>[];
websiteSubmissions: Record<string, unknown>[];
documentSends: Record<string, unknown>[];
emailThreads: Array<{
thread: Record<string, unknown>;
messages: Record<string, unknown>[];
}>;
reminders: Record<string, unknown>[];
scratchpadNotes: Record<string, unknown>[];
portalUsers: Record<string, unknown>[];
mergeLog: 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,
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.berthTenancies.findMany({
where: and(eq(berthTenancies.clientId, clientId), eq(berthTenancies.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<string, Record<string, unknown>[]>();
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<string>`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<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),
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<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>;
}
// ─── 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.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 `<!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>`;
}