diff --git a/src/app/api/v1/clients/[id]/hard-delete-request/route.ts b/src/app/api/v1/clients/[id]/hard-delete-request/route.ts new file mode 100644 index 0000000..a38716d --- /dev/null +++ b/src/app/api/v1/clients/[id]/hard-delete-request/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { requestHardDeleteCode } from '@/lib/services/client-hard-delete.service'; +import { errorResponse, NotFoundError } from '@/lib/errors'; + +/** + * Send a one-time confirmation code to the operator's account email so + * they can permanently delete an archived client. Two-permission gate: + * `clients.delete` (the standard archive permission) is enforced by the + * route wrapper; the service additionally requires the client to be + * archived. The dedicated `admin.permanently_delete_clients` flag is + * checked by the partner /hard-delete route — see route comment there. + */ +export const POST = withAuth( + withPermission('admin', 'permanently_delete_clients', async (_req, ctx, params) => { + try { + const id = params.id; + if (!id) throw new NotFoundError('client'); + + const result = await requestHardDeleteCode({ + clientId: id, + portId: ctx.portId, + requesterUserId: ctx.userId, + meta: { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + }); + + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/clients/[id]/hard-delete/route.ts b/src/app/api/v1/clients/[id]/hard-delete/route.ts new file mode 100644 index 0000000..a140c10 --- /dev/null +++ b/src/app/api/v1/clients/[id]/hard-delete/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { hardDeleteClient } from '@/lib/services/client-hard-delete.service'; +import { errorResponse, NotFoundError } from '@/lib/errors'; + +const hardDeleteSchema = z.object({ + code: z.string().regex(/^\d{4}$/, '4-digit code required'), + typedName: z.string().min(1, 'Type the client name to confirm'), +}); + +/** + * Permanently delete an archived client. Gated on the dedicated + * `admin.permanently_delete_clients` permission AND requires both: + * - the 4-digit code that was emailed to the operator (10 min TTL), + * - the typed full name of the client (case-insensitive equality). + * The service additionally enforces that the client is already archived. + */ +export const POST = withAuth( + withPermission('admin', 'permanently_delete_clients', async (req, ctx, params) => { + try { + const id = params.id; + if (!id) throw new NotFoundError('client'); + const body = await parseBody(req, hardDeleteSchema); + + const result = await hardDeleteClient({ + clientId: id, + portId: ctx.portId, + requesterUserId: ctx.userId, + code: body.code, + typedName: body.typedName, + meta: { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + }); + + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/clients/bulk-hard-delete-request/route.ts b/src/app/api/v1/clients/bulk-hard-delete-request/route.ts new file mode 100644 index 0000000..f608901 --- /dev/null +++ b/src/app/api/v1/clients/bulk-hard-delete-request/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { requestBulkHardDeleteCode } from '@/lib/services/client-hard-delete.service'; +import { errorResponse } from '@/lib/errors'; + +const bodySchema = z.object({ + ids: z.array(z.string().min(1)).min(1).max(100), +}); + +export const POST = withAuth( + withPermission('admin', 'permanently_delete_clients', async (req, ctx) => { + try { + const { ids } = await parseBody(req, bodySchema); + const result = await requestBulkHardDeleteCode({ + clientIds: ids, + portId: ctx.portId, + requesterUserId: ctx.userId, + meta: { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + }); + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/clients/bulk-hard-delete/route.ts b/src/app/api/v1/clients/bulk-hard-delete/route.ts new file mode 100644 index 0000000..b2bfd32 --- /dev/null +++ b/src/app/api/v1/clients/bulk-hard-delete/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { bulkHardDeleteClients } from '@/lib/services/client-hard-delete.service'; +import { errorResponse } from '@/lib/errors'; + +const bodySchema = z.object({ + ids: z.array(z.string().min(1)).min(1).max(100), + code: z.string().regex(/^\d{4}$/, '4-digit code required'), + typedPhrase: z.string().min(1, 'Type the confirmation phrase'), +}); + +export const POST = withAuth( + withPermission('admin', 'permanently_delete_clients', async (req, ctx) => { + try { + const body = await parseBody(req, bodySchema); + const result = await bulkHardDeleteClients({ + clientIds: body.ids, + portId: ctx.portId, + requesterUserId: ctx.userId, + code: body.code, + typedPhrase: body.typedPhrase, + meta: { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }, + }); + return NextResponse.json({ data: result }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/admin/roles/role-form.tsx b/src/components/admin/roles/role-form.tsx index a70dc02..18e682b 100644 --- a/src/components/admin/roles/role-form.tsx +++ b/src/components/admin/roles/role-form.tsx @@ -84,6 +84,7 @@ const DEFAULT_PERMISSIONS: Record> = { manage_forms: false, manage_tags: false, system_backup: false, + permanently_delete_clients: false, }, residential_clients: { view: false, create: false, edit: false, delete: false }, residential_interests: { diff --git a/src/components/clients/bulk-hard-delete-dialog.tsx b/src/components/clients/bulk-hard-delete-dialog.tsx new file mode 100644 index 0000000..fd56ae4 --- /dev/null +++ b/src/components/clients/bulk-hard-delete-dialog.tsx @@ -0,0 +1,190 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AlertTriangle, Loader2, Mail } from 'lucide-react'; +import { toast } from 'sonner'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { apiFetch } from '@/lib/api/client'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + clientIds: string[]; + onDeleted?: (deletedCount: number) => void; +} + +type Stage = 'intent' | 'confirm'; + +export function BulkHardDeleteDialog({ open, onOpenChange, clientIds, onDeleted }: Props) { + const qc = useQueryClient(); + const [stage, setStage] = useState('intent'); + const [code, setCode] = useState(''); + const [typedPhrase, setTypedPhrase] = useState(''); + const [maskedEmail, setMaskedEmail] = useState(null); + + const expectedPhrase = `DELETE ${clientIds.length} CLIENT${clientIds.length === 1 ? '' : 'S'}`; + + useEffect(() => { + if (open) { + setStage('intent'); + setCode(''); + setTypedPhrase(''); + setMaskedEmail(null); + } + }, [open]); + + const requestCode = useMutation({ + mutationFn: () => + apiFetch<{ data: { count: number; sentToMaskedEmail: string } }>( + '/api/v1/clients/bulk-hard-delete-request', + { method: 'POST', body: { ids: clientIds } }, + ), + onSuccess: (res) => { + setMaskedEmail(res.data.sentToMaskedEmail); + setStage('confirm'); + toast.success(`Code sent to ${res.data.sentToMaskedEmail}`); + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Failed to send code'); + }, + }); + + const bulkDelete = useMutation({ + mutationFn: () => + apiFetch<{ data: { deletedCount: number } }>('/api/v1/clients/bulk-hard-delete', { + method: 'POST', + body: { ids: clientIds, code, typedPhrase }, + }), + onSuccess: (res) => { + const n = res.data.deletedCount; + const failed = clientIds.length - n; + if (failed === 0) { + toast.success(`${n} client${n === 1 ? '' : 's'} permanently deleted.`); + } else { + toast.warning(`${n} of ${clientIds.length} deleted. ${failed} failed (see audit log).`); + } + qc.invalidateQueries({ queryKey: ['clients'] }); + onOpenChange(false); + onDeleted?.(n); + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Bulk delete failed'); + }, + }); + + const phraseMatches = typedPhrase.trim().toUpperCase() === expectedPhrase; + const codeValid = /^\d{4}$/.test(code.trim()); + + return ( + + + + + + Permanently delete {clientIds.length} client{clientIds.length === 1 ? '' : 's'} + + + All selected clients must already be archived. This cannot be undone. + + + + {stage === 'intent' ? ( +
+

+ We’ll email a 4-digit confirmation code to your account address. The code is + tied to this exact set of clients and expires in 10 minutes. +

+
+ For each client we delete: client record + addresses, contacts, notes, tags, portal + user, GDPR records, all interests, all reservations. Signed documents, email threads, + files and reminders are detached but kept. +
+
+ ) : ( +
+
+ +
+ Code sent to {maskedEmail}. Enter both fields + below. +
+
+
+ + setCode(e.target.value.replace(/\D/g, ''))} + placeholder="0000" + className="font-mono tracking-[0.4em] text-center text-lg" + autoComplete="off" + /> +
+
+ + setTypedPhrase(e.target.value)} + placeholder={expectedPhrase} + autoComplete="off" + className="font-mono" + /> +
+
+ )} + + + + {stage === 'intent' ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/src/components/clients/client-list.tsx b/src/components/clients/client-list.tsx index aa9a321..99887fe 100644 --- a/src/components/clients/client-list.tsx +++ b/src/components/clients/client-list.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useParams } from 'next/navigation'; -import { Plus, Archive, Tag as TagIcon, TagsIcon } from 'lucide-react'; +import { Plus, Archive, Tag as TagIcon, TagsIcon, Trash2 } from 'lucide-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; @@ -15,6 +15,8 @@ import { TableSkeleton } from '@/components/shared/loading-skeleton'; import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog'; import { PermissionGate } from '@/components/shared/permission-gate'; import { TagPicker } from '@/components/shared/tag-picker'; +import { BulkHardDeleteDialog } from '@/components/clients/bulk-hard-delete-dialog'; +import { usePermissions } from '@/hooks/use-permissions'; import { Dialog, DialogContent, @@ -43,6 +45,10 @@ export function ClientList() { null, ); const [tagChoice, setTagChoice] = useState([]); + const [bulkDeleteIds, setBulkDeleteIds] = useState([]); + + const { can } = usePermissions(); + const canHardDelete = can('admin', 'permanently_delete_clients'); const { data, @@ -187,6 +193,19 @@ export function ClientList() { bulkMutation.mutate({ action: 'archive', ids }); }, }, + ...(canHardDelete + ? [ + { + label: 'Permanently delete (archived only)', + icon: Trash2, + variant: 'destructive' as const, + onClick: (ids: string[]) => { + if (ids.length === 0) return; + setBulkDeleteIds(ids); + }, + }, + ] + : []), ]} cardRender={(row) => ( archiveClient && archiveMutation.mutate(archiveClient.id)} isLoading={archiveMutation.isPending} /> + + 0} + onOpenChange={(open) => !open && setBulkDeleteIds([])} + clientIds={bulkDeleteIds} + onDeleted={() => setBulkDeleteIds([])} + /> ); } diff --git a/src/components/clients/hard-delete-dialog.tsx b/src/components/clients/hard-delete-dialog.tsx new file mode 100644 index 0000000..fec08eb --- /dev/null +++ b/src/components/clients/hard-delete-dialog.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AlertTriangle, Loader2, Mail } from 'lucide-react'; +import { toast } from 'sonner'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { apiFetch } from '@/lib/api/client'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + clientId: string; + clientName: string; + /** Called after successful delete, e.g. to navigate away. */ + onDeleted?: () => void; +} + +type Stage = 'intent' | 'confirm'; + +export function HardDeleteDialog({ open, onOpenChange, clientId, clientName, onDeleted }: Props) { + const qc = useQueryClient(); + const [stage, setStage] = useState('intent'); + const [code, setCode] = useState(''); + const [typedName, setTypedName] = useState(''); + const [maskedEmail, setMaskedEmail] = useState(null); + + useEffect(() => { + if (open) { + setStage('intent'); + setCode(''); + setTypedName(''); + setMaskedEmail(null); + } + }, [open]); + + const requestCode = useMutation({ + mutationFn: () => + apiFetch<{ data: { sentToMaskedEmail: string } }>( + `/api/v1/clients/${clientId}/hard-delete-request`, + { method: 'POST' }, + ), + onSuccess: (res) => { + setMaskedEmail(res.data.sentToMaskedEmail); + setStage('confirm'); + toast.success(`Code sent to ${res.data.sentToMaskedEmail}`); + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Failed to send code'); + }, + }); + + const hardDelete = useMutation({ + mutationFn: () => + apiFetch<{ data: { deletedClientId: string } }>(`/api/v1/clients/${clientId}/hard-delete`, { + method: 'POST', + body: { code, typedName }, + }), + onSuccess: () => { + toast.success(`${clientName} permanently deleted.`); + qc.invalidateQueries({ queryKey: ['clients'] }); + onOpenChange(false); + onDeleted?.(); + }, + onError: (err: unknown) => { + toast.error(err instanceof Error ? err.message : 'Delete failed'); + }, + }); + + const nameMatches = typedName.trim().toLowerCase() === clientName.trim().toLowerCase(); + const codeValid = /^\d{4}$/.test(code.trim()); + + return ( + + + + + + Permanently delete {clientName} + + + This permanently removes the client record and detaches all related history (signed + documents, emails, files). It cannot be undone. + + + + {stage === 'intent' ? ( +
+

+ Permanent deletion is reserved for archived clients only. We’ll email a 4-digit + confirmation code to your account address. The code expires in 10 minutes. +

+
+

+ What gets deleted +

+
    +
  • Client record + addresses, contacts, notes, tags
  • +
  • Portal user account + GDPR consent records
  • +
  • All pipeline interests + reservations for this client
  • +
+

What is preserved

+
    +
  • Signed documents (detached from client, kept for legal history)
  • +
  • Email threads, files, reminders (detached)
  • +
  • Audit log entries
  • +
+
+
+ ) : ( +
+
+ +
+ Code sent to {maskedEmail}. It expires in 10 + minutes. Check your inbox and enter both fields below. +
+
+
+ + setCode(e.target.value.replace(/\D/g, ''))} + placeholder="0000" + className="font-mono tracking-[0.4em] text-center text-lg" + autoComplete="off" + /> +
+
+ + setTypedName(e.target.value)} + placeholder={clientName} + autoComplete="off" + /> +
+
+ )} + + + + {stage === 'intent' ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/src/lib/audit.ts b/src/lib/audit.ts index 749bdbe..e6ee6ab 100644 --- a/src/lib/audit.ts +++ b/src/lib/audit.ts @@ -23,7 +23,9 @@ export type AuditAction = | 'portal_password_reset_request' | 'portal_password_reset' | 'send' - | 'view'; + | 'view' + | 'request_hard_delete_code' + | 'hard_delete'; /** * Common shape passed to service functions so they can stamp audit logs and diff --git a/src/lib/db/schema/users.ts b/src/lib/db/schema/users.ts index ba9936b..52e4a17 100644 --- a/src/lib/db/schema/users.ts +++ b/src/lib/db/schema/users.ts @@ -124,6 +124,9 @@ export type RolePermissions = { manage_forms: boolean; manage_tags: boolean; system_backup: boolean; + // Permanent client deletion is gated separately from admin.manage_users + // because it bypasses archive/restore. Requires email-code confirmation. + permanently_delete_clients: boolean; }; residential_clients: { view: boolean; diff --git a/src/lib/db/seed.ts b/src/lib/db/seed.ts index 43b03d4..57b8ddd 100644 --- a/src/lib/db/seed.ts +++ b/src/lib/db/seed.ts @@ -92,6 +92,7 @@ const ALL_PERMISSIONS: RolePermissions = { manage_forms: true, manage_tags: true, system_backup: true, + permanently_delete_clients: true, }, residential_clients: { view: true, create: true, edit: true, delete: true }, residential_interests: { @@ -168,6 +169,7 @@ const DIRECTOR_PERMISSIONS: RolePermissions = { manage_forms: true, manage_tags: true, system_backup: false, + permanently_delete_clients: false, }, residential_clients: { view: true, create: true, edit: true, delete: true }, residential_interests: { @@ -244,6 +246,7 @@ const SALES_MANAGER_PERMISSIONS: RolePermissions = { manage_forms: false, manage_tags: true, system_backup: false, + permanently_delete_clients: false, }, residential_clients: { view: false, create: false, edit: false, delete: false }, residential_interests: { @@ -320,6 +323,7 @@ const SALES_AGENT_PERMISSIONS: RolePermissions = { manage_forms: false, manage_tags: true, system_backup: false, + permanently_delete_clients: false, }, residential_clients: { view: false, create: false, edit: false, delete: false }, residential_interests: { @@ -396,6 +400,7 @@ const VIEWER_PERMISSIONS: RolePermissions = { manage_forms: false, manage_tags: false, system_backup: false, + permanently_delete_clients: false, }, residential_clients: { view: false, create: false, edit: false, delete: false }, residential_interests: { @@ -475,6 +480,7 @@ const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = { manage_forms: false, manage_tags: false, system_backup: false, + permanently_delete_clients: false, }, residential_clients: { view: true, create: true, edit: true, delete: false }, residential_interests: { diff --git a/src/lib/services/client-hard-delete.service.ts b/src/lib/services/client-hard-delete.service.ts new file mode 100644 index 0000000..3deb520 --- /dev/null +++ b/src/lib/services/client-hard-delete.service.ts @@ -0,0 +1,409 @@ +/** + * Permanent client deletion with email-code confirmation. + * + * Flow: + * 1. Operator presses "Permanently delete" on an archived client. + * 2. requestHardDeleteCode() generates a 4-digit code, stores it in + * Redis under a per-{user, client} key with a 10-minute TTL, and + * emails the code to the operator's account address. + * 3. Operator types both the code AND the client's full name into the + * confirmation dialog. + * 4. hardDeleteClient() validates code (timing-safe) + name (case- + * insensitive trim equality), then deletes the client. + * + * Hard-delete is gated on: + * - permission `admin.permanently_delete_clients` + * - the client must already be archived (defense-in-depth: forces + * operators through the smart-archive flow first). + * + * The DB cascade story: + * - cascade FKs handle: companies, addresses, contacts, notes, tags, + * portal users, GDPR records — see ON DELETE CASCADE on the FK + * definitions in src/lib/db/schema/clients.ts. + * - non-cascade nullable FKs (files, documents, form_submissions, + * email_messages, reminders, document_sends) get cleared inline so + * audit history is preserved without blocking the delete. + * - non-cascade non-nullable FKs (interests, reservations, surviving + * row in client_merge_log) are deleted explicitly inside the tx. + */ + +import { timingSafeEqual } from 'node:crypto'; + +import { and, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { clients, clientMergeLog } from '@/lib/db/schema/clients'; +import { interests } from '@/lib/db/schema/interests'; +import { berthReservations } from '@/lib/db/schema/reservations'; +import { files, documents, formSubmissions } from '@/lib/db/schema/documents'; +import { documentSends } from '@/lib/db/schema/brochures'; +import { emailThreads } from '@/lib/db/schema/email'; +import { reminders } from '@/lib/db/schema/operations'; +import { user as authUser } from '@/lib/db/schema/users'; +import { redis } from '@/lib/redis'; +import { sendEmail } from '@/lib/email'; +import { logger } from '@/lib/logger'; +import { createAuditLog, type AuditMeta } from '@/lib/audit'; +import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; + +const CODE_TTL_SECONDS = 10 * 60; + +function codeKey(userId: string, clientId: string): string { + return `client-hard-delete-code:${userId}:${clientId}`; +} + +function generateCode(): string { + // 4-digit zero-padded numeric code. Math.random is sufficient for a + // short-TTL one-time confirmation code that's already gated by an + // authenticated session AND a permission flag. + return Math.floor(Math.random() * 10000) + .toString() + .padStart(4, '0'); +} + +function safeEqualStr(a: string, b: string): boolean { + const ab = Buffer.from(a, 'utf8'); + const bb = Buffer.from(b, 'utf8'); + if (ab.length !== bb.length) return false; + return timingSafeEqual(ab, bb); +} + +export async function requestHardDeleteCode(args: { + clientId: string; + portId: string; + requesterUserId: string; + meta: AuditMeta; +}): Promise<{ sentToMaskedEmail: string }> { + const [client] = await db + .select({ id: clients.id, fullName: clients.fullName, archivedAt: clients.archivedAt }) + .from(clients) + .where(and(eq(clients.id, args.clientId), eq(clients.portId, args.portId))) + .limit(1); + if (!client) throw new NotFoundError('client'); + if (!client.archivedAt) { + throw new ConflictError('Client must be archived before permanent deletion'); + } + + const [u] = await db + .select({ email: authUser.email, name: authUser.name }) + .from(authUser) + .where(eq(authUser.id, args.requesterUserId)) + .limit(1); + if (!u) throw new NotFoundError('user'); + + const code = generateCode(); + await redis.set(codeKey(args.requesterUserId, args.clientId), code, 'EX', CODE_TTL_SECONDS); + + const subject = `Confirmation code: permanently delete ${client.fullName}`; + const html = ` +

Hello ${u.name},

+

You requested to permanently delete the archived client + ${escapeHtml(client.fullName)}.

+

Enter this code in the confirmation dialog to proceed:

+

${code}

+

This code expires in 10 minutes. If you didn’t request this, + you can safely ignore this email — no action will be taken.

+ `; + const text = [ + `Hello ${u.name},`, + '', + `You requested to permanently delete the archived client "${client.fullName}".`, + '', + `Confirmation code: ${code}`, + `(expires in 10 minutes)`, + '', + `If you didn't request this, you can safely ignore this email.`, + ].join('\n'); + + try { + await sendEmail(u.email, subject, html, undefined, text, args.portId); + } catch (err) { + // Wipe the cached code so a failed send doesn't leave a usable code + // in Redis without the operator ever seeing it. + await redis.del(codeKey(args.requesterUserId, args.clientId)).catch(() => undefined); + throw err; + } + + void createAuditLog({ + portId: args.portId, + userId: args.requesterUserId, + action: 'request_hard_delete_code', + entityType: 'client', + entityId: args.clientId, + metadata: { sentTo: u.email }, + ipAddress: args.meta.ipAddress, + userAgent: args.meta.userAgent, + }); + + return { sentToMaskedEmail: maskEmail(u.email) }; +} + +export async function hardDeleteClient(args: { + clientId: string; + portId: string; + requesterUserId: string; + code: string; + typedName: string; + meta: AuditMeta; +}): Promise<{ deletedClientId: string }> { + const [client] = await db + .select({ id: clients.id, fullName: clients.fullName, archivedAt: clients.archivedAt }) + .from(clients) + .where(and(eq(clients.id, args.clientId), eq(clients.portId, args.portId))) + .limit(1); + if (!client) throw new NotFoundError('client'); + if (!client.archivedAt) { + throw new ConflictError('Client must be archived before permanent deletion'); + } + + // Validate the typed name (case-insensitive, trimmed) before consuming + // the code, so a typo doesn't cost the operator their code. + const expected = client.fullName.trim().toLowerCase(); + const actual = args.typedName.trim().toLowerCase(); + if (expected !== actual) { + throw new ValidationError('Typed name does not match the client'); + } + + const key = codeKey(args.requesterUserId, args.clientId); + const stored = await redis.get(key); + if (!stored) { + throw new ValidationError('Confirmation code expired or not requested'); + } + if (!safeEqualStr(stored, args.code.trim())) { + throw new ValidationError('Confirmation code is incorrect'); + } + // Single-use: delete the code immediately so a failed delete tx + // forces the operator to request a fresh code. + await redis.del(key); + + await db.transaction(async (tx) => { + // Lock the client row. + const [locked] = await tx + .select({ id: clients.id, archivedAt: clients.archivedAt }) + .from(clients) + .where(and(eq(clients.id, args.clientId), eq(clients.portId, args.portId))) + .for('update'); + if (!locked) throw new NotFoundError('client'); + if (!locked.archivedAt) throw new ConflictError('Client must be archived'); + + // Detach nullable FKs so we keep their audit history. + await tx.update(files).set({ clientId: null }).where(eq(files.clientId, args.clientId)); + await tx.update(documents).set({ clientId: null }).where(eq(documents.clientId, args.clientId)); + await tx + .update(formSubmissions) + .set({ clientId: null }) + .where(eq(formSubmissions.clientId, args.clientId)); + await tx + .update(emailThreads) + .set({ clientId: null }) + .where(eq(emailThreads.clientId, args.clientId)); + await tx.update(reminders).set({ clientId: null }).where(eq(reminders.clientId, args.clientId)); + await tx + .update(documentSends) + .set({ clientId: null }) + .where(eq(documentSends.clientId, args.clientId)); + + // client_merge_log.surviving_client_id has no cascade and is + // notNull → must be deleted explicitly. Merged records remain in + // the log because mergedClientId has no FK. + await tx.delete(clientMergeLog).where(eq(clientMergeLog.survivingClientId, args.clientId)); + + // Delete non-nullable-FK children explicitly (cascade chains + // pick up their own children in turn). + await tx.delete(interests).where(eq(interests.clientId, args.clientId)); + await tx.delete(berthReservations).where(eq(berthReservations.clientId, args.clientId)); + + // Finally, the client itself. + await tx.delete(clients).where(eq(clients.id, args.clientId)); + }); + + void createAuditLog({ + portId: args.portId, + userId: args.requesterUserId, + action: 'hard_delete', + entityType: 'client', + entityId: args.clientId, + metadata: { fullName: client.fullName }, + ipAddress: args.meta.ipAddress, + userAgent: args.meta.userAgent, + }); + + logger.warn( + { clientId: args.clientId, portId: args.portId, userId: args.requesterUserId }, + 'Client hard-deleted', + ); + + return { deletedClientId: args.clientId }; +} + +// ─── Bulk hard delete ─────────────────────────────────────────────────────── + +function hashIds(ids: string[]): string { + // Stable hash so the same set always produces the same key — order + // independent. SHA-1 is more than enough for collision-avoidance on + // a per-user keyspace. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { createHash } = require('node:crypto') as typeof import('node:crypto'); + const sorted = [...ids].sort().join('|'); + return createHash('sha1').update(sorted).digest('hex'); +} + +function bulkCodeKey(userId: string, idsHash: string): string { + return `client-bulk-hard-delete-code:${userId}:${idsHash}`; +} + +export async function requestBulkHardDeleteCode(args: { + clientIds: string[]; + portId: string; + requesterUserId: string; + meta: AuditMeta; +}): Promise<{ count: number; sentToMaskedEmail: string }> { + if (args.clientIds.length === 0) { + throw new ValidationError('No clients selected'); + } + if (args.clientIds.length > 100) { + throw new ValidationError('Maximum 100 clients per bulk hard-delete'); + } + // Verify every client belongs to this port AND is archived. All-or- + // nothing: refuse if any row violates either constraint. + const rows = await db + .select({ id: clients.id, fullName: clients.fullName, archivedAt: clients.archivedAt }) + .from(clients) + .where(eq(clients.portId, args.portId)); + const found = new Map(rows.map((r) => [r.id, r])); + for (const id of args.clientIds) { + const c = found.get(id); + if (!c) throw new NotFoundError(`client ${id}`); + if (!c.archivedAt) { + throw new ConflictError(`Client ${c.fullName} is not archived`); + } + } + + const [u] = await db + .select({ email: authUser.email, name: authUser.name }) + .from(authUser) + .where(eq(authUser.id, args.requesterUserId)) + .limit(1); + if (!u) throw new NotFoundError('user'); + + const idsHash = hashIds(args.clientIds); + const code = generateCode(); + await redis.set(bulkCodeKey(args.requesterUserId, idsHash), code, 'EX', CODE_TTL_SECONDS); + + const subject = `Confirmation code: permanently delete ${args.clientIds.length} clients`; + const html = ` +

Hello ${u.name},

+

You requested to permanently delete ${args.clientIds.length} + archived clients in bulk.

+

Enter this code in the confirmation dialog to proceed:

+

${code}

+

This code expires in 10 minutes. If you didn’t request this, + you can safely ignore this email — no action will be taken.

+ `; + const text = [ + `Hello ${u.name},`, + '', + `You requested to permanently delete ${args.clientIds.length} archived clients in bulk.`, + '', + `Confirmation code: ${code}`, + `(expires in 10 minutes)`, + '', + `If you didn't request this, you can safely ignore this email.`, + ].join('\n'); + + try { + await sendEmail(u.email, subject, html, undefined, text, args.portId); + } catch (err) { + await redis.del(bulkCodeKey(args.requesterUserId, idsHash)).catch(() => undefined); + throw err; + } + + void createAuditLog({ + portId: args.portId, + userId: args.requesterUserId, + action: 'request_hard_delete_code', + entityType: 'client', + entityId: 'bulk', + metadata: { count: args.clientIds.length, sentTo: u.email }, + ipAddress: args.meta.ipAddress, + userAgent: args.meta.userAgent, + }); + + return { count: args.clientIds.length, sentToMaskedEmail: maskEmail(u.email) }; +} + +export async function bulkHardDeleteClients(args: { + clientIds: string[]; + portId: string; + requesterUserId: string; + code: string; + typedPhrase: string; + meta: AuditMeta; +}): Promise<{ deletedCount: number }> { + if (args.clientIds.length === 0) { + throw new ValidationError('No clients selected'); + } + // Phrase format: "DELETE N CLIENTS" (case-insensitive). + const expectedPhrase = `delete ${args.clientIds.length} client${args.clientIds.length === 1 ? '' : 's'}`; + if (args.typedPhrase.trim().toLowerCase() !== expectedPhrase) { + throw new ValidationError(`Type "${expectedPhrase.toUpperCase()}" exactly to confirm`); + } + + const idsHash = hashIds(args.clientIds); + const key = bulkCodeKey(args.requesterUserId, idsHash); + const stored = await redis.get(key); + if (!stored) { + throw new ValidationError('Confirmation code expired or not requested for this exact set'); + } + if (!safeEqualStr(stored, args.code.trim())) { + throw new ValidationError('Confirmation code is incorrect'); + } + await redis.del(key); + + let deleted = 0; + for (const id of args.clientIds) { + try { + // Reuse the single-client path so the cascade logic stays in one + // place. We pass a synthetic per-client code that bypasses the + // single-client redis check by writing a one-shot value. + const singleKey = codeKey(args.requesterUserId, id); + const oneShot = generateCode(); + await redis.set(singleKey, oneShot, 'EX', 60); + const [c] = await db + .select({ fullName: clients.fullName }) + .from(clients) + .where(and(eq(clients.id, id), eq(clients.portId, args.portId))) + .limit(1); + if (!c) continue; + await hardDeleteClient({ + clientId: id, + portId: args.portId, + requesterUserId: args.requesterUserId, + code: oneShot, + typedName: c.fullName, + meta: args.meta, + }); + deleted += 1; + } catch (err) { + logger.error({ err, clientId: id }, 'bulk hard-delete: client failed, continuing'); + } + } + + return { deletedCount: deleted }; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function maskEmail(email: string): string { + const [local, domain] = email.split('@'); + if (!local || !domain) return email; + if (local.length <= 2) return `${local[0] ?? ''}***@${domain}`; + return `${local.slice(0, 2)}***@${domain}`; +} diff --git a/tests/helpers/factories.ts b/tests/helpers/factories.ts index f7a1e50..440411c 100644 --- a/tests/helpers/factories.ts +++ b/tests/helpers/factories.ts @@ -366,6 +366,7 @@ export function makeFullPermissions(): RolePermissions { manage_forms: true, manage_tags: true, system_backup: true, + permanently_delete_clients: true, }, residential_clients: { view: true, create: true, edit: true, delete: true }, residential_interests: { @@ -445,6 +446,7 @@ export function makeViewerPermissions(): RolePermissions { manage_forms: false, manage_tags: false, system_backup: false, + permanently_delete_clients: false, }, residential_clients: { view: false, create: false, edit: false, delete: false }, residential_interests: { @@ -524,6 +526,7 @@ export function makeSalesAgentPermissions(): RolePermissions { manage_forms: false, manage_tags: false, system_backup: false, + permanently_delete_clients: false, }, residential_clients: { view: false, create: false, edit: false, delete: false }, residential_interests: { @@ -603,6 +606,7 @@ export function makeSalesManagerPermissions(): RolePermissions { manage_forms: false, manage_tags: true, system_backup: false, + permanently_delete_clients: false, }, residential_clients: { view: true, create: true, edit: true, delete: true }, residential_interests: { @@ -622,6 +626,7 @@ export function makeDirectorPermissions(): RolePermissions { admin: { ...makeFullPermissions().admin, system_backup: false, + permanently_delete_clients: false, }, }; }