/** * 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, inArray, sql } from 'drizzle-orm'; import { db } from '@/lib/db'; import { clients, clientContacts, 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, emailMessages } from '@/lib/db/schema/email'; import { reminders } from '@/lib/db/schema/operations'; import { scratchpadNotes } from '@/lib/db/schema/system'; import { websiteSubmissions } from '@/lib/db/schema/website-submissions'; 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 { demoteSystemFolderOnEntityDelete } from '@/lib/services/document-folders.service'; import { getStorageBackend } from '@/lib/storage'; import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; const ERASED_SENTINEL = '[erased]'; 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); // Same error for both cases so an attacker can't distinguish "no code // requested" (probe to know the request endpoint window is open) from // "wrong code" (probe to brute-force the 4-digit space). The operator // has the email open and can re-request if expired. if (!stored || !safeEqualStr(stored, args.code.trim())) { throw new ValidationError('Invalid or expired confirmation code'); } // Single-use: delete the code immediately so a failed delete tx // forces the operator to request a fresh code. await redis.del(key); // Storage keys we'll need to delete POST-commit. Collected inside the tx // so the read is consistent with what the tx detached. Deleting blobs // INSIDE the tx would block the commit on remote storage latency and // leave the tx hanging if S3 is slow; deleting AFTER commit means an // S3 outage at most leaks the blob (a known acceptable RTBF tradeoff, // since the DB row is detached + filename redacted, so the blob has // no identifying metadata and can be reaped by a future sweeper). const blobStorageKeys: string[] = []; 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'); // Read email contacts BEFORE the cascade so we can wipe matching // website_submissions rows — that table has no clientId FK (raw // inquiry-form data, pre-promotion), matched only by email in the // JSONB payload. Article-17 requires removing the data subject's // submitted form data too. const emailContactRows = await tx .select({ value: clientContacts.value }) .from(clientContacts) .where(and(eq(clientContacts.clientId, args.clientId), eq(clientContacts.channel, 'email'))); const emailValues = emailContactRows .map((r) => r.value.trim().toLowerCase()) .filter((v) => v.length > 0); if (emailValues.length > 0) { await tx .delete(websiteSubmissions) .where( and( eq(websiteSubmissions.portId, args.portId), inArray(sqlHello ${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 interface BulkHardDeleteResult { deletedCount: number; /** Ids that were requested but not deleted, with a per-id reason * (e.g. became unarchived between preflight and execute, removed by * another operator, transient failure inside the inner deletion). */ skipped: Array<{ clientId: string; reason: string }>; } export async function bulkHardDeleteClients(args: { clientIds: string[]; portId: string; requesterUserId: string; code: string; typedPhrase: string; meta: AuditMeta; }): Promise