feat(documents): ensureEntityFolder (concurrent-safe + suffix on collision)

Idempotent per-entity subfolder creation under the matching system
root. Fast-path SELECT short-circuits the common case. Inserts race
safely via uniq_document_folders_entity (partial unique on
port_id+entity_type+entity_id) — the loser re-SELECTs the winner's
row. Sibling-name collisions between two entities with the same
display name append (2), (3), … to the new folder; existing folders
never rename. Exports EntityType for use by downstream tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 11:14:11 +02:00
parent a23a9862cc
commit 2f3200764a
2 changed files with 239 additions and 1 deletions

View File

@@ -2,6 +2,9 @@ import { and, asc, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { documentFolders, documents, type DocumentFolder } from '@/lib/db/schema/documents';
import { clients } from '@/lib/db/schema/clients';
import { companies } from '@/lib/db/schema/companies';
import { yachts } from '@/lib/db/schema/yachts';
import { createAuditLog } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
@@ -343,3 +346,161 @@ export async function ensureSystemRoots(portId: string, userId: string): Promise
return row;
});
}
// ─── ensureEntityFolder ──────────────────────────────────────────────────────
export type EntityType = 'client' | 'company' | 'yacht';
const ENTITY_TYPES = new Set<EntityType>(['client', 'company', 'yacht']);
/**
* Returns the display name for an entity used to label its subfolder.
* Clients use `fullName` verbatim (matching rep-facing list views).
* Companies and yachts use their `name` column verbatim.
*/
async function resolveEntityDisplayName(
portId: string,
entityType: EntityType,
entityId: string,
): Promise<string> {
if (entityType === 'client') {
const c = await db.query.clients.findFirst({
where: and(eq(clients.id, entityId), eq(clients.portId, portId)),
columns: { fullName: true },
});
if (!c) throw new NotFoundError('Client');
return c.fullName;
}
if (entityType === 'company') {
const co = await db.query.companies.findFirst({
where: and(eq(companies.id, entityId), eq(companies.portId, portId)),
columns: { name: true },
});
if (!co) throw new NotFoundError('Company');
return co.name;
}
// yacht
const y = await db.query.yachts.findFirst({
where: and(eq(yachts.id, entityId), eq(yachts.portId, portId)),
columns: { name: true },
});
if (!y) throw new NotFoundError('Yacht');
return y.name;
}
/**
* Returns true if the error is a Postgres unique-violation (SQLSTATE 23505)
* on the per-entity unique index (`uniq_document_folders_entity`). Narrowing
* to this specific constraint lets callers distinguish a concurrent-winner
* race (same entity_id inserted twice) from a sibling-name collision.
*/
function isEntityFolderConflict(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const e = err as {
code?: unknown;
constraint_name?: unknown;
constraint?: unknown;
cause?: { code?: unknown; constraint_name?: unknown; constraint?: unknown };
};
const code = e.code ?? e.cause?.code;
if (code !== '23505') return false;
const constraint =
e.constraint_name ?? e.constraint ?? e.cause?.constraint_name ?? e.cause?.constraint;
return constraint === 'uniq_document_folders_entity';
}
/**
* Idempotently create the per-entity subfolder under the matching system
* root (`Clients/` / `Companies/` / `Yachts/`). Returns the folder row
* regardless of whether it was newly created or already existed.
*
* Concurrent callers race safely via the partial unique index
* `uniq_document_folders_entity` — the loser INSERT errors and the
* re-SELECT returns the winner's row.
*
* On sibling-name collision (two entities want the same display name),
* appends a numeric suffix `(2)`, `(3)`, …, until the insert succeeds.
* The `system_managed` flag stays true on the suffixed folder.
*
* Note: this helper deliberately does NOT use the no-args
* onConflictDoNothing() pattern from ensureSystemRoots. Entity inserts
* can conflict on EITHER uniq_document_folders_entity (race winner
* already inserted the same entity_id) OR uniq_document_folders_sibling_name
* (different entity, same display name). We need to discriminate so we
* can retry-with-suffix on the name collision and re-SELECT-and-return
* on the entity-id collision.
*/
export async function ensureEntityFolder(
portId: string,
entityType: EntityType,
entityId: string,
userId: string,
): Promise<DocumentFolder> {
if (!ENTITY_TYPES.has(entityType)) {
throw new ValidationError(`Unknown entity type: ${entityType}`);
}
// Fast path: row already exists.
const existing = await db.query.documentFolders.findFirst({
where: and(
eq(documentFolders.portId, portId),
eq(documentFolders.entityType, entityType),
eq(documentFolders.entityId, entityId),
),
});
if (existing) return existing;
// Locate the system root for this entity type.
const rootName: SystemRootName =
entityType === 'client' ? 'Clients' : entityType === 'company' ? 'Companies' : 'Yachts';
const root = await db.query.documentFolders.findFirst({
where: and(
eq(documentFolders.portId, portId),
eq(documentFolders.entityType, 'root'),
eq(documentFolders.name, rootName),
),
});
if (!root) {
// Self-heal: the port-init hook may have been skipped (legacy port).
await ensureSystemRoots(portId, userId);
return ensureEntityFolder(portId, entityType, entityId, userId);
}
const baseName = await resolveEntityDisplayName(portId, entityType, entityId);
// Try the base name first; on sibling-name collision, append (2), (3)...
for (let attempt = 0; attempt < 50; attempt += 1) {
const candidate = attempt === 0 ? baseName : `${baseName} (${attempt + 1})`;
try {
const [row] = await db
.insert(documentFolders)
.values({
portId,
parentId: root.id,
name: candidate,
systemManaged: true,
entityType,
entityId,
createdBy: userId,
})
.returning();
if (!row) throw new Error('ensureEntityFolder: insert returned no row');
return row;
} catch (err) {
// If another caller won the entity-id race, re-SELECT and return their row.
if (isEntityFolderConflict(err)) {
const winner = await db.query.documentFolders.findFirst({
where: and(
eq(documentFolders.portId, portId),
eq(documentFolders.entityType, entityType),
eq(documentFolders.entityId, entityId),
),
});
if (winner) return winner;
}
// Sibling-name collision (different entity, same name) → bump suffix and retry.
if (isSiblingNameConflict(err)) continue;
throw err;
}
}
throw new ConflictError(`Could not allocate a unique folder name for ${baseName}`);
}