From 86a6944d1c1f5b8d5a21f1ea18e298f5e3c32f41 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 11 May 2026 11:25:16 +0200 Subject: [PATCH] feat(documents): syncEntityFolderName + entity-rename hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/lib/services/clients.service.ts | 8 +++ src/lib/services/companies.service.ts | 8 +++ src/lib/services/document-folders.service.ts | 56 ++++++++++++++++ src/lib/services/yachts.service.ts | 8 +++ .../document-folders-system-folders.test.ts | 64 +++++++++++++++++++ 5 files changed, 144 insertions(+) diff --git a/src/lib/services/clients.service.ts b/src/lib/services/clients.service.ts index f34b016c..c060c2df 100644 --- a/src/lib/services/clients.service.ts +++ b/src/lib/services/clients.service.ts @@ -23,6 +23,8 @@ 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 { logger } from '@/lib/logger'; +import { syncEntityFolderName } from '@/lib/services/document-folders.service'; import type { CreateClientInput, UpdateClientInput, @@ -529,6 +531,12 @@ export async function updateClient( 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; } diff --git a/src/lib/services/companies.service.ts b/src/lib/services/companies.service.ts index 2c74d5a7..a0b88eb9 100644 --- a/src/lib/services/companies.service.ts +++ b/src/lib/services/companies.service.ts @@ -12,6 +12,8 @@ import { withTransaction } from '@/lib/db/utils'; import { buildListQuery } from '@/lib/db/query-builder'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; 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 { setEntityTags } from '@/lib/services/entity-tags.helper'; import { diffEntity } from '@/lib/entity-diff'; @@ -181,6 +183,12 @@ export async function updateCompany( 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!; } diff --git a/src/lib/services/document-folders.service.ts b/src/lib/services/document-folders.service.ts index e6fcb852..14483edc 100644 --- a/src/lib/services/document-folders.service.ts +++ b/src/lib/services/document-folders.service.ts @@ -515,3 +515,59 @@ export async function ensureEntityFolder( } 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 { + 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}`); +} diff --git a/src/lib/services/yachts.service.ts b/src/lib/services/yachts.service.ts index dc1c48e5..f6001f1c 100644 --- a/src/lib/services/yachts.service.ts +++ b/src/lib/services/yachts.service.ts @@ -5,6 +5,8 @@ import type { Yacht } from '@/lib/db/schema/yachts'; import { companies } from '@/lib/db/schema/companies'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; 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 { setEntityTags } from '@/lib/services/entity-tags.helper'; import { diffEntity } from '@/lib/entity-diff'; @@ -161,6 +163,12 @@ export async function updateYacht( 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!; } diff --git a/tests/unit/document-folders-system-folders.test.ts b/tests/unit/document-folders-system-folders.test.ts index 1caa58a6..22299ada 100644 --- a/tests/unit/document-folders-system-folders.test.ts +++ b/tests/unit/document-folders-system-folders.test.ts @@ -138,6 +138,7 @@ import { deleteFolderSoftRescue, moveFolder, renameFolder, + syncEntityFolderName, } from '@/lib/services/document-folders.service'; describe('document-folders service · system folder protection', () => { @@ -188,3 +189,66 @@ describe('document-folders service · system folder protection', () => { ).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)`); + }); +});