fix: cascade-archive client's open interests — F10

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 22:53:42 +02:00
parent f85948488d
commit 98fe295675

View File

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