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 { emitToRoom } from '@/lib/socket/server';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
import { buildListQuery } from '@/lib/db/query-builder';
|
||||||
import { diffEntity } from '@/lib/entity-diff';
|
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 { logger } from '@/lib/logger';
|
||||||
import {
|
import {
|
||||||
syncEntityFolderName,
|
syncEntityFolderName,
|
||||||
@@ -555,17 +555,37 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta)
|
|||||||
throw new NotFoundError('Client');
|
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
|
// fire-and-forget: archive UI does not depend on the folder suffix
|
||||||
// being stamped before the HTTP response returns. Task 5 (rename
|
// being stamped before the HTTP response returns. Task 5 (rename
|
||||||
// hook) uses await because the rename should be visible to the
|
// hook) uses await because the rename should be visible to the
|
||||||
// next read; archive does not.
|
// next read; archive does not.
|
||||||
void applyEntityArchivedSuffix(portId, 'client', id, meta.userId).catch((err) => {
|
void applyEntityArchivedSuffix(portId, 'client', id, meta.userId).catch((err) => {
|
||||||
logger.warn(
|
logger.warn({ err, clientId: id, portId }, 'Failed to apply archived suffix to client folder');
|
||||||
{ err, clientId: id, portId },
|
|
||||||
'Failed to apply archived suffix to client folder',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
void createAuditLog({
|
void createAuditLog({
|
||||||
@@ -574,11 +594,18 @@ export async function archiveClient(id: string, portId: string, meta: AuditMeta)
|
|||||||
action: 'archive',
|
action: 'archive',
|
||||||
entityType: 'client',
|
entityType: 'client',
|
||||||
entityId: id,
|
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,
|
ipAddress: meta.ipAddress,
|
||||||
userAgent: meta.userAgent,
|
userAgent: meta.userAgent,
|
||||||
});
|
});
|
||||||
|
|
||||||
emitToRoom(`port:${portId}`, 'client:archived', { clientId: id });
|
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 }) =>
|
void import('@/lib/services/webhook-dispatch').then(({ dispatchWebhookEvent }) =>
|
||||||
dispatchWebhookEvent(portId, 'client:archived', { clientId: id }),
|
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);
|
await restore(clients, clients.id, id);
|
||||||
|
|
||||||
void applyEntityRestoredSuffix(portId, 'client', id, meta.userId).catch((err) => {
|
void applyEntityRestoredSuffix(portId, 'client', id, meta.userId).catch((err) => {
|
||||||
logger.warn(
|
logger.warn({ err, clientId: id, portId }, 'Failed to clear archived suffix on client folder');
|
||||||
{ err, clientId: id, portId },
|
|
||||||
'Failed to clear archived suffix on client folder',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
void createAuditLog({
|
void createAuditLog({
|
||||||
|
|||||||
Reference in New Issue
Block a user