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:
2026-05-11 11:25:16 +02:00
parent 64d0ae540b
commit 86a6944d1c
5 changed files with 144 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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!;
}

View File

@@ -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<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}`);
}

View File

@@ -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!;
}