import { Body, Controller, Delete, Get, GoneException, HttpCode, Param, Patch, Post, Put, Req, Res, } from '@nestjs/common'; import { CollabTokenResponse, CreatePageInput, PageListItemView, PageStateView, PageView, RepositionPageInput, UpdatePageInput, createPageInputSchema, repositionPageInputSchema, updatePageInputSchema, } from '@dorfteich/shared'; import type { Response } from 'express'; import { AuthedRequest, Public } from '../auth/auth.guard'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { AuthenticatedOnly, RequiresPagePermission, RequiresPondRole, } from '../permissions/permission.decorators'; import { PagesService } from './pages.service'; /** Page CRUD and Yjs state persistence (issue #23; permission guard since #52). */ @Controller() export class PagesController { constructor(private readonly pages: PagesService) {} @Post('ponds/:pondId/pages') @RequiresPondRole('editor', { idParam: 'pondId' }) async create( @Param('pondId') pondId: string, @Body(new ZodValidationPipe(createPageInputSchema)) input: CreatePageInput, @Req() request: AuthedRequest, ): Promise { return this.pages.create(request.user!, pondId, input); } @Get('ponds/:pondId/pages') @RequiresPondRole('reader', { idParam: 'pondId' }) // the service filters per page async list( @Param('pondId') pondId: string, @Req() request: AuthedRequest, ): Promise { return this.pages.list(request.user!, pondId); } @Get('pages/:id') @RequiresPagePermission('read', { idParam: 'id' }) async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise { return this.pages.getState(request.user!, id); } /** * Short-lived collaboration token for the collab server (issue #34). * `@Public()` so an anonymous visitor to a public page can obtain a token * (issue #53); the permission guard still enforces read access (404 when no * grant makes the page readable) and downgrades non-writers to `ro`. */ @Get('pages/:id/collab-token') @Public() @RequiresPagePermission('read', { idParam: 'id' }) // readers (incl. public) get an `ro` token async collabToken( @Param('id') id: string, @Req() request: AuthedRequest, ): Promise { return this.pages.issueCollabToken(request.user ?? null, id); } /** Markdown export (issue #30) — downloads `.md`. */ @Get('pages/:id/export/markdown') @RequiresPagePermission('read', { idParam: 'id' }) async exportMarkdown( @Param('id') id: string, @Req() request: AuthedRequest, @Res({ passthrough: true }) response: Response, ): Promise { const { slug, markdown } = await this.pages.exportMarkdown(request.user!, id); response.set('Content-Type', 'text/markdown; charset=utf-8'); response.set('Content-Disposition', `attachment; filename="${slug}.md"`); return markdown; } /** Resolves the pond-slug + page-slug pair the `/p/:pondSlug/:pageSlug` route * navigates to (issue #25); the pond id must already be known to the caller * (e.g. from `GET /ponds/:slug`). */ @Get('ponds/:pondId/pages/:slug') @RequiresPagePermission('read', { pondIdParam: 'pondId', slugParam: 'slug' }) async getStateBySlug( @Param('pondId') pondId: string, @Param('slug') slug: string, @Req() request: AuthedRequest, ): Promise { return this.pages.getStateBySlug(request.user!, pondId, slug); } /** * The REST state-write path was retired when the editor moved to live * collaboration (#36): document changes now flow through the collab server * (ADR 0003), which is the sole writer of page state. The read paths (`GET`) * remain. Kept as an explicit 410 so any stale client gets a clear signal. */ @Put('pages/:id/state') @AuthenticatedOnly() // always 410 — never touches the page saveState(): never { throw new GoneException({ code: 'rest_state_write_retired', details: { hint: 'Page content is edited live over the collaboration server (/collab).' }, }); } /** Reposition a page in the manual sidebar order (issue #45). */ @Patch('pages/:id/position') @RequiresPagePermission('write', { idParam: 'id' }) async reposition( @Param('id') id: string, @Body(new ZodValidationPipe(repositionPageInputSchema)) input: RepositionPageInput, @Req() request: AuthedRequest, ): Promise { return this.pages.reposition(request.user!, id, input); } @Patch('pages/:id') @RequiresPagePermission('write', { idParam: 'id' }) async update( @Param('id') id: string, @Body(new ZodValidationPipe(updatePageInputSchema)) input: UpdatePageInput, @Req() request: AuthedRequest, ): Promise { return this.pages.update(request.user!, id, input); } @Delete('pages/:id') @HttpCode(204) @RequiresPagePermission('write', { idParam: 'id' }) async remove(@Param('id') id: string, @Req() request: AuthedRequest): Promise { await this.pages.softDelete(request.user!, id); } }