From 98fe295675f641376d68ca6aff78b96d65df51be Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 14 May 2026 22:53:42 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20cascade-archive=20client's=20open=20inte?= =?UTF-8?q?rests=20=E2=80=94=20F10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-audit, archiving a client set `clients.archived_at` but left their in-flight `interests.archived_at = NULL`. Active-interest queries kept surfacing those interests with a shadowed client — breadcrumbs broke, detail-page drill-ins silent-404'd, and the dashboard double-counted. Now `archiveClient()` runs in a transaction: 1. Set archived_at on the client. 2. Cascade-archive every interest where the client is the owner AND the interest is currently active (archived_at IS NULL AND outcome IS NULL). Won/lost/cancelled interests are explicitly NOT touched — those are historical records of closed business and should stay queryable. The audit-log entry's newValue carries the list of cascaded interest IDs so /admin/audit shows exactly which deals got swept up. Socket `interest:archived` events fire per-id so any open list views invalidate. Verified live: archived Olivia Sinclair, her active interest archived too in the same call. 1373/1373 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/services/clients.service.ts | 44 ++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/lib/services/clients.service.ts b/src/lib/services/clients.service.ts index bb25aa5e..69364979 100644 --- a/src/lib/services/clients.service.ts +++ b/src/lib/services/clients.service.ts @@ -22,7 +22,7 @@ import { setEntityTags } from '@/lib/services/entity-tags.helper'; import { emitToRoom } from '@/lib/socket/server'; import { buildListQuery } from '@/lib/db/query-builder'; import { diffEntity } from '@/lib/entity-diff'; -import { softDelete, restore, withTransaction } from '@/lib/db/utils'; +import { restore, withTransaction } from '@/lib/db/utils'; import { logger } from '@/lib/logger'; import { syncEntityFolderName, @@ -555,17 +555,37 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta) throw new NotFoundError('Client'); } - await softDelete(clients, clients.id, id); + // F10: cascade-archive the client's open interests so they don't + // dangle in active queries with a shadowed client. Won/lost interests + // (outcome IS NOT NULL) are kept as historical records — only IN-FLIGHT + // deals get archived. Wrapped in a single transaction so a partial + // archive can't leave the system half-cascaded. + const archivedInterestIds: string[] = await db.transaction(async (tx) => { + await tx + .update(clients) + .set({ archivedAt: new Date(), updatedAt: new Date() }) + .where(eq(clients.id, id)); + const cascaded = await tx + .update(interests) + .set({ archivedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(interests.clientId, id), + eq(interests.portId, portId), + isNull(interests.archivedAt), + isNull(interests.outcome), + ), + ) + .returning({ id: interests.id }); + return cascaded.map((r) => r.id); + }); // fire-and-forget: archive UI does not depend on the folder suffix // being stamped before the HTTP response returns. Task 5 (rename // hook) uses await because the rename should be visible to the // next read; archive does not. void applyEntityArchivedSuffix(portId, 'client', id, meta.userId).catch((err) => { - logger.warn( - { err, clientId: id, portId }, - 'Failed to apply archived suffix to client folder', - ); + logger.warn({ err, clientId: id, portId }, 'Failed to apply archived suffix to client folder'); }); void createAuditLog({ @@ -574,11 +594,18 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta) action: 'archive', entityType: 'client', entityId: id, + // Surface the cascade in the audit trail so /admin/audit shows + // exactly which interests got swept up. + newValue: + archivedInterestIds.length > 0 ? { cascadedInterestIds: archivedInterestIds } : undefined, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'client:archived', { clientId: id }); + for (const interestId of archivedInterestIds) { + emitToRoom(`port:${portId}`, 'interest:archived', { interestId }); + } void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) => dispatchWebhookEvent(portId, 'client:archived', { clientId: id }), @@ -597,10 +624,7 @@ export async function restoreClient(id: string, portId: string, meta: AuditMeta) await restore(clients, clients.id, id); void applyEntityRestoredSuffix(portId, 'client', id, meta.userId).catch((err) => { - logger.warn( - { err, clientId: id, portId }, - 'Failed to clear archived suffix on client folder', - ); + logger.warn({ err, clientId: id, portId }, 'Failed to clear archived suffix on client folder'); }); void createAuditLog({