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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user