From a0ffa1baae2f19d678cb67bc7a6aefa2d3806549 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 20:03:25 +0200 Subject: [PATCH] feat(documents): folder filter on list + per-doc move endpoint listDocuments accepts folderId (string | null | undefined) and includeDescendants. folderId=null returns only docs at root; includeDescendants=true expands the subtree via collectDescendantIds (in-memory walk over the cached tree -- folder trees are small). PATCH /api/v1/documents/[id]/folder moves a single document under documents.manage_folders, with audit-log metadata { type: 'folder_move' }. Bumping updatedAt is correct for per-doc moves because reps deliberately acted on that document -- different semantics from the bulk soft-rescue in Task 4. createDocument accepts an optional folderId for the upcoming UI's "create in current folder" affordance. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/v1/documents/[id]/folder/route.ts | 68 +++++++++ src/lib/services/document-folders.service.ts | 22 +++ src/lib/services/documents.service.ts | 15 +- src/lib/validators/documents.ts | 3 + .../documents-list-folder-filter.test.ts | 129 ++++++++++++++++++ 5 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 src/app/api/v1/documents/[id]/folder/route.ts create mode 100644 tests/integration/documents-list-folder-filter.test.ts diff --git a/src/app/api/v1/documents/[id]/folder/route.ts b/src/app/api/v1/documents/[id]/folder/route.ts new file mode 100644 index 00000000..343e4ff0 --- /dev/null +++ b/src/app/api/v1/documents/[id]/folder/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from 'next/server'; +import { and, eq } from 'drizzle-orm'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { db } from '@/lib/db'; +import { documents, documentFolders } from '@/lib/db/schema/documents'; +import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; +import { moveDocumentToFolderSchema } from '@/lib/validators/document-folders'; +import { createAuditLog } from '@/lib/audit'; + +/** + * Per-document move endpoint. Moving a single document is a deliberate + * user action so we DO bump `updated_at` here — different semantics from + * the bulk soft-rescue in `deleteFolderSoftRescue` where the timestamp + * stays put because reps did not act on the individual documents. + * + * Validates the destination folder belongs to the same port (cross-port + * leakage guard) and emits a folder-tagged audit log so the documents + * inspector can filter on `metadata.type='folder_move'`. + */ +export const PATCH = withAuth( + withPermission('documents', 'manage_folders', async (req, ctx, params) => { + try { + const docId = params.id; + if (!docId) throw new NotFoundError('Document'); + const body = await parseBody(req, moveDocumentToFolderSchema); + + const existing = await db.query.documents.findFirst({ + where: and(eq(documents.id, docId), eq(documents.portId, ctx.portId)), + }); + if (!existing) throw new NotFoundError('Document'); + + if (body.folderId !== null) { + const folder = await db.query.documentFolders.findFirst({ + where: and( + eq(documentFolders.id, body.folderId), + eq(documentFolders.portId, ctx.portId), + ), + }); + if (!folder) throw new ValidationError('Invalid folder'); + } + + const [updated] = await db + .update(documents) + .set({ folderId: body.folderId, updatedAt: new Date() }) + .where(eq(documents.id, docId)) + .returning(); + + void createAuditLog({ + userId: ctx.userId, + portId: ctx.portId, + action: 'update', + entityType: 'document', + entityId: docId, + oldValue: { folderId: existing.folderId }, + newValue: { folderId: body.folderId }, + metadata: { type: 'folder_move' }, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + + return NextResponse.json({ data: updated }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/lib/services/document-folders.service.ts b/src/lib/services/document-folders.service.ts index 2b132d59..f3770662 100644 --- a/src/lib/services/document-folders.service.ts +++ b/src/lib/services/document-folders.service.ts @@ -275,3 +275,25 @@ export async function deleteFolderSoftRescue( metadata: { rescuedTo: newParent }, }); } + +/** + * Walk a folder tree and return the IDs of every descendant of `rootId` + * (NOT including rootId itself). Used by `listDocuments` when + * `includeDescendants=true` so a port-wide tree fetch is reused + * instead of a recursive CTE. + */ +export function collectDescendantIds(tree: FolderNode[], rootId: string): string[] { + const out: string[] = []; + function visit(nodes: FolderNode[], inside: boolean) { + for (const n of nodes) { + if (inside || n.id === rootId) { + if (n.id !== rootId) out.push(n.id); + visit(n.children, true); + } else { + visit(n.children, false); + } + } + } + visit(tree, false); + return out; +} diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts index 4af2dc85..45b2a786 100644 --- a/src/lib/services/documents.service.ts +++ b/src/lib/services/documents.service.ts @@ -1,4 +1,4 @@ -import { and, count, eq, gte, inArray, lt, lte, ne, sql, exists } from 'drizzle-orm'; +import { and, count, eq, gte, inArray, isNull, lt, lte, ne, sql, exists } from 'drizzle-orm'; import { db } from '@/lib/db'; import { @@ -34,6 +34,7 @@ import { voidDocument as documensoVoid, } from '@/lib/services/documenso-client'; import { getPortEoiSigners } from '@/lib/services/documenso-payload'; +import { listTree, collectDescendantIds } from '@/lib/services/document-folders.service'; import type { CreateDocumentInput, UpdateDocumentInput, @@ -159,6 +160,17 @@ export async function listDocuments( if (clientId) filters.push(eq(documents.clientId, clientId)); if (documentType) filters.push(eq(documents.documentType, documentType)); if (status) filters.push(eq(documents.status, status)); + if (query.folderId !== undefined) { + if (query.folderId === null) { + filters.push(isNull(documents.folderId)); + } else if (query.includeDescendants) { + const tree = await listTree(portId); + const descendantIds = collectDescendantIds(tree, query.folderId); + filters.push(inArray(documents.folderId, [query.folderId, ...descendantIds])); + } else { + filters.push(eq(documents.folderId, query.folderId)); + } + } if (sentSince) filters.push(gte(documents.createdAt, new Date(sentSince))); if (sentUntil) filters.push(lte(documents.createdAt, new Date(sentUntil))); if (signatureOnly === true) { @@ -443,6 +455,7 @@ export async function createDocument(portId: string, data: CreateDocumentInput, portId, interestId: data.interestId ?? null, clientId: data.clientId ?? null, + folderId: data.folderId ?? null, documentType: data.documentType, title: data.title, notes: data.notes ?? null, diff --git a/src/lib/validators/documents.ts b/src/lib/validators/documents.ts index 39af8766..2c18741c 100644 --- a/src/lib/validators/documents.ts +++ b/src/lib/validators/documents.ts @@ -6,6 +6,7 @@ import { DOCUMENT_TYPES, DOCUMENT_STATUSES } from '@/lib/constants'; export const createDocumentSchema = z.object({ interestId: z.string().optional(), clientId: z.string().optional(), + folderId: z.string().nullable().optional(), documentType: z.enum(DOCUMENT_TYPES), title: z.string().min(1).max(200), notes: z.string().optional(), @@ -83,6 +84,8 @@ export const listDocumentsSchema = baseListQuerySchema.extend({ interestId: z.string().optional(), clientId: z.string().optional(), documentType: z.string().optional(), + folderId: z.string().nullable().optional(), + includeDescendants: z.coerce.boolean().optional(), status: z.string().optional(), /** Hub tab filter - applies tab-specific status / signer-membership constraints. */ tab: z.enum(documentsHubTabs).optional(), diff --git a/tests/integration/documents-list-folder-filter.test.ts b/tests/integration/documents-list-folder-filter.test.ts new file mode 100644 index 00000000..f764631b --- /dev/null +++ b/tests/integration/documents-list-folder-filter.test.ts @@ -0,0 +1,129 @@ +/** + * Task 7 — listDocuments folder filtering (TDD). + * + * Exercises the three folderId modes: null (root only), a string (direct + * children), and a string with includeDescendants=true (subtree). Mirrors the + * pattern in document-folders-crud.test.ts: makePort returns a Port row, and + * TEST_USER_ID is resolved once via beforeAll from any seeded user. + */ + +import { describe, it, expect, beforeEach, beforeAll } from 'vitest'; +import { eq } from 'drizzle-orm'; + +import { db } from '@/lib/db'; +import { documents, documentFolders } from '@/lib/db/schema/documents'; +import { user } from '@/lib/db/schema/users'; +import { createFolder } from '@/lib/services/document-folders.service'; +import { listDocuments } from '@/lib/services/documents.service'; +import { makePort } from '../helpers/factories'; + +describe('documents.listDocuments folder filtering', () => { + let portId: string; + let testUserId: string; + + 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'); + testUserId = u.id; + }); + + beforeEach(async () => { + const port = await makePort(); + portId = port.id; + await db.delete(documents).where(eq(documents.portId, portId)); + await db.delete(documentFolders).where(eq(documentFolders.portId, portId)); + }); + + it('filters by folderId (direct children only by default)', async () => { + const root = await createFolder(portId, testUserId, { name: 'Root', parentId: null }); + const sub = await createFolder(portId, testUserId, { name: 'Sub', parentId: root.id }); + await db.insert(documents).values([ + { + portId, + documentType: 'other', + title: 'In Root', + createdBy: testUserId, + folderId: root.id, + }, + { portId, documentType: 'other', title: 'In Sub', createdBy: testUserId, folderId: sub.id }, + { + portId, + documentType: 'other', + title: 'At Root (no folder)', + createdBy: testUserId, + folderId: null, + }, + ]); + + const res = await listDocuments(portId, { + page: 1, + limit: 50, + order: 'desc', + includeArchived: false, + includeDescendants: false, + folderId: root.id, + }); + const titles = (res.data as Array<{ title: string }>).map((d) => d.title); + expect(titles).toContain('In Root'); + expect(titles).not.toContain('In Sub'); + expect(titles).not.toContain('At Root (no folder)'); + }); + + it('includeDescendants=true pulls in children of children', async () => { + const root = await createFolder(portId, testUserId, { name: 'Root', parentId: null }); + const sub = await createFolder(portId, testUserId, { name: 'Sub', parentId: root.id }); + await db.insert(documents).values([ + { + portId, + documentType: 'other', + title: 'In Root', + createdBy: testUserId, + folderId: root.id, + }, + { portId, documentType: 'other', title: 'In Sub', createdBy: testUserId, folderId: sub.id }, + ]); + + const res = await listDocuments(portId, { + page: 1, + limit: 50, + order: 'desc', + includeArchived: false, + includeDescendants: true, + folderId: root.id, + }); + const titles = (res.data as Array<{ title: string }>).map((d) => d.title); + expect(titles).toContain('In Root'); + expect(titles).toContain('In Sub'); + }); + + it('folderId=null returns only docs at root', async () => { + const root = await createFolder(portId, testUserId, { name: 'Root', parentId: null }); + await db.insert(documents).values([ + { + portId, + documentType: 'other', + title: 'In Root', + createdBy: testUserId, + folderId: root.id, + }, + { + portId, + documentType: 'other', + title: 'At Root', + createdBy: testUserId, + folderId: null, + }, + ]); + + const res = await listDocuments(portId, { + page: 1, + limit: 50, + order: 'desc', + includeArchived: false, + includeDescendants: false, + folderId: null, + }); + const titles = (res.data as Array<{ title: string }>).map((d) => d.title); + expect(titles).toEqual(['At Root']); + }); +});