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:
68
src/app/api/v1/documents/[id]/folder/route.ts
Normal file
68
src/app/api/v1/documents/[id]/folder/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -275,3 +275,25 @@ export async function deleteFolderSoftRescue(
|
|||||||
metadata: { rescuedTo: newParent },
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { db } from '@/lib/db';
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
voidDocument as documensoVoid,
|
voidDocument as documensoVoid,
|
||||||
} from '@/lib/services/documenso-client';
|
} from '@/lib/services/documenso-client';
|
||||||
import { getPortEoiSigners } from '@/lib/services/documenso-payload';
|
import { getPortEoiSigners } from '@/lib/services/documenso-payload';
|
||||||
|
import { listTree, collectDescendantIds } from '@/lib/services/document-folders.service';
|
||||||
import type {
|
import type {
|
||||||
CreateDocumentInput,
|
CreateDocumentInput,
|
||||||
UpdateDocumentInput,
|
UpdateDocumentInput,
|
||||||
@@ -159,6 +160,17 @@ export async function listDocuments(
|
|||||||
if (clientId) filters.push(eq(documents.clientId, clientId));
|
if (clientId) filters.push(eq(documents.clientId, clientId));
|
||||||
if (documentType) filters.push(eq(documents.documentType, documentType));
|
if (documentType) filters.push(eq(documents.documentType, documentType));
|
||||||
if (status) filters.push(eq(documents.status, status));
|
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 (sentSince) filters.push(gte(documents.createdAt, new Date(sentSince)));
|
||||||
if (sentUntil) filters.push(lte(documents.createdAt, new Date(sentUntil)));
|
if (sentUntil) filters.push(lte(documents.createdAt, new Date(sentUntil)));
|
||||||
if (signatureOnly === true) {
|
if (signatureOnly === true) {
|
||||||
@@ -443,6 +455,7 @@ export async function createDocument(portId: string, data: CreateDocumentInput,
|
|||||||
portId,
|
portId,
|
||||||
interestId: data.interestId ?? null,
|
interestId: data.interestId ?? null,
|
||||||
clientId: data.clientId ?? null,
|
clientId: data.clientId ?? null,
|
||||||
|
folderId: data.folderId ?? null,
|
||||||
documentType: data.documentType,
|
documentType: data.documentType,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
notes: data.notes ?? null,
|
notes: data.notes ?? null,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { DOCUMENT_TYPES, DOCUMENT_STATUSES } from '@/lib/constants';
|
|||||||
export const createDocumentSchema = z.object({
|
export const createDocumentSchema = z.object({
|
||||||
interestId: z.string().optional(),
|
interestId: z.string().optional(),
|
||||||
clientId: z.string().optional(),
|
clientId: z.string().optional(),
|
||||||
|
folderId: z.string().nullable().optional(),
|
||||||
documentType: z.enum(DOCUMENT_TYPES),
|
documentType: z.enum(DOCUMENT_TYPES),
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
@@ -83,6 +84,8 @@ export const listDocumentsSchema = baseListQuerySchema.extend({
|
|||||||
interestId: z.string().optional(),
|
interestId: z.string().optional(),
|
||||||
clientId: z.string().optional(),
|
clientId: z.string().optional(),
|
||||||
documentType: z.string().optional(),
|
documentType: z.string().optional(),
|
||||||
|
folderId: z.string().nullable().optional(),
|
||||||
|
includeDescendants: z.coerce.boolean().optional(),
|
||||||
status: z.string().optional(),
|
status: z.string().optional(),
|
||||||
/** Hub tab filter - applies tab-specific status / signer-membership constraints. */
|
/** Hub tab filter - applies tab-specific status / signer-membership constraints. */
|
||||||
tab: z.enum(documentsHubTabs).optional(),
|
tab: z.enum(documentsHubTabs).optional(),
|
||||||
|
|||||||
129
tests/integration/documents-list-folder-filter.test.ts
Normal file
129
tests/integration/documents-list-folder-filter.test.ts
Normal file
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user