Files
pn-new-crm/src/lib/services/gdpr-bundle-builder.ts

418 lines
15 KiB
TypeScript
Raw Normal View History

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
* 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, inArray, or, sql } from 'drizzle-orm';
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
import { db } from '@/lib/db';
import { NotFoundError } from '@/lib/errors';
import {
clients,
clientContacts,
clientAddresses,
clientNotes,
clientRelationships,
clientTags,
clientMergeLog,
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
} from '@/lib/db/schema/clients';
import { tags, scratchpadNotes } from '@/lib/db/schema/system';
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
import { companies, companyMemberships } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { interests } from '@/lib/db/schema/interests';
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
import { berthTenancies } from '@/lib/db/schema/tenancies';
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
import { invoices } from '@/lib/db/schema/financial';
import { documents, files, formSubmissions } from '@/lib/db/schema/documents';
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
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';
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 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>[];
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
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>[];
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
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,
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
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)),
}),
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
db.query.berthTenancies.findMany({
where: and(eq(berthTenancies.clientId, clientId), eq(berthTenancies.portId, portId)),
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
}),
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),
),
),
}),
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
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),
),
)
: [];
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
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),
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,
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),
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
};
}
/**
* 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, '&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.
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),
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('Contact log', bundle.contactLog),
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('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),
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('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>
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>`;
}