feat(documents): folder service · listTree + createFolder
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) <noreply@anthropic.com>
This commit is contained in:
112
src/lib/services/document-folders.service.ts
Normal file
112
src/lib/services/document-folders.service.ts
Normal file
@@ -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<FolderNode[]> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(documentFolders)
|
||||||
|
.where(eq(documentFolders.portId, portId))
|
||||||
|
.orderBy(asc(documentFolders.name));
|
||||||
|
|
||||||
|
const byId = new Map<string, FolderNode>();
|
||||||
|
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<DocumentFolder> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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_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_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_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_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_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)
|
, del_ema AS (DELETE FROM email_accounts WHERE port_id IN (SELECT id FROM doomed) RETURNING 1)
|
||||||
|
|||||||
96
tests/integration/document-folders-crud.test.ts
Normal file
96
tests/integration/document-folders-crud.test.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user