From 1082b805429174e00081ebe90dc212d6d42d3745 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 19:55:39 +0200 Subject: [PATCH] feat(documents): folder CRUD API routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/document-folders → full tree (documents.view). POST /api/v1/document-folders → create (documents.manage_folders). PATCH /api/v1/document-folders/[id] → rename OR move (union schema — refuses both in one body so audit logs stay one-op-per-call). DELETE /api/v1/document-folders/[id] → soft-rescue delete; returns 204. PATCH passes ctx.userId through to the service-level audit-log emitters (renameFolder + moveFolder gained userId in Task 4 fixes). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/v1/document-folders/[id]/route.ts | 55 +++++++++++++++++++ src/app/api/v1/document-folders/route.ts | 47 ++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 src/app/api/v1/document-folders/[id]/route.ts create mode 100644 src/app/api/v1/document-folders/route.ts diff --git a/src/app/api/v1/document-folders/[id]/route.ts b/src/app/api/v1/document-folders/[id]/route.ts new file mode 100644 index 00000000..3b788822 --- /dev/null +++ b/src/app/api/v1/document-folders/[id]/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse, NotFoundError } from '@/lib/errors'; +import { + renameFolderSchema, + moveFolderSchema, +} from '@/lib/validators/document-folders'; +import { + renameFolder, + moveFolder, + deleteFolderSoftRescue, +} from '@/lib/services/document-folders.service'; + +/** + * PATCH supports either { name } (rename) or { parentId } (move). + * Refuses both in the same body — keeps the audit log clean + * (one operation per call) and prevents the rep from accidentally + * doing two unrelated changes in one click. + */ +const patchBodySchema = z.union([renameFolderSchema, moveFolderSchema]); + +export const PATCH = withAuth( + withPermission('documents', 'manage_folders', async (req, ctx, params) => { + try { + const folderId = params.id; + if (!folderId) throw new NotFoundError('Folder'); + const body = await parseBody(req, patchBodySchema); + let updated; + if ('name' in body) { + updated = await renameFolder(ctx.portId, folderId, body.name, ctx.userId); + } else { + updated = await moveFolder(ctx.portId, folderId, body.parentId, ctx.userId); + } + return NextResponse.json({ data: updated }); + } catch (error) { + return errorResponse(error); + } + }), +); + +export const DELETE = withAuth( + withPermission('documents', 'manage_folders', async (_req, ctx, params) => { + try { + const folderId = params.id; + if (!folderId) throw new NotFoundError('Folder'); + await deleteFolderSoftRescue(ctx.portId, folderId, ctx.userId); + return new NextResponse(null, { status: 204 }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/app/api/v1/document-folders/route.ts b/src/app/api/v1/document-folders/route.ts new file mode 100644 index 00000000..c0d48c83 --- /dev/null +++ b/src/app/api/v1/document-folders/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { errorResponse } from '@/lib/errors'; +import { createFolderSchema } from '@/lib/validators/document-folders'; +import { listTree, createFolder } from '@/lib/services/document-folders.service'; + +/** + * GET /api/v1/document-folders + * + * Returns the entire folder tree for the caller's port. Roots come + * back at the top level with `children` nested. Cached on the client + * via TanStack — folders change rarely; the manager mutations + * invalidate the query. + * + * Permission: documents.view (read-only; everyone in the port can + * browse the tree even if they can't manage it). + */ +export const GET = withAuth( + withPermission('documents', 'view', async (_req, ctx) => { + try { + const tree = await listTree(ctx.portId); + return NextResponse.json({ data: tree }); + } catch (error) { + return errorResponse(error); + } + }), +); + +/** + * POST /api/v1/document-folders + * Body: { name, parentId } + * + * Permission: documents.manage_folders. + */ +export const POST = withAuth( + withPermission('documents', 'manage_folders', async (req, ctx) => { + try { + const body = await parseBody(req, createFolderSchema); + const folder = await createFolder(ctx.portId, ctx.userId, body); + return NextResponse.json({ data: folder }, { status: 201 }); + } catch (error) { + return errorResponse(error); + } + }), +);