diff --git a/src/lib/services/document-folders.service.ts b/src/lib/services/document-folders.service.ts index abee0846..d94fd7f9 100644 --- a/src/lib/services/document-folders.service.ts +++ b/src/lib/services/document-folders.service.ts @@ -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(['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 { + 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 { + 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}`); +} diff --git a/tests/unit/document-folders-system-folders.test.ts b/tests/unit/document-folders-system-folders.test.ts index fd750f3f..6c1bd16f 100644 --- a/tests/unit/document-folders-system-folders.test.ts +++ b/tests/unit/document-folders-system-folders.test.ts @@ -1,5 +1,6 @@ /** * Task 2 — ensureSystemRoots (TDD). + * Task 3 — ensureEntityFolder (TDD). * * Fixture convention: makePort from helpers/factories (async DB insert); * TEST_USER_ID resolved once via beforeAll from a seeded user — same pattern @@ -11,8 +12,9 @@ import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { documentFolders } from '@/lib/db/schema/documents'; +import { clients } from '@/lib/db/schema/clients'; import { user } from '@/lib/db/schema/users'; -import { ensureSystemRoots } from '@/lib/services/document-folders.service'; +import { ensureSystemRoots, ensureEntityFolder } from '@/lib/services/document-folders.service'; import { makePort } from '../helpers/factories'; let TEST_USER_ID = ''; @@ -61,3 +63,78 @@ describe('document-folders service · ensureSystemRoots', () => { expect(roots.map((r) => r.name)).toEqual(['Clients', 'Companies', 'Yachts']); }); }); + +describe('document-folders service · ensureEntityFolder', () => { + let portId: string; + let clientId: string; + let rootId: string; + + beforeEach(async () => { + const port = await makePort(); + portId = port.id; + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + const roots = await ensureSystemRoots(portId, TEST_USER_ID); + rootId = roots.find((r) => r.name === 'Clients')!.id; + + const [client] = await db + .insert(clients) + .values({ + portId, + fullName: `Smith, John ${crypto.randomUUID().slice(0, 8)}`, + }) + .returning(); + clientId = client!.id; + }); + + it('creates a subfolder under the matching system root with system_managed=true', async () => { + const folder = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID); + expect(folder.systemManaged).toBe(true); + expect(folder.entityType).toBe('client'); + expect(folder.entityId).toBe(clientId); + expect(folder.parentId).toBe(rootId); + // name is the client's fullName verbatim + const [row] = await db.select().from(clients).where(eq(clients.id, clientId)); + expect(folder.name).toBe(row!.fullName); + }); + + it('is idempotent — returns the same row on second call', async () => { + const a = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID); + const b = await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID); + expect(a.id).toBe(b.id); + const all = await db + .select() + .from(documentFolders) + .where( + and(eq(documentFolders.entityType, 'client'), eq(documentFolders.entityId, clientId)), + ); + expect(all).toHaveLength(1); + }); + + it('appends a numeric suffix on name collision with an existing folder', async () => { + // Insert a second client with the exact same fullName as the first. + const [firstClient] = await db + .select() + .from(clients) + .where(eq(clients.id, clientId)); + const sharedName = firstClient!.fullName; + + const [collidingClient] = await db + .insert(clients) + .values({ + portId, + fullName: sharedName, + }) + .returning(); + + await ensureEntityFolder(portId, 'client', clientId, TEST_USER_ID); + const second = await ensureEntityFolder(portId, 'client', collidingClient!.id, TEST_USER_ID); + expect(second.name).toBe(`${sharedName} (2)`); + }); + + it('rejects unknown entity types', async () => { + await expect( + // @ts-expect-error -- runtime check + ensureEntityFolder(portId, 'boat', clientId, TEST_USER_ID), + ).rejects.toThrow(/entity type/i); + }); +});