import { BadRequestException, Body, Controller, ForbiddenException, HttpCode, HttpStatus, NotFoundException, Post, UseGuards, } from '@nestjs/common'; import { PageService } from './services/page.service'; import { CreatePageDto } from './dto/create-page.dto'; import { UpdatePageDto } from './dto/update-page.dto'; import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto'; import { DeletePageDto, PageHistoryIdDto, PageIdDto, PageInfoDto, } from './dto/page.dto'; import { PageHistoryService } from './services/page-history.service'; import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { User, Workspace } from '@docmost/db/types/entity.types'; import { SidebarPageDto } from './dto/sidebar-page.dto'; import { SpaceCaslAction, SpaceCaslSubject, } from '../casl/interfaces/space-ability.type'; import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { RecentPageDto } from './dto/recent-page.dto'; import { DuplicatePageDto } from './dto/duplicate-page.dto'; import { DeletedPageDto } from './dto/deleted-page.dto'; @UseGuards(JwtAuthGuard) @Controller('pages') export class PageController { constructor( private readonly pageService: PageService, private readonly pageRepo: PageRepo, private readonly pageHistoryService: PageHistoryService, private readonly spaceAbility: SpaceAbilityFactory, ) {} @HttpCode(HttpStatus.OK) @Post('/info') async getPage(@Body() dto: PageInfoDto, @AuthUser() user: User) { const page = await this.pageRepo.findById(dto.pageId, { includeSpace: true, includeContent: true, includeCreator: true, includeLastUpdatedBy: true, includeContributors: true, }); if (!page) { throw new NotFoundException('Page not found'); } const ability = await this.spaceAbility.createForUser(user, page.spaceId); if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } return page; } @HttpCode(HttpStatus.OK) @Post('create') async create( @Body() createPageDto: CreatePageDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { const ability = await this.spaceAbility.createForUser( user, createPageDto.spaceId, ); if (ability.cannot(SpaceCaslAction.Create, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } return this.pageService.create(user.id, workspace.id, createPageDto); } @HttpCode(HttpStatus.OK) @Post('update') async update(@Body() updatePageDto: UpdatePageDto, @AuthUser() user: User) { const page = await this.pageRepo.findById(updatePageDto.pageId); if (!page) { throw new NotFoundException('Page not found'); } const ability = await this.spaceAbility.createForUser(user, page.spaceId); if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } return this.pageService.update(page, updatePageDto, user.id); } @HttpCode(HttpStatus.OK) @Post('delete') async delete( @Body() deletePageDto: DeletePageDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { const page = await this.pageRepo.findById(deletePageDto.pageId); if (!page) { throw new NotFoundException('Page not found'); } const ability = await this.spaceAbility.createForUser(user, page.spaceId); if (deletePageDto.permanentlyDelete) { // Permanent deletion requires space admin permissions if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) { throw new ForbiddenException( 'Only space admins can permanently delete pages', ); } await this.pageService.forceDelete(deletePageDto.pageId, workspace.id); } else { // Soft delete requires page manage permissions if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } await this.pageService.removePage( deletePageDto.pageId, user.id, workspace.id, ); } } @HttpCode(HttpStatus.OK) @Post('restore') async restore( @Body() pageIdDto: PageIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, ) { const page = await this.pageRepo.findById(pageIdDto.pageId); if (!page) { throw new NotFoundException('Page not found'); } const ability = await this.spaceAbility.createForUser(user, page.spaceId); if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } await this.pageRepo.restorePage(pageIdDto.pageId, workspace.id); return this.pageRepo.findById(pageIdDto.pageId, { includeHasChildren: true, }); } @HttpCode(HttpStatus.OK) @Post('recent') async getRecentPages( @Body() recentPageDto: RecentPageDto, @Body() pagination: PaginationOptions, @AuthUser() user: User, ) { if (recentPageDto.spaceId) { const ability = await this.spaceAbility.createForUser( user, recentPageDto.spaceId, ); if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } return this.pageService.getRecentSpacePages( recentPageDto.spaceId, pagination, ); } return this.pageService.getRecentPages(user.id, pagination); } @HttpCode(HttpStatus.OK) @Post('trash') async getDeletedPages( @Body() deletedPageDto: DeletedPageDto, @Body() pagination: PaginationOptions, @AuthUser() user: User, ) { if (deletedPageDto.spaceId) { const ability = await this.spaceAbility.createForUser( user, deletedPageDto.spaceId, ); if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } return this.pageService.getDeletedSpacePages( deletedPageDto.spaceId, pagination, ); } } // TODO: scope to workspaces @HttpCode(HttpStatus.OK) @Post('/history') async getPageHistory( @Body() dto: PageIdDto, @Body() pagination: PaginationOptions, @AuthUser() user: User, ) { const page = await this.pageRepo.findById(dto.pageId); if (!page) { throw new NotFoundException('Page not found'); } const ability = await this.spaceAbility.createForUser(user, page.spaceId); if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } return this.pageHistoryService.findHistoryByPageId(page.id, pagination); } @HttpCode(HttpStatus.OK) @Post('/history/info') async getPageHistoryInfo( @Body() dto: PageHistoryIdDto, @AuthUser() user: User, ) { const history = await this.pageHistoryService.findById(dto.historyId); if (!history) { throw new NotFoundException('Page history not found'); } const ability = await this.spaceAbility.createForUser( user, history.spaceId, ); if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } return history; } @HttpCode(HttpStatus.OK) @Post('/sidebar-pages') async getSidebarPages( @Body() dto: SidebarPageDto, @Body() pagination: PaginationOptions, @AuthUser() user: User, ) { if (!dto.spaceId && !dto.pageId) { throw new BadRequestException( 'Either spaceId or pageId must be provided', ); } let spaceId = dto.spaceId; if (dto.pageId) { const page = await this.pageRepo.findById(dto.pageId); if (!page) { throw new ForbiddenException(); } spaceId = page.spaceId; } const ability = await this.spaceAbility.createForUser(user, spaceId); if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } return this.pageService.getSidebarPages(spaceId, pagination, dto.pageId); } @HttpCode(HttpStatus.OK) @Post('move-to-space') async movePageToSpace( @Body() dto: MovePageToSpaceDto, @AuthUser() user: User, ) { const movedPage = await this.pageRepo.findById(dto.pageId); if (!movedPage) { throw new NotFoundException('Page to move not found'); } if (movedPage.spaceId === dto.spaceId) { throw new BadRequestException('Page is already in this space'); } const abilities = await Promise.all([ this.spaceAbility.createForUser(user, movedPage.spaceId), this.spaceAbility.createForUser(user, dto.spaceId), ]); if ( abilities.some((ability) => ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page), ) ) { throw new ForbiddenException(); } return this.pageService.movePageToSpace(movedPage, dto.spaceId); } @HttpCode(HttpStatus.OK) @Post('duplicate') async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) { const copiedPage = await this.pageRepo.findById(dto.pageId); if (!copiedPage) { throw new NotFoundException('Page to copy not found'); } // If spaceId is provided, it's a copy to different space if (dto.spaceId) { const abilities = await Promise.all([ this.spaceAbility.createForUser(user, copiedPage.spaceId), this.spaceAbility.createForUser(user, dto.spaceId), ]); if ( abilities.some((ability) => ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page), ) ) { throw new ForbiddenException(); } return this.pageService.duplicatePage(copiedPage, dto.spaceId, user); } else { // If no spaceId, it's a duplicate in same space const ability = await this.spaceAbility.createForUser( user, copiedPage.spaceId, ); if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } return this.pageService.duplicatePage(copiedPage, undefined, user); } } @HttpCode(HttpStatus.OK) @Post('move') async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) { const movedPage = await this.pageRepo.findById(dto.pageId); if (!movedPage) { throw new NotFoundException('Moved page not found'); } const ability = await this.spaceAbility.createForUser( user, movedPage.spaceId, ); if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } return this.pageService.movePage(dto, movedPage); } @HttpCode(HttpStatus.OK) @Post('/breadcrumbs') async getPageBreadcrumbs(@Body() dto: PageIdDto, @AuthUser() user: User) { const page = await this.pageRepo.findById(dto.pageId); if (!page) { throw new NotFoundException('Page not found'); } const ability = await this.spaceAbility.createForUser(user, page.spaceId); if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) { throw new ForbiddenException(); } return this.pageService.getPageBreadCrumbs(page.id); } }