From 4b31f01a04709c34207097967089a584ac9b0395 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 19:30:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(documents):=20folder=20service=20=C2=B7=20?= =?UTF-8?q?listTree=20+=20createFolder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In-memory tree build (single SELECT + JS nesting); the folder tree is small enough that a recursive CTE buys nothing. Sibling-name conflict maps the Postgres unique-index 23505 to a typed ConflictError so the UI can render a clean toast. Cross-port parentId rejected at the service boundary. Also adds document_folders to the global teardown CTE so test ports can be cleaned up without FK violations. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/services/document-folders.service.ts | 112 ++++++++++++++++++ tests/global-setup.ts | 1 + .../integration/document-folders-crud.test.ts | 96 +++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 src/lib/services/document-folders.service.ts create mode 100644 tests/integration/document-folders-crud.test.ts diff --git a/src/lib/services/document-folders.service.ts b/src/lib/services/document-folders.service.ts new file mode 100644 index 00000000..56ac7304 --- /dev/null +++ b/src/lib/services/document-folders.service.ts @@ -0,0 +1,112 @@ +import { and, asc, eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { documentFolders, type DocumentFolder } from '@/lib/db/schema/documents'; +import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; + +/** + * Returns true if the error is a Postgres unique-violation (SQLSTATE 23505) + * raised specifically on the sibling-name unique index. Narrowing to this + * constraint prevents swallowing unrelated unique violations. + */ +function isSiblingNameConflict(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_sibling_name'; +} + +export interface FolderNode extends DocumentFolder { + children: FolderNode[]; +} + +/** + * Returns the entire folder tree for a port, nested under their + * parents. Roots come back at the top level. Order is alphabetical + * (case-insensitive) within each parent — matches the sibling-uniqueness + * index ordering and gives reps a stable browsing experience. + * + * Uses a single SELECT + JS nesting rather than a recursive CTE; the + * folder tree is small (UI gates depth; thousands of folders would be + * a misuse) so the in-memory build is cheaper than a CTE round-trip. + */ +export async function listTree(portId: string): Promise { + const rows = await db + .select() + .from(documentFolders) + .where(eq(documentFolders.portId, portId)) + .orderBy(asc(documentFolders.name)); + + const byId = new Map(); + for (const row of rows) byId.set(row.id, { ...row, children: [] }); + + const roots: FolderNode[] = []; + for (const node of byId.values()) { + if (node.parentId === null) { + roots.push(node); + } else { + const parent = byId.get(node.parentId); + if (parent) parent.children.push(node); + // Orphan rows (parentId pointing nowhere) are dropped from the + // tree but stay in the DB. Surface via a separate maintenance + // query if needed; never silently re-parent. + } + } + return roots; +} + +interface CreateFolderInput { + name: string; + parentId: string | null; +} + +/** + * Creates a folder under the given parent. Throws ConflictError when + * a sibling with the same case-insensitive name already exists (the + * DB unique index is the authoritative guard; this maps the Postgres + * 23505 to the typed error). Throws ValidationError when `parentId` + * doesn't belong to this port (cross-port leakage guard). + */ +export async function createFolder( + portId: string, + userId: string, + data: CreateFolderInput, +): Promise { + const trimmed = data.name.trim(); + if (!trimmed) throw new ValidationError('Folder name cannot be empty'); + if (trimmed.length > 200) throw new ValidationError('Folder name cannot exceed 200 chars'); + + if (data.parentId !== null) { + const parent = await db.query.documentFolders.findFirst({ + where: and(eq(documentFolders.id, data.parentId), eq(documentFolders.portId, portId)), + }); + if (!parent) throw new ValidationError('Parent folder not found in this port'); + } + + try { + const [row] = await db + .insert(documentFolders) + .values({ + portId, + parentId: data.parentId, + name: trimmed, + createdBy: userId, + }) + .returning(); + if (!row) throw new NotFoundError('Folder'); + return row; + } catch (err) { + if (isSiblingNameConflict(err)) { + throw new ConflictError(`A folder named "${trimmed}" already exists here`); + } + throw err; + } +} diff --git a/tests/global-setup.ts b/tests/global-setup.ts index a80d97f5..270b27bc 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -39,6 +39,7 @@ export async function teardown() { , del_comp AS (DELETE FROM companies WHERE port_id IN (SELECT id FROM doomed) RETURNING 1) , del_cfd AS (DELETE FROM custom_field_definitions WHERE port_id IN (SELECT id FROM doomed) RETURNING 1) , del_docs AS (DELETE FROM documents WHERE port_id IN (SELECT id FROM doomed) RETURNING 1) + , del_dfold AS (DELETE FROM document_folders WHERE port_id IN (SELECT id FROM doomed) RETURNING 1) , del_dt AS (DELETE FROM document_templates WHERE port_id IN (SELECT id FROM doomed) RETURNING 1) , del_emt AS (DELETE FROM email_threads WHERE port_id IN (SELECT id FROM doomed) RETURNING 1) , del_ema AS (DELETE FROM email_accounts WHERE port_id IN (SELECT id FROM doomed) RETURNING 1) diff --git a/tests/integration/document-folders-crud.test.ts b/tests/integration/document-folders-crud.test.ts new file mode 100644 index 00000000..1ee73541 --- /dev/null +++ b/tests/integration/document-folders-crud.test.ts @@ -0,0 +1,96 @@ +/** + * Task 3 — document-folders service: listTree + createFolder (TDD). + * + * Uses the makePort factory (not a "setupTestPort" helper — that name + * doesn't exist in this codebase). TEST_USER_ID is resolved once via + * beforeAll from any seeded user, matching the pattern in + * alerts-tenant-isolation.test.ts and gdpr-export.test.ts. + */ + +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; + +import { db } from '@/lib/db'; +import { documentFolders } from '@/lib/db/schema/documents'; +import { user } from '@/lib/db/schema/users'; +import { listTree, createFolder } from '@/lib/services/document-folders.service'; +import { makePort } from '../helpers/factories'; + +let TEST_USER_ID = ''; + +beforeAll(async () => { + const [u] = await db.select({ id: user.id }).from(user).limit(1); + if (!u) throw new Error('No user available; run pnpm db:seed first'); + TEST_USER_ID = u.id; +}); + +describe('document-folders service · listTree', () => { + let portId: string; + + beforeEach(async () => { + const port = await makePort(); + portId = port.id; + await db.delete(documentFolders).execute(); + }); + + it('returns an empty array when no folders exist', async () => { + const tree = await listTree(portId); + expect(tree).toEqual([]); + }); + + it('returns root folders with children nested under them', async () => { + const root = await createFolder(portId, TEST_USER_ID, { name: 'Deals 2026', parentId: null }); + const child = await createFolder(portId, TEST_USER_ID, { + name: 'Q1', + parentId: root.id, + }); + const tree = await listTree(portId); + expect(tree).toHaveLength(1); + expect(tree[0]?.id).toBe(root.id); + expect(tree[0]?.children).toHaveLength(1); + expect(tree[0]?.children[0]?.id).toBe(child.id); + }); + + it('only returns folders for the requested port', async () => { + const otherPort = await makePort(); + await createFolder(otherPort.id, TEST_USER_ID, { name: 'Other Port', parentId: null }); + const tree = await listTree(portId); + expect(tree).toEqual([]); + }); +}); + +describe('document-folders service · createFolder unique-sibling guard', () => { + let portId: string; + + beforeEach(async () => { + const port = await makePort(); + portId = port.id; + await db.delete(documentFolders).execute(); + }); + + it('rejects a duplicate sibling name (case-insensitive)', async () => { + await createFolder(portId, TEST_USER_ID, { name: 'Deals 2026', parentId: null }); + await expect( + createFolder(portId, TEST_USER_ID, { name: 'deals 2026', parentId: null }), + ).rejects.toThrow(/already exists/i); + }); + + it('allows the same name under different parents', async () => { + const a = await createFolder(portId, TEST_USER_ID, { name: 'A', parentId: null }); + const b = await createFolder(portId, TEST_USER_ID, { name: 'B', parentId: null }); + await createFolder(portId, TEST_USER_ID, { name: 'Drafts', parentId: a.id }); + await expect( + createFolder(portId, TEST_USER_ID, { name: 'Drafts', parentId: b.id }), + ).resolves.toBeDefined(); + }); + + it('rejects a parentId from another port', async () => { + const otherPort = await makePort(); + const otherFolder = await createFolder(otherPort.id, TEST_USER_ID, { + name: 'Other', + parentId: null, + }); + await expect( + createFolder(portId, TEST_USER_ID, { name: 'Should fail', parentId: otherFolder.id }), + ).rejects.toThrow(/not found in this port/i); + }); +});