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