import { Inject, Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, Res } from "@nestjs/common"; import type { Request, Response } from "express"; import { PageService } from "./page.service.js"; import { ReviewService } from "./review.service.js"; import { ZodValidate } from "../../common/pipes/zod-validation.pipe.js"; import { requireActor } from "../../common/types.js"; import { ReviewActionDto, type ReviewActionInput } from "./review.dto.js"; import { ApiError } from "../../common/errors.js"; import { ApplyTemplateDto, type ApplyTemplateInput, BulkPagesDto, type BulkPagesInput, CreatePageDto, type CreatePageInput, ConvertSelectionDto, type ConvertSelectionInput, CompareVersionsQuery, type CompareVersionsInput, CreateTemplateDto, type CreateTemplateInput, DuplicatePageDto, type DuplicatePageInput, ListPagesQuery, type ListPagesInput, MovePageDto, type MovePageInput, UpdatePageDto, type UpdatePageInput } from "./dto.js"; @Controller() export class PagesController { constructor( @Inject(PageService) private readonly pages: PageService, @Inject(ReviewService) private readonly reviews: ReviewService ) {} // ---------- workspace-scoped ---------- @Get("workspaces/:workspaceId/pages") list( @Param("workspaceId") workspaceId: string, @Query(ZodValidate(ListPagesQuery)) query: ListPagesInput, @Req() req: Request ) { return this.pages.list(workspaceId, requireActor(req), query); } @Post("workspaces/:workspaceId/pages") create( @Param("workspaceId") workspaceId: string, @Body(ZodValidate(CreatePageDto)) body: CreatePageInput, @Req() req: Request ) { // Zod allows `parentId: null` (wire); the service takes `parentId?: string` // (null ≡ top level — coalesce, same as `?? null` on the service side). return this.pages.create(workspaceId, requireActor(req), { ...body, parentId: body.parentId ?? undefined }, req); } // ---------- page-scoped ---------- @Get("pages/:id") get(@Param("id") id: string, @Req() req: Request) { return this.pages.get(id, requireActor(req)); } // ---------- T-180: content export (Phase 8) ---------- /** * `GET /pages/:id/export/markdown` — download the page (and, with * `?includeChildren=1`, its sub-pages) as Markdown. VIEW-gated server-side * (same level as `GET /pages/:id`); the export is AUDITED (`page.exported`). * The service renders the markdown; the controller only sets the download * headers and sends the body (mirrors the database CSV export route). */ @Get("pages/:id/export/markdown") async exportMarkdown( @Param("id") id: string, @Query("includeChildren") includeChildren: string | undefined, @Req() req: Request, @Res() res: Response ) { const { markdown, filename } = await this.pages.exportMarkdown( id, requireActor(req), { includeChildren: includeChildren === "1" || includeChildren === "true" }, req ); res.setHeader("Content-Type", "text/markdown; charset=utf-8"); res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); res.send(markdown); } // ---------- T-162/T-163: review workflow (banner state + actions) ---------- @Get("pages/:id/review") reviewState(@Param("id") id: string, @Req() req: Request) { return this.reviews.reviewState(id, requireActor(req)); } @Post("pages/:id/review") reviewAct( @Param("id") id: string, @Body(ZodValidate(ReviewActionDto)) body: ReviewActionInput, @Req() req: Request ) { return this.reviews.act(id, requireActor(req), body, req); } @Patch("pages/:id") update( @Param("id") id: string, @Body(ZodValidate(UpdatePageDto)) body: UpdatePageInput, @Req() req: Request ) { return this.pages.update(id, requireActor(req), body, req); } // ---------- T-167: whiteboard canvas (page kind) ---------- // // The canvas document is validated by the zero-dep `@factory/whiteboard` // schema (a plain object — no Zod DTO: `validateCanvas` already returns a // field-level error list the service turns into a 400 envelope). The save // body may exceed the global 256 KB JSON cap — it is buffered by the // route-scoped `whiteboard-body.ts` parser (2 MB, 413 past it). @Get("pages/:id/whiteboard") getCanvas(@Param("id") id: string, @Req() req: Request) { return this.pages.getCanvas(id, requireActor(req)); } @Put("pages/:id/whiteboard") saveCanvas( @Param("id") id: string, @Body() canvas: unknown, @Req() req: Request ) { return this.pages.saveCanvas(id, requireActor(req), canvas, req); } // T-171: convert a whiteboard selection into a real page (or task page). @Post("pages/:id/whiteboard/convert") convertSelection( @Param("id") id: string, @Body(ZodValidate(ConvertSelectionDto)) body: ConvertSelectionInput, @Req() req: Request ) { return this.pages.convertSelection(id, requireActor(req), body, req); } @Delete("pages/:id") trash(@Param("id") id: string, @Req() req: Request) { return this.pages.trash(id, requireActor(req), req); } @Delete("pages/:id/permanent") deletePermanent(@Param("id") id: string, @Req() req: Request) { return this.pages.deletePermanent(id, requireActor(req), req).then(() => ({ deleted: true })); } @Post("pages/:id/restore") restore(@Param("id") id: string, @Req() req: Request) { return this.pages.restore(id, requireActor(req), req); } // ---------- T-138: duplicate ---------- @Post("pages/:id/duplicate") duplicate( @Param("id") id: string, @Body(ZodValidate(DuplicatePageDto)) body: DuplicatePageInput, @Req() req: Request ) { return this.pages.duplicate(id, requireActor(req), { parentId: body.parentId ?? undefined, title: body.title }, req); } // ---------- T-139: bulk ---------- @Post("pages/bulk") bulk( @Body(ZodValidate(BulkPagesDto)) body: BulkPagesInput, @Req() req: Request ) { const workspaceId = req.headers["x-factory-workspace"]; if (typeof workspaceId !== "string" || !workspaceId) { throw ApiError.validation("workspaceId is required (X-Factory-Workspace header).", [ { field: "workspaceId", issue: "required" } ]); } return this.pages.bulk(workspaceId, requireActor(req), body, req); } // ---------- T-140: backlinks ---------- @Get("pages/:id/backlinks") backlinks(@Param("id") id: string, @Req() req: Request) { return this.pages.backlinks(id, requireActor(req)); } // ---------- T-149: cross-workspace move ---------- @Post("pages/:id/move") move( @Param("id") id: string, @Body(ZodValidate(MovePageDto)) body: MovePageInput, @Req() req: Request ) { return this.pages.moveCrossWorkspace(id, requireActor(req), { toWorkspaceId: body.toWorkspaceId, toParentId: body.toParentId ?? undefined }, req); } // ---------- versions ---------- @Get("pages/:id/versions") versions(@Param("id") id: string, @Req() req: Request) { return this.pages.listVersions(id, requireActor(req)); } // T-150: declared BEFORE `:versionId` — Nest matches routes in order and // the literal segment must win. @Get("pages/:id/versions/compare") compareVersions( @Param("id") id: string, @Query(ZodValidate(CompareVersionsQuery)) query: CompareVersionsInput, @Req() req: Request ) { return this.pages.compareVersions(id, query.from, query.to, requireActor(req)); } @Post("pages/:id/versions") createVersion(@Param("id") id: string, @Body() body: { note?: string } | undefined, @Req() req: Request) { return this.pages.createVersion(id, requireActor(req), body?.note); } // T-151: read-only preview of one version's snapshot. @Get("pages/:id/versions/:versionId") getVersionPreview(@Param("id") id: string, @Param("versionId") versionId: string, @Req() req: Request) { return this.pages.getVersionPreview(id, versionId, requireActor(req)); } @Post("pages/:id/versions/:versionId/restore") restoreVersion( @Param("id") id: string, @Param("versionId") versionId: string, @Req() req: Request ) { return this.pages.restoreVersion(id, versionId, requireActor(req), req); } // ---------- T-157: watchlist ---------- @Get("pages/:id/watch") isWatching(@Param("id") id: string, @Req() req: Request) { return this.pages.isWatching(id, requireActor(req)); } @Post("pages/:id/watch") watch(@Param("id") id: string, @Req() req: Request) { return this.pages.watch(id, requireActor(req)); } @Delete("pages/:id/watch") unwatch(@Param("id") id: string, @Req() req: Request) { return this.pages.unwatch(id, requireActor(req)); } // ---------- favorites ---------- @Get("pages/:id/favorite") favorite(@Param("id") id: string, @Req() req: Request) { return this.pages.isFavorite(id, requireActor(req)); } @Post("pages/:id/favorite") addFavorite(@Param("id") id: string, @Req() req: Request) { return this.pages.favorite(id, requireActor(req)); } @Delete("pages/:id/favorite") removeFavorite(@Param("id") id: string, @Req() req: Request) { return this.pages.unfavorite(id, requireActor(req)); } // ---------- templates ---------- @Post("pages/:id/templates") createTemplate( @Param("id") id: string, @Body(ZodValidate(CreateTemplateDto)) body: CreateTemplateInput, @Req() req: Request ) { return this.pages.createTemplate(id, requireActor(req), body, req); } @Get("workspaces/:workspaceId/templates") templates( @Param("workspaceId") workspaceId: string, @Query("type") type: string | undefined, @Query("category") category: string | undefined, @Query("search") search: string | undefined, @Req() req: Request ) { // T-141: additive directory filters (no params = previous behavior). return this.pages.listTemplates(workspaceId, requireActor(req), type, { category, search }); } @Post("templates/:id/apply") applyTemplate( @Param("id") id: string, @Body(ZodValidate(ApplyTemplateDto)) body: ApplyTemplateInput, @Req() req: Request ) { const workspaceId = body.workspaceId ?? req.headers["x-factory-workspace"]; if (typeof workspaceId !== "string" || !workspaceId) { throw ApiError.validation("workspaceId is required (body or X-Factory-Workspace header).", [ { field: "workspaceId", issue: "required" } ]); } // Zod allows `parentId: null` (wire); the service takes `parentId?: string` // (null ≡ top level — coalesce, same as `?? null` on the service side). return this.pages.applyTemplate(workspaceId, requireActor(req), id, { parentId: body.parentId ?? undefined, title: body.title, values: body.values }, req); } /** T-141: read-only preview of the template's CURRENT snapshot. ID-scoped * (no workspace header): the directory is a cross-workspace union whose * rows carry no workspaceId — the service authorizes against the * template's OWN workspace membership. */ @Get("templates/:id/preview") previewTemplate(@Param("id") id: string, @Req() req: Request) { return this.pages.previewTemplate(id, requireActor(req)); } // ---------- T-145: template version history ---------- @Post("templates/:id/versions") createTemplateVersion( @Param("id") id: string, @Body() body: { note?: string } | undefined, @Req() req: Request ) { const workspaceId = req.headers["x-factory-workspace"]; if (typeof workspaceId !== "string" || !workspaceId) { throw ApiError.validation("workspaceId is required (X-Factory-Workspace header).", [{ field: "workspaceId", issue: "required" }]); } return this.pages.createTemplateVersion(id, workspaceId, requireActor(req), body?.note, req); } @Get("templates/:id/versions") listTemplateVersions(@Param("id") id: string, @Req() req: Request) { const workspaceId = req.headers["x-factory-workspace"]; if (typeof workspaceId !== "string" || !workspaceId) { throw ApiError.validation("workspaceId is required (X-Factory-Workspace header).", [{ field: "workspaceId", issue: "required" }]); } return this.pages.listTemplateVersions(id, workspaceId, requireActor(req)); } @Post("templates/:id/versions/:versionId/restore") restoreTemplateVersion( @Param("id") id: string, @Param("versionId") versionId: string, @Req() req: Request ) { const workspaceId = req.headers["x-factory-workspace"]; if (typeof workspaceId !== "string" || !workspaceId) { throw ApiError.validation("workspaceId is required (X-Factory-Workspace header).", [{ field: "workspaceId", issue: "required" }]); } return this.pages.restoreTemplateVersion(id, versionId, workspaceId, requireActor(req), req); } }