/** * 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 { scratchpadNotes } from '@/lib/db/schema/system'; 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 { 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); // 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); 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)); // 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', ); }); 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 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