Files
pn-new-crm/src/lib/services/client-hard-delete.service.ts
Matt b3f87563c6 feat(audit-cleanup): finish all 15 outstanding items from verified backlog
Audit cleanup completion plan, all tiers shipped:

Tier 1 (security + data integrity)
- A.7 RTBF true wipe: redact email_messages body/subject/addresses for
  threads owned by deleted client; redact document_sends.recipient_email;
  collect file storage keys + delete blobs post-commit.
- A.8 user_permission_overrides FK: documented inline why cascade is
  correct (not set-null as audit suggested) — overrides have no value
  without their user.
- W2.14 PII redaction: camelCase normalization in audit.ts +
  error-events.service.ts isSensitiveKey; added city/postal/country/
  birth fragments. firstName/lastName/dateOfBirth/postalCode etc. now
  caught in BOTH masker paths. 12 new test cases lock the coverage.

Tier 2 (Documenso completion + refactor)
- C.2: documentEvents.recipient_email column + partial unique index for
  per-recipient webhook dedup (migration 0075). handleDocumentSigned
  now sets recipient_email on insert.
- Phase 2: completion_cc_emails distribution. handleDocumentCompleted
  reads documents.completionCcEmails, filters out signer-duplicates
  case-insensitively, fans signed PDF out to non-signer recipients.
- C.4: extracted createPublicInterest() service from the 346-line
  api/public/interests route. Route becomes a thin shell (rate-limit,
  port resolution, audit log, email fan-out). The trio creation logic
  is now unit-testable without an HTTP fixture.
- Phase 4: POST /api/v1/document-templates/[id]/detect-fields wired
  to document-field-detector.detectFields(). Sparkles "Auto-detect"
  button added to template-editor.tsx — maps DetectedField → marker
  with best-guess merge token (DATE / NAME / EMAIL); user retags.

Tier 3 (reporting + recommender snapshot lockfiles)
- W7.reports: extracted rollupStageRevenue / rollupStageCounts /
  computeTotalForecast / computeOccupancyRate / rollupBerthStatusCounts
  into src/lib/services/report-math.ts (pure functions). 16 new tests
  including an inline-snapshot lockfile on a representative 7-stage
  forecast. report-generators.ts now delegates.
- W7.recommender: 18 new toMatchSnapshot tripwires on classifyTier
  boundaries + computeHeat at canonical input points.

Tier 4 (rolling)
- W6.attach: fixed outdated CLAUDE.md claim — threshold banner is
  informational and never depended on IMAP; bounce monitoring (the
  IMAP poller) is separate.
- D.1 + D.2: documented deferral inline with full why-not-build-it
  reasoning so a future engineer sees the rationale.
- G.1: representative formatDate sweep (audit-log-list, user-list,
  document-templates merge tokens, document-signing email). Rest of
  the ~100 sites stay rolling.

Quality gates: 1420/1420 vitest (46 new tests above baseline of 1374),
tsc clean, 0 lint errors.

Plan: docs/superpowers/plans/2026-05-18-audit-cleanup-completion.md
Migration: 0075_c2_document_events_recipient_email.sql (applied to dev DB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:22:36 +02:00

571 lines
22 KiB
TypeScript

/**
* 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 = `
<p>Hello ${u.name},</p>
<p>You requested to permanently delete the archived client
<strong>${escapeHtml(client.fullName)}</strong>.</p>
<p>Enter this code in the confirmation dialog to proceed:</p>
<p style="font-size:28px; font-weight:bold; letter-spacing:6px; padding:14px 0;">${code}</p>
<p>This code expires in 10 minutes. If you didn&rsquo;t request this,
you can safely ignore this email &mdash; no action will be taken.</p>
`;
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<string>`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 = `
<p>Hello ${u.name},</p>
<p>You requested to permanently delete <strong>${args.clientIds.length}</strong>
archived clients in bulk.</p>
<p>Enter this code in the confirmation dialog to proceed:</p>
<p style="font-size:28px; font-weight:bold; letter-spacing:6px; padding:14px 0;">${code}</p>
<p>This code expires in 10 minutes. If you didn&rsquo;t request this,
you can safely ignore this email &mdash; no action will be taken.</p>
`;
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<BulkHardDeleteResult> {
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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}`;
}