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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user