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>
This commit is contained in:
@@ -52,6 +52,7 @@
|
|||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"@tanstack/react-query-devtools": "^5.62.0",
|
"@tanstack/react-query-devtools": "^5.62.0",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"better-auth": "^1.2.0",
|
"better-auth": "^1.2.0",
|
||||||
"bullmq": "^5.25.0",
|
"bullmq": "^5.25.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
@@ -92,6 +93,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.5",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/iso-3166-2": "^1.0.4",
|
"@types/iso-3166-2": "^1.0.4",
|
||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
506
pnpm-lock.yaml
generated
506
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
24
src/app/api/v1/clients/[id]/gdpr-export/[exportId]/route.ts
Normal file
24
src/app/api/v1/clients/[id]/gdpr-export/[exportId]/route.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { getExportDownloadUrl } from '@/lib/services/gdpr-export.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a fresh signed URL for an existing GDPR export. Staff use this
|
||||||
|
* from the admin UI; the email path embeds its own signed URL.
|
||||||
|
*/
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission(
|
||||||
|
'admin',
|
||||||
|
'manage_settings',
|
||||||
|
withRateLimit('exports', async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const url = await getExportDownloadUrl(params.exportId!, ctx.portId);
|
||||||
|
return NextResponse.json({ data: { url } });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
49
src/app/api/v1/clients/[id]/gdpr-export/route.ts
Normal file
49
src/app/api/v1/clients/[id]/gdpr-export/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, withRateLimit } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { requestGdprExport, listClientExports } from '@/lib/services/gdpr-export.service';
|
||||||
|
|
||||||
|
const requestSchema = z.object({
|
||||||
|
/** When true, the bundle is emailed to the client once it finishes building. */
|
||||||
|
emailToClient: z.boolean().optional().default(false),
|
||||||
|
/** Optional override recipient (e.g. legal counsel). Skips the primary-email lookup. */
|
||||||
|
emailOverride: z.string().email().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GET = withAuth(
|
||||||
|
withPermission('clients', 'view', async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const rows = await listClientExports(params.id!, ctx.portId);
|
||||||
|
return NextResponse.json({ data: rows });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const POST = withAuth(
|
||||||
|
withPermission(
|
||||||
|
'admin',
|
||||||
|
'manage_settings',
|
||||||
|
withRateLimit('exports', async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
const body = await parseBody(req, requestSchema);
|
||||||
|
const result = await requestGdprExport({
|
||||||
|
clientId: params.id!,
|
||||||
|
portId: ctx.portId,
|
||||||
|
requestedBy: ctx.userId,
|
||||||
|
emailToClient: body.emailToClient,
|
||||||
|
emailOverride: body.emailOverride ?? null,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: result.export }, { status: 202 });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -10,6 +10,7 @@ import { TagBadge } from '@/components/shared/tag-badge';
|
|||||||
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
|
||||||
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
|
||||||
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
|
import { PortalInviteButton } from '@/components/clients/portal-invite-button';
|
||||||
|
import { GdprExportButton } from '@/components/clients/gdpr-export-button';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
interface ClientDetailHeaderProps {
|
interface ClientDetailHeaderProps {
|
||||||
@@ -122,6 +123,7 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
|
|||||||
defaultEmail={primaryEmail?.value}
|
defaultEmail={primaryEmail?.value}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<GdprExportButton clientId={client.id} />
|
||||||
<Button
|
<Button
|
||||||
variant={isArchived ? 'outline' : 'outline'}
|
variant={isArchived ? 'outline' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
207
src/components/clients/gdpr-export-button.tsx
Normal file
207
src/components/clients/gdpr-export-button.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Download, FileDown, Loader2, Mail } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { usePermissions } from '@/hooks/use-permissions';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
interface ExportRow {
|
||||||
|
id: string;
|
||||||
|
status: 'pending' | 'building' | 'ready' | 'sent' | 'failed';
|
||||||
|
storageKey: string | null;
|
||||||
|
sizeBytes: number | null;
|
||||||
|
createdAt: string;
|
||||||
|
readyAt: string | null;
|
||||||
|
sentAt: string | null;
|
||||||
|
sentTo: string | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListResp {
|
||||||
|
data: ExportRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<ExportRow['status'], 'secondary' | 'outline' | 'destructive'> = {
|
||||||
|
pending: 'outline',
|
||||||
|
building: 'outline',
|
||||||
|
ready: 'secondary',
|
||||||
|
sent: 'secondary',
|
||||||
|
failed: 'destructive',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GdprExportButton({ clientId }: { clientId: string }) {
|
||||||
|
const { can, isSuperAdmin } = usePermissions();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [emailToClient, setEmailToClient] = useState(false);
|
||||||
|
const [emailOverride, setEmailOverride] = useState('');
|
||||||
|
|
||||||
|
const allowed = isSuperAdmin || can('admin', 'manage_settings');
|
||||||
|
|
||||||
|
const queryKey = ['gdpr-exports', clientId];
|
||||||
|
const { data, isLoading } = useQuery<ListResp>({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => apiFetch<ListResp>(`/api/v1/clients/${clientId}/gdpr-export`),
|
||||||
|
enabled: open && allowed,
|
||||||
|
refetchInterval: open && allowed ? 5_000 : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiFetch(`/api/v1/clients/${clientId}/gdpr-export`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
emailToClient,
|
||||||
|
emailOverride: emailOverride.trim() || null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Export queued — refresh in ~30 seconds');
|
||||||
|
qc.invalidateQueries({ queryKey });
|
||||||
|
setEmailOverride('');
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to queue export');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!allowed) return null;
|
||||||
|
|
||||||
|
async function downloadById(exportId: string) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch<{ data: { url: string } }>(
|
||||||
|
`/api/v1/clients/${clientId}/gdpr-export/${exportId}`,
|
||||||
|
);
|
||||||
|
window.open(res.data.url, '_blank', 'noopener');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to fetch download URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = data?.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<FileDown className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
GDPR export
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Personal data export</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Bundles every record we hold about this client (profile, contacts, addresses, yachts,
|
||||||
|
companies, interests, reservations, invoices, documents, audit log) into a ZIP with JSON
|
||||||
|
and HTML copies. Used to satisfy GDPR Article 15 access requests.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||||
|
<Checkbox
|
||||||
|
id="email-to-client"
|
||||||
|
checked={emailToClient}
|
||||||
|
onCheckedChange={(v) => setEmailToClient(v === true)}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2 flex-1 min-w-0">
|
||||||
|
<Label htmlFor="email-to-client" className="text-sm font-medium">
|
||||||
|
Email the bundle when ready
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Sends a 7-day signed download link to the client's primary email — or to the
|
||||||
|
override below.
|
||||||
|
</p>
|
||||||
|
{emailToClient ? (
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="optional override (defaults to primary contact)"
|
||||||
|
value={emailOverride}
|
||||||
|
onChange={(e) => setEmailOverride(e.target.value)}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={() => request.mutate()} disabled={request.isPending}>
|
||||||
|
{request.isPending ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileDown className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Queue export
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-2">Recent exports</h4>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No exports yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="text-sm divide-y border rounded-lg">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<li key={r.id} className="flex items-center gap-2 py-2 px-3 hover:bg-muted/50">
|
||||||
|
<Badge variant={STATUS_VARIANT[r.status]} className="capitalize text-xs">
|
||||||
|
{r.status}
|
||||||
|
</Badge>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs">
|
||||||
|
Requested {format(new Date(r.createdAt), 'MMM d, yyyy HH:mm')}
|
||||||
|
</div>
|
||||||
|
{r.sentTo ? (
|
||||||
|
<div className="text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||||
|
<Mail className="h-3 w-3" />
|
||||||
|
Sent to {r.sentTo}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{r.error ? (
|
||||||
|
<div className="text-xs text-destructive truncate">{r.error}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{(r.status === 'ready' || r.status === 'sent') && r.storageKey ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => downloadById(r.id)}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ export type AuditAction =
|
|||||||
| 'permission_denied'
|
| 'permission_denied'
|
||||||
| 'revert'
|
| 'revert'
|
||||||
| 'revoke_invite'
|
| 'revoke_invite'
|
||||||
| 'resend_invite';
|
| 'resend_invite'
|
||||||
|
| 'request_gdpr_export'
|
||||||
|
| 'send_gdpr_export';
|
||||||
|
|
||||||
export interface AuditLogParams {
|
export interface AuditLogParams {
|
||||||
/** Null for system-generated events. */
|
/** Null for system-generated events. */
|
||||||
|
|||||||
21
src/lib/db/migrations/0018_stormy_spencer_smythe.sql
Normal file
21
src/lib/db/migrations/0018_stormy_spencer_smythe.sql
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
CREATE TABLE "gdpr_exports" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"port_id" text NOT NULL,
|
||||||
|
"client_id" text NOT NULL,
|
||||||
|
"requested_by" text NOT NULL,
|
||||||
|
"status" text DEFAULT 'pending' NOT NULL,
|
||||||
|
"storage_key" text,
|
||||||
|
"size_bytes" integer,
|
||||||
|
"error" text,
|
||||||
|
"sent_to" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"ready_at" timestamp with time zone,
|
||||||
|
"sent_at" timestamp with time zone,
|
||||||
|
"expires_at" timestamp with time zone
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "gdpr_exports" ADD CONSTRAINT "gdpr_exports_port_id_ports_id_fk" FOREIGN KEY ("port_id") REFERENCES "public"."ports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "gdpr_exports" ADD CONSTRAINT "gdpr_exports_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "gdpr_exports" ADD CONSTRAINT "gdpr_exports_requested_by_user_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_gdpr_exports_client" ON "gdpr_exports" USING btree ("client_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_gdpr_exports_port_created" ON "gdpr_exports" USING btree ("port_id","created_at");
|
||||||
10158
src/lib/db/migrations/meta/0018_snapshot.json
Normal file
10158
src/lib/db/migrations/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,13 @@
|
|||||||
"when": 1777398450555,
|
"when": 1777398450555,
|
||||||
"tag": "0017_tiny_mercury",
|
"tag": "0017_tiny_mercury",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1777399135032,
|
||||||
|
"tag": "0018_stormy_spencer_smythe",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/lib/db/schema/gdpr.ts
Normal file
56
src/lib/db/schema/gdpr.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* GDPR client-data export tracking.
|
||||||
|
*
|
||||||
|
* Each row is one export request. The actual bundle (a ZIP holding
|
||||||
|
* `client.json` + `client.html` and a copy of every attached file)
|
||||||
|
* lives in MinIO; we keep the storage key here plus the lifecycle
|
||||||
|
* markers needed for audit + the "download history" UI.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pgTable, text, timestamp, integer, index } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
import { ports } from './ports';
|
||||||
|
import { clients } from './clients';
|
||||||
|
import { user } from './users';
|
||||||
|
|
||||||
|
export const gdprExports = pgTable(
|
||||||
|
'gdpr_exports',
|
||||||
|
{
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => crypto.randomUUID()),
|
||||||
|
portId: text('port_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||||
|
clientId: text('client_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => clients.id, { onDelete: 'cascade' }),
|
||||||
|
/** Staff member who requested the export. */
|
||||||
|
requestedBy: text('requested_by')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'restrict' }),
|
||||||
|
/** 'pending' | 'building' | 'ready' | 'sent' | 'failed' */
|
||||||
|
status: text('status').notNull().default('pending'),
|
||||||
|
/** MinIO path under the configured bucket — null until the worker uploads. */
|
||||||
|
storageKey: text('storage_key'),
|
||||||
|
sizeBytes: integer('size_bytes'),
|
||||||
|
/** When status='failed', the truncated error message. */
|
||||||
|
error: text('error'),
|
||||||
|
/** Email recipient if the bundle was emailed (typically the client's primary). */
|
||||||
|
sentTo: text('sent_to'),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
readyAt: timestamp('ready_at', { withTimezone: true }),
|
||||||
|
sentAt: timestamp('sent_at', { withTimezone: true }),
|
||||||
|
/** Cleanup target — bundles are removed from MinIO after this. */
|
||||||
|
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index('idx_gdpr_exports_client').on(table.clientId),
|
||||||
|
index('idx_gdpr_exports_port_created').on(table.portId, table.createdAt),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type GdprExport = typeof gdprExports.$inferSelect;
|
||||||
|
export type NewGdprExport = typeof gdprExports.$inferInsert;
|
||||||
|
|
||||||
|
export type GdprExportStatus = 'pending' | 'building' | 'ready' | 'sent' | 'failed';
|
||||||
@@ -53,5 +53,8 @@ export * from './insights';
|
|||||||
// AI usage ledger (Phase 3b)
|
// AI usage ledger (Phase 3b)
|
||||||
export * from './ai-usage';
|
export * from './ai-usage';
|
||||||
|
|
||||||
|
// GDPR export tracking (Phase 3d)
|
||||||
|
export * from './gdpr';
|
||||||
|
|
||||||
// Relations (must come last — references all tables)
|
// Relations (must come last — references all tables)
|
||||||
export * from './relations';
|
export * from './relations';
|
||||||
|
|||||||
@@ -8,10 +8,22 @@ export const exportWorker = new Worker(
|
|||||||
'export',
|
'export',
|
||||||
async (job: Job) => {
|
async (job: Job) => {
|
||||||
logger.info({ jobId: job.id, jobName: job.name }, 'Processing export job');
|
logger.info({ jobId: job.id, jobName: job.name }, 'Processing export job');
|
||||||
// TODO(L2): implement export job handlers
|
switch (job.name) {
|
||||||
// - CSV data export
|
case 'gdpr-export': {
|
||||||
// - PDF export
|
const data = job.data as {
|
||||||
// - Parent company report export
|
exportId: string;
|
||||||
|
portId: string;
|
||||||
|
clientId: string;
|
||||||
|
emailToClient: boolean;
|
||||||
|
emailOverride: string | null;
|
||||||
|
};
|
||||||
|
const { processGdprExportJob } = await import('@/lib/services/gdpr-export.service');
|
||||||
|
await processGdprExportJob(data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
logger.warn({ jobName: job.name }, 'Unknown export job');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connection: { url: process.env.REDIS_URL! } as ConnectionOptions,
|
connection: { url: process.env.REDIS_URL! } as ConnectionOptions,
|
||||||
|
|||||||
267
src/lib/services/gdpr-bundle-builder.ts
Normal file
267
src/lib/services/gdpr-bundle-builder.ts
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* 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, or } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { NotFoundError } from '@/lib/errors';
|
||||||
|
import {
|
||||||
|
clients,
|
||||||
|
clientContacts,
|
||||||
|
clientAddresses,
|
||||||
|
clientNotes,
|
||||||
|
clientRelationships,
|
||||||
|
clientTags,
|
||||||
|
} from '@/lib/db/schema/clients';
|
||||||
|
import { tags } from '@/lib/db/schema/system';
|
||||||
|
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||||
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
|
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||||
|
import { invoices } from '@/lib/db/schema/financial';
|
||||||
|
import { documents } from '@/lib/db/schema/documents';
|
||||||
|
import { auditLogs } from '@/lib/db/schema/system';
|
||||||
|
|
||||||
|
export interface GdprBundle {
|
||||||
|
/** Bundle metadata for traceability. */
|
||||||
|
meta: {
|
||||||
|
generatedAt: string;
|
||||||
|
portId: string;
|
||||||
|
clientId: string;
|
||||||
|
schemaVersion: 1;
|
||||||
|
};
|
||||||
|
client: Record<string, unknown>;
|
||||||
|
contacts: Record<string, unknown>[];
|
||||||
|
addresses: Record<string, unknown>[];
|
||||||
|
tags: Array<{ id: string; name: string; color: string }>;
|
||||||
|
relationships: Record<string, unknown>[];
|
||||||
|
notes: Record<string, unknown>[];
|
||||||
|
ownedYachts: Record<string, unknown>[];
|
||||||
|
companyMemberships: Array<{
|
||||||
|
membership: Record<string, unknown>;
|
||||||
|
company: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
interests: Record<string, unknown>[];
|
||||||
|
reservations: Record<string, unknown>[];
|
||||||
|
invoices: Record<string, unknown>[];
|
||||||
|
documents: Record<string, unknown>[];
|
||||||
|
auditTrail: Record<string, unknown>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads every row that references this client across all tenant-scoped
|
||||||
|
* tables. Every query is filtered by `portId` as well, so a stale FK
|
||||||
|
* to another tenant never leaks across.
|
||||||
|
*/
|
||||||
|
export async function buildClientBundle(clientId: string, portId: string): Promise<GdprBundle> {
|
||||||
|
const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) });
|
||||||
|
if (!client || client.portId !== portId) {
|
||||||
|
throw new NotFoundError('Client');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
contacts,
|
||||||
|
addresses,
|
||||||
|
relationships,
|
||||||
|
notes,
|
||||||
|
tagJoins,
|
||||||
|
ownedYachts,
|
||||||
|
membershipRows,
|
||||||
|
interestRows,
|
||||||
|
reservationRows,
|
||||||
|
invoiceRows,
|
||||||
|
documentRows,
|
||||||
|
auditRows,
|
||||||
|
] = await Promise.all([
|
||||||
|
db.query.clientContacts.findMany({ where: eq(clientContacts.clientId, clientId) }),
|
||||||
|
db.query.clientAddresses.findMany({ where: eq(clientAddresses.clientId, clientId) }),
|
||||||
|
db.query.clientRelationships.findMany({
|
||||||
|
where: or(
|
||||||
|
eq(clientRelationships.clientAId, clientId),
|
||||||
|
eq(clientRelationships.clientBId, clientId),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
db.query.clientNotes.findMany({ where: eq(clientNotes.clientId, clientId) }),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: tags.id,
|
||||||
|
name: tags.name,
|
||||||
|
color: tags.color,
|
||||||
|
})
|
||||||
|
.from(clientTags)
|
||||||
|
.innerJoin(tags, eq(clientTags.tagId, tags.id))
|
||||||
|
.where(eq(clientTags.clientId, clientId)),
|
||||||
|
db.query.yachts.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(yachts.portId, portId),
|
||||||
|
eq(yachts.currentOwnerType, 'client'),
|
||||||
|
eq(yachts.currentOwnerId, clientId),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ membership: companyMemberships, company: companies })
|
||||||
|
.from(companyMemberships)
|
||||||
|
.innerJoin(companies, eq(companyMemberships.companyId, companies.id))
|
||||||
|
.where(and(eq(companyMemberships.clientId, clientId), eq(companies.portId, portId))),
|
||||||
|
db.query.interests.findMany({
|
||||||
|
where: and(eq(interests.clientId, clientId), eq(interests.portId, portId)),
|
||||||
|
}),
|
||||||
|
db.query.berthReservations.findMany({
|
||||||
|
where: and(eq(berthReservations.clientId, clientId), eq(berthReservations.portId, portId)),
|
||||||
|
}),
|
||||||
|
db.query.invoices.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(invoices.portId, portId),
|
||||||
|
eq(invoices.billingEntityType, 'client'),
|
||||||
|
eq(invoices.billingEntityId, clientId),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
db.query.documents.findMany({
|
||||||
|
where: and(eq(documents.portId, portId), eq(documents.clientId, clientId)),
|
||||||
|
}),
|
||||||
|
db.query.auditLogs.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(auditLogs.portId, portId),
|
||||||
|
eq(auditLogs.entityType, 'client'),
|
||||||
|
eq(auditLogs.entityId, clientId),
|
||||||
|
),
|
||||||
|
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||||
|
limit: 500,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
portId,
|
||||||
|
clientId,
|
||||||
|
schemaVersion: 1,
|
||||||
|
},
|
||||||
|
client: client as unknown as Record<string, unknown>,
|
||||||
|
contacts: contacts as unknown as Record<string, unknown>[],
|
||||||
|
addresses: addresses as unknown as Record<string, unknown>[],
|
||||||
|
tags: tagJoins,
|
||||||
|
relationships: relationships as unknown as Record<string, unknown>[],
|
||||||
|
notes: notes as unknown as Record<string, unknown>[],
|
||||||
|
ownedYachts: ownedYachts as unknown as Record<string, unknown>[],
|
||||||
|
companyMemberships: membershipRows as unknown as Array<{
|
||||||
|
membership: Record<string, unknown>;
|
||||||
|
company: Record<string, unknown>;
|
||||||
|
}>,
|
||||||
|
interests: interestRows as unknown as Record<string, unknown>[],
|
||||||
|
reservations: reservationRows as unknown as Record<string, unknown>[],
|
||||||
|
invoices: invoiceRows as unknown as Record<string, unknown>[],
|
||||||
|
documents: documentRows as unknown as Record<string, unknown>[],
|
||||||
|
auditTrail: auditRows as unknown as Record<string, unknown>[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── HTML rendering ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escapeHtml(s: unknown): string {
|
||||||
|
if (s === null || s === undefined) return '';
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableSection(title: string, rows: Record<string, unknown>[]): string {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return `<section><h2>${escapeHtml(title)}</h2><p class="empty">No records.</p></section>`;
|
||||||
|
}
|
||||||
|
const headers = Array.from(
|
||||||
|
rows.reduce<Set<string>>((set, r) => {
|
||||||
|
Object.keys(r).forEach((k) => set.add(k));
|
||||||
|
return set;
|
||||||
|
}, new Set()),
|
||||||
|
);
|
||||||
|
const headerHtml = headers.map((h) => `<th>${escapeHtml(h)}</th>`).join('');
|
||||||
|
const bodyHtml = rows
|
||||||
|
.map(
|
||||||
|
(r) =>
|
||||||
|
`<tr>${headers
|
||||||
|
.map((h) => {
|
||||||
|
const v = r[h];
|
||||||
|
const cell = typeof v === 'object' && v !== null ? JSON.stringify(v) : v;
|
||||||
|
return `<td>${escapeHtml(cell)}</td>`;
|
||||||
|
})
|
||||||
|
.join('')}</tr>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
return `
|
||||||
|
<section>
|
||||||
|
<h2>${escapeHtml(title)} <small>(${rows.length})</small></h2>
|
||||||
|
<table><thead><tr>${headerHtml}</tr></thead><tbody>${bodyHtml}</tbody></table>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 as unknown as Record<string, unknown>[]),
|
||||||
|
tableSection('Relationships', bundle.relationships),
|
||||||
|
tableSection('Notes', bundle.notes),
|
||||||
|
tableSection('Owned yachts', bundle.ownedYachts),
|
||||||
|
tableSection(
|
||||||
|
'Company memberships',
|
||||||
|
bundle.companyMemberships.map((m) => ({
|
||||||
|
...m.membership,
|
||||||
|
companyName: m.company.name,
|
||||||
|
companyLegalName: m.company.legalName,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
tableSection('Interests', bundle.interests),
|
||||||
|
tableSection('Reservations', bundle.reservations),
|
||||||
|
tableSection('Invoices', bundle.invoices),
|
||||||
|
tableSection('Documents', bundle.documents),
|
||||||
|
tableSection('Audit trail (last 500 events)', bundle.auditTrail),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<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>`;
|
||||||
|
}
|
||||||
265
src/lib/services/gdpr-export.service.ts
Normal file
265
src/lib/services/gdpr-export.service.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* GDPR client-data export orchestration.
|
||||||
|
*
|
||||||
|
* `requestExport()` creates a row, queues a BullMQ job, and returns. The
|
||||||
|
* `processExportJob()` handler builds the bundle, ZIPs JSON+HTML into
|
||||||
|
* MinIO, optionally emails the client a download link, and updates the
|
||||||
|
* row to status='ready' or 'sent'.
|
||||||
|
*
|
||||||
|
* Bundles are kept for 30 days then expired by maintenance (the
|
||||||
|
* gdpr_exports.expires_at column is the cleanup target).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import archiver from 'archiver';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { gdprExports, type GdprExport } from '@/lib/db/schema/gdpr';
|
||||||
|
import { clients, clientContacts } from '@/lib/db/schema/clients';
|
||||||
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
|
import { env } from '@/lib/env';
|
||||||
|
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { minioClient, getPresignedUrl } from '@/lib/minio';
|
||||||
|
import { getQueue } from '@/lib/queue';
|
||||||
|
import { createAuditLog } from '@/lib/audit';
|
||||||
|
import { buildClientBundle, renderBundleHtml } from '@/lib/services/gdpr-bundle-builder';
|
||||||
|
|
||||||
|
const EXPIRY_DAYS = 30;
|
||||||
|
const PRESIGN_EXPIRY_SECONDS = 7 * 24 * 60 * 60; // 7 days for the email link
|
||||||
|
|
||||||
|
interface RequestExportInput {
|
||||||
|
clientId: string;
|
||||||
|
portId: string;
|
||||||
|
requestedBy: string;
|
||||||
|
/** When true, the bundle is emailed to the client's primary address once ready. */
|
||||||
|
emailToClient: boolean;
|
||||||
|
/** Override recipient (e.g. lawyer or agent). When set, takes precedence over the client's primary email. */
|
||||||
|
emailOverride?: string | null;
|
||||||
|
ipAddress: string;
|
||||||
|
userAgent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestExportResult {
|
||||||
|
export: GdprExport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestGdprExport(input: RequestExportInput): Promise<RequestExportResult> {
|
||||||
|
const client = await db.query.clients.findFirst({
|
||||||
|
where: eq(clients.id, input.clientId),
|
||||||
|
});
|
||||||
|
if (!client || client.portId !== input.portId) throw new NotFoundError('Client');
|
||||||
|
|
||||||
|
if (input.emailToClient && !input.emailOverride) {
|
||||||
|
const primary = await db.query.clientContacts.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(clientContacts.clientId, input.clientId),
|
||||||
|
eq(clientContacts.channel, 'email'),
|
||||||
|
eq(clientContacts.isPrimary, true),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!primary) {
|
||||||
|
throw new ValidationError(
|
||||||
|
'Client has no primary email contact — provide an emailOverride or add one before exporting.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await db
|
||||||
|
.insert(gdprExports)
|
||||||
|
.values({
|
||||||
|
portId: input.portId,
|
||||||
|
clientId: input.clientId,
|
||||||
|
requestedBy: input.requestedBy,
|
||||||
|
status: 'pending',
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
if (!row) throw new Error('Failed to create export row');
|
||||||
|
|
||||||
|
void createAuditLog({
|
||||||
|
userId: input.requestedBy,
|
||||||
|
portId: input.portId,
|
||||||
|
action: 'request_gdpr_export',
|
||||||
|
entityType: 'client',
|
||||||
|
entityId: input.clientId,
|
||||||
|
metadata: { exportId: row.id, emailToClient: input.emailToClient },
|
||||||
|
ipAddress: input.ipAddress,
|
||||||
|
userAgent: input.userAgent,
|
||||||
|
});
|
||||||
|
|
||||||
|
await getQueue('export').add('gdpr-export', {
|
||||||
|
exportId: row.id,
|
||||||
|
portId: input.portId,
|
||||||
|
clientId: input.clientId,
|
||||||
|
emailToClient: input.emailToClient,
|
||||||
|
emailOverride: input.emailOverride ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { export: row };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessJobInput {
|
||||||
|
exportId: string;
|
||||||
|
portId: string;
|
||||||
|
clientId: string;
|
||||||
|
emailToClient: boolean;
|
||||||
|
emailOverride: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worker entry point. Loads the bundle, ZIPs it, uploads to MinIO,
|
||||||
|
* (optionally) emails the client. Failures mark the row 'failed' with
|
||||||
|
* the truncated error.
|
||||||
|
*/
|
||||||
|
export async function processGdprExportJob(input: ProcessJobInput): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(gdprExports)
|
||||||
|
.set({ status: 'building' })
|
||||||
|
.where(eq(gdprExports.id, input.exportId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bundle = await buildClientBundle(input.clientId, input.portId);
|
||||||
|
const json = JSON.stringify(bundle, null, 2);
|
||||||
|
const html = renderBundleHtml(bundle);
|
||||||
|
|
||||||
|
// Stream a ZIP into a buffer. Receipts/contracts are not included
|
||||||
|
// here — they live on file rows referenced by the bundle and would
|
||||||
|
// bloat the archive. Add them later if Article-15 requests demand.
|
||||||
|
const zip = archiver('zip', { zlib: { level: 9 } });
|
||||||
|
const sink = new PassThrough();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
sink.on('data', (c: Buffer) => chunks.push(c));
|
||||||
|
const done = new Promise<Buffer>((resolve, reject) => {
|
||||||
|
sink.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
sink.on('error', reject);
|
||||||
|
zip.on('error', reject);
|
||||||
|
});
|
||||||
|
zip.pipe(sink);
|
||||||
|
zip.append(json, { name: 'client.json' });
|
||||||
|
zip.append(html, { name: 'client.html' });
|
||||||
|
zip.append(
|
||||||
|
`Personal data export for client ${input.clientId}\nGenerated ${bundle.meta.generatedAt}\n`,
|
||||||
|
{ name: 'README.txt' },
|
||||||
|
);
|
||||||
|
await zip.finalize();
|
||||||
|
const buffer = await done;
|
||||||
|
|
||||||
|
const port = await db.query.ports.findFirst({ where: eq(ports.id, input.portId) });
|
||||||
|
const portSlug = port?.slug ?? 'unknown';
|
||||||
|
const storageKey = `${portSlug}/gdpr-exports/${input.clientId}/${input.exportId}.zip`;
|
||||||
|
|
||||||
|
await minioClient.putObject(env.MINIO_BUCKET, storageKey, buffer, buffer.length, {
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Content-Disposition': `attachment; filename="gdpr-export-${input.clientId}.zip"`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||||
|
await db
|
||||||
|
.update(gdprExports)
|
||||||
|
.set({
|
||||||
|
status: 'ready',
|
||||||
|
storageKey,
|
||||||
|
sizeBytes: buffer.length,
|
||||||
|
readyAt: new Date(),
|
||||||
|
expiresAt,
|
||||||
|
})
|
||||||
|
.where(eq(gdprExports.id, input.exportId));
|
||||||
|
|
||||||
|
if (input.emailToClient) {
|
||||||
|
await emailExport(input, storageKey);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err, exportId: input.exportId }, 'GDPR export job failed');
|
||||||
|
await db
|
||||||
|
.update(gdprExports)
|
||||||
|
.set({
|
||||||
|
status: 'failed',
|
||||||
|
error: err instanceof Error ? err.message.slice(0, 1000) : 'Unknown error',
|
||||||
|
})
|
||||||
|
.where(eq(gdprExports.id, input.exportId));
|
||||||
|
throw err; // let BullMQ retry per the queue config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emailExport(input: ProcessJobInput, storageKey: string): Promise<void> {
|
||||||
|
// 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 = `
|
||||||
|
<p>Hello ${escapeHtml(name)},</p>
|
||||||
|
<p>You requested a copy of the personal data we hold about you. The export is ready and contains:</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>client.json</code> — machine-readable data dump</li>
|
||||||
|
<li><code>client.html</code> — same data as a printable web page</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="${url}">Download the export (ZIP, expires ${escapeHtml(expiry)})</a></p>
|
||||||
|
<p>If you have any questions, reply to this email.</p>
|
||||||
|
`;
|
||||||
|
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, '"')
|
||||||
|
.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<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
200
tests/integration/gdpr-export.test.ts
Normal file
200
tests/integration/gdpr-export.test.ts
Normal file
@@ -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('<!doctype html>')).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: '<script>alert(1)</script>' },
|
||||||
|
});
|
||||||
|
const bundle = await buildClientBundle(client.id, port.id);
|
||||||
|
const html = renderBundleHtml(bundle);
|
||||||
|
// The literal "<script>" must not appear unescaped anywhere in the output.
|
||||||
|
expect(html).not.toContain('<script>alert(1)</script>');
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user