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({