/** * 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(sql`LOWER(${websiteSubmissions.payload}->>'email')`, emailValues), ), ); } // A.7 RTBF wipe — Article-17 erasure of PII-bearing fields, not just FK // detach. The previous code merely nullified clientId, which left: // - email_messages.{body_html, body_text, subject, from/to/cc} intact // - document_sends.recipient_email intact // - files.{original_name, storage_path blobs} intact // Below we (a) collect blob storage paths so we can delete them // post-commit, (b) redact PII text columns to a sentinel, and only // then (c) detach the FKs so the audit-trail rows survive without // their data subject's content. // (a) Collect file storage paths + original filenames (which may // themselves contain PII like "alice-smith-passport.pdf"). const fileRows = await tx .select({ id: files.id, storagePath: files.storagePath }) .from(files) .where(eq(files.clientId, args.clientId)); blobStorageKeys.push(...fileRows.map((f) => f.storagePath)); if (fileRows.length > 0) { await tx .update(files) .set({ clientId: null, originalName: ERASED_SENTINEL, filename: ERASED_SENTINEL, }) .where(eq(files.clientId, args.clientId)); } // (b) Redact email_messages content for threads owned by this client. // Threads themselves stay (we detach via clientId=null below) so the // audit log "a thread existed" remains; the message bodies, subjects, // and address arrays — all PII — get wiped. const threadRows = await tx .select({ id: emailThreads.id }) .from(emailThreads) .where(eq(emailThreads.clientId, args.clientId)); if (threadRows.length > 0) { const threadIds = threadRows.map((t) => t.id); await tx .update(emailMessages) .set({ bodyHtml: ERASED_SENTINEL, bodyText: ERASED_SENTINEL, subject: ERASED_SENTINEL, fromAddress: ERASED_SENTINEL, toAddresses: [ERASED_SENTINEL], ccAddresses: null, }) .where(inArray(emailMessages.threadId, threadIds)); } 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)); // (c) document_sends — redact recipient_email when detaching. The row // stays (audit log "a doc was sent") but the recipient identity is wiped. await tx .update(documentSends) .set({ clientId: null, recipientEmail: ERASED_SENTINEL }) .where(eq(documentSends.clientId, args.clientId)); // G-C2: scratchpad_notes.linked_client_id is RESTRICT (default for no // onDelete clause). Any rep who linked a scratchpad note to this client // would otherwise throw an FK violation when we try to delete the // client row below. Nullify so the note survives the hard-delete. await tx .update(scratchpadNotes) .set({ linkedClientId: null }) .where(eq(scratchpadNotes.linkedClientId, 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)); }); // G-C3 / A7: demote the system-managed folder so the partial unique // index `uniq_document_folders_entity` releases its slot. Done as a // post-commit fire-and-forget — folder hygiene is non-essential to the // delete being durable, and we don't want a folder-table glitch to // un-delete the client by aborting the outer transaction. void demoteSystemFolderOnEntityDelete(args.portId, 'client', args.clientId).catch((err) => { logger.error( { err, clientId: args.clientId, portId: args.portId }, 'hardDeleteClient: failed to demote system folder', ); }); // A.7 RTBF: delete blobs from storage post-commit. We never want a // storage error to abort the DB tx (PII removal must succeed durably // even if S3 is flaky), so this runs after commit and logs failures // individually. Surviving blobs without a row reference are reaped by // the standard orphan-blob sweeper job. if (blobStorageKeys.length > 0) { void (async () => { const storage = await getStorageBackend(); let deleted = 0; for (const key of blobStorageKeys) { try { await storage.delete(key); deleted += 1; } catch (err) { logger.error( { err, clientId: args.clientId, storageKey: key }, 'hardDeleteClient: blob delete failed (RTBF)', ); } } logger.info( { clientId: args.clientId, deletedBlobs: deleted, totalBlobs: blobStorageKeys.length }, 'hardDeleteClient: blob deletion complete', ); })(); } 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. 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 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 { 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); // Same error for both cases — see single-client variant for rationale. // Code is tied to the exact set hash so a wrong-set probe fails here too. if (!stored || !safeEqualStr(stored, args.code.trim())) { throw new ValidationError('Invalid or expired confirmation code'); } await redis.del(key); let deleted = 0; const skipped: BulkHardDeleteResult['skipped'] = []; 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, archivedAt: clients.archivedAt }) .from(clients) .where(and(eq(clients.id, id), eq(clients.portId, args.portId))) .limit(1); if (!c) { skipped.push({ clientId: id, reason: 'client no longer exists' }); continue; } if (!c.archivedAt) { skipped.push({ clientId: id, reason: 'client is no longer archived (un-archived since preflight)', }); 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'); skipped.push({ clientId: id, reason: err instanceof Error ? err.message : 'unknown failure', }); } } return { deletedCount: deleted, skipped }; } 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}`; }