feat(documents): syncEntityFolderName + entity-rename hooks
Per-entity subfolder names mirror the entity's current display string. Wired into updateClient / updateCompany / updateYacht; runs only when the name field changes. Best-effort (logged + swallowed) so a folder- sync error never fails an entity update. Preserves the (archived) suffix when present; skips entirely when the folder has been demoted to (deleted) — the rep owns the name at that point. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,8 @@ 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 { softDelete, restore, withTransaction } from '@/lib/db/utils';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { syncEntityFolderName } from '@/lib/services/document-folders.service';
|
||||||
import type {
|
import type {
|
||||||
CreateClientInput,
|
CreateClientInput,
|
||||||
UpdateClientInput,
|
UpdateClientInput,
|
||||||
@@ -529,6 +531,12 @@ export async function updateClient(
|
|||||||
dispatchWebhookEvent(portId, 'client:updated', { clientId: id }),
|
dispatchWebhookEvent(portId, 'client:updated', { clientId: id }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (data.fullName !== undefined) {
|
||||||
|
await syncEntityFolderName(portId, 'client', id, meta.userId).catch((err) => {
|
||||||
|
logger.error({ err, clientId: id }, 'Failed to sync client folder name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { withTransaction } from '@/lib/db/utils';
|
|||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
import { buildListQuery } from '@/lib/db/query-builder';
|
||||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
import { NotFoundError, ConflictError } from '@/lib/errors';
|
import { NotFoundError, ConflictError } from '@/lib/errors';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { syncEntityFolderName } from '@/lib/services/document-folders.service';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
||||||
import { diffEntity } from '@/lib/entity-diff';
|
import { diffEntity } from '@/lib/entity-diff';
|
||||||
@@ -181,6 +183,12 @@ export async function updateCompany(
|
|||||||
changedFields: Object.keys(diff),
|
changedFields: Object.keys(diff),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (data.name !== undefined) {
|
||||||
|
await syncEntityFolderName(portId, 'company', id, meta.userId).catch((err) => {
|
||||||
|
logger.error({ err, companyId: id }, 'Failed to sync company folder name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return updated!;
|
return updated!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -515,3 +515,59 @@ export async function ensureEntityFolder(
|
|||||||
}
|
}
|
||||||
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
|
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename the per-entity subfolder to match the entity's current display
|
||||||
|
* name. Called from the entity rename services (`updateClient`,
|
||||||
|
* `updateCompany`, `updateYacht`). No-op when the folder does not exist
|
||||||
|
* (lazy creation — entities without a folder skip the sync entirely).
|
||||||
|
*
|
||||||
|
* Sibling-name collision is resolved by suffix bump (matches
|
||||||
|
* `ensureEntityFolder` semantics).
|
||||||
|
*
|
||||||
|
* Intentionally does NOT call `assertNotSystemManaged` — this helper
|
||||||
|
* is the legitimate path for renaming a system folder.
|
||||||
|
*/
|
||||||
|
export async function syncEntityFolderName(
|
||||||
|
portId: string,
|
||||||
|
entityType: EntityType,
|
||||||
|
entityId: string,
|
||||||
|
_userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!ENTITY_TYPES.has(entityType)) return;
|
||||||
|
|
||||||
|
const folder = await db.query.documentFolders.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(documentFolders.portId, portId),
|
||||||
|
eq(documentFolders.entityType, entityType),
|
||||||
|
eq(documentFolders.entityId, entityId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!folder) return; // Lazy creation — nothing to sync yet.
|
||||||
|
|
||||||
|
// Preserve archived suffix if present.
|
||||||
|
const isArchived = folder.name.endsWith(' (archived)');
|
||||||
|
const isDeleted = folder.name.endsWith(' (deleted)');
|
||||||
|
if (isDeleted) return; // Demoted; rep owns the name now.
|
||||||
|
|
||||||
|
const baseName = await resolveEntityDisplayName(portId, entityType, entityId);
|
||||||
|
const targetSuffix = isArchived ? ' (archived)' : '';
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||||
|
const candidate =
|
||||||
|
attempt === 0 ? `${baseName}${targetSuffix}` : `${baseName} (${attempt + 1})${targetSuffix}`;
|
||||||
|
if (candidate === folder.name) return; // No-op rename.
|
||||||
|
try {
|
||||||
|
const [updated] = await db
|
||||||
|
.update(documentFolders)
|
||||||
|
.set({ name: candidate, updatedAt: new Date() })
|
||||||
|
.where(eq(documentFolders.id, folder.id))
|
||||||
|
.returning();
|
||||||
|
if (updated) return;
|
||||||
|
} catch (err) {
|
||||||
|
if (isSiblingNameConflict(err)) continue;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { Yacht } from '@/lib/db/schema/yachts';
|
|||||||
import { companies } from '@/lib/db/schema/companies';
|
import { companies } from '@/lib/db/schema/companies';
|
||||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import { syncEntityFolderName } from '@/lib/services/document-folders.service';
|
||||||
import { emitToRoom } from '@/lib/socket/server';
|
import { emitToRoom } from '@/lib/socket/server';
|
||||||
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
||||||
import { diffEntity } from '@/lib/entity-diff';
|
import { diffEntity } from '@/lib/entity-diff';
|
||||||
@@ -161,6 +163,12 @@ export async function updateYacht(
|
|||||||
changedFields: Object.keys(diff),
|
changedFields: Object.keys(diff),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (data.name !== undefined) {
|
||||||
|
await syncEntityFolderName(portId, 'yacht', id, meta.userId).catch((err) => {
|
||||||
|
logger.error({ err, yachtId: id }, 'Failed to sync yacht folder name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return updated!;
|
return updated!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ import {
|
|||||||
deleteFolderSoftRescue,
|
deleteFolderSoftRescue,
|
||||||
moveFolder,
|
moveFolder,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
|
syncEntityFolderName,
|
||||||
} from '@/lib/services/document-folders.service';
|
} from '@/lib/services/document-folders.service';
|
||||||
|
|
||||||
describe('document-folders service · system folder protection', () => {
|
describe('document-folders service · system folder protection', () => {
|
||||||
@@ -188,3 +189,66 @@ describe('document-folders service · system folder protection', () => {
|
|||||||
).resolves.toBeDefined();
|
).resolves.toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('document-folders service · syncEntityFolderName', () => {
|
||||||
|
let portId: string;
|
||||||
|
let clientId: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const port = await makePort();
|
||||||
|
portId = port.id;
|
||||||
|
await db.delete(documentFolders).where(eq(documentFolders.portId, portId));
|
||||||
|
await ensureSystemRoots(portId, TEST_USER_ID);
|
||||||
|
const originalFullName = `John Smith ${crypto.randomUUID().slice(0, 6)}`;
|
||||||
|
const [client] = await db
|
||||||
|
.insert(clients)
|
||||||
|
.values({
|
||||||
|
portId,
|
||||||
|
fullName: originalFullName,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
clientId = client!.id;
|
||||||
|
await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renames the entity subfolder when the entity is renamed', async () => {
|
||||||
|
const newName = `Jonathan Smith ${crypto.randomUUID().slice(0, 6)}`;
|
||||||
|
await db.update(clients).set({ fullName: newName }).where(eq(clients.id, clientId));
|
||||||
|
await syncEntityFolderName(portId, 'client', clientId, TEST_USER_ID);
|
||||||
|
const folder = await db.query.documentFolders.findFirst({
|
||||||
|
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
|
||||||
|
});
|
||||||
|
expect(folder?.name).toBe(newName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when the folder does not exist (lazy creation)', async () => {
|
||||||
|
const otherPort = await makePort();
|
||||||
|
await ensureSystemRoots(otherPort.id, TEST_USER_ID);
|
||||||
|
const [otherClient] = await db
|
||||||
|
.insert(clients)
|
||||||
|
.values({ portId: otherPort.id, fullName: `Jane Doe ${crypto.randomUUID().slice(0, 6)}` })
|
||||||
|
.returning();
|
||||||
|
// No folder created. Sync should not throw.
|
||||||
|
await expect(
|
||||||
|
syncEntityFolderName(otherPort.id, 'client', otherClient!.id, TEST_USER_ID),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends numeric suffix on rename collision (target name already taken)', async () => {
|
||||||
|
const sharedName = `Jane Smith ${crypto.randomUUID().slice(0, 6)}`;
|
||||||
|
const [collider] = await db
|
||||||
|
.insert(clients)
|
||||||
|
.values({ portId, fullName: sharedName })
|
||||||
|
.returning();
|
||||||
|
await ensureEntityFolder(portId, 'client', collider!.id, TEST_USER_ID);
|
||||||
|
|
||||||
|
// Rename John → same as collider.
|
||||||
|
await db.update(clients).set({ fullName: sharedName }).where(eq(clients.id, clientId));
|
||||||
|
await syncEntityFolderName(portId, 'client', clientId, TEST_USER_ID);
|
||||||
|
|
||||||
|
const folder = await db.query.documentFolders.findFirst({
|
||||||
|
where: and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)),
|
||||||
|
});
|
||||||
|
expect(folder?.name).toBe(`${sharedName} (2)`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user