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) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 20:03:25 +02:00
parent e9d5df647d
commit a0ffa1baae
5 changed files with 236 additions and 1 deletions

View File

@@ -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);
}
}),
);

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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(),