dorfteich/apps/api/src/pages/pages.controller.ts
Claude Fable 5 d73b120d06 #148: Seitenlisten filtern nach createdSince/updatedSince
Neues pageListQuerySchema (ISO 8601, Kulanz für Datum ohne Zeit),
Query-Parameter auf interner und Public-API-Seitenliste, Prisma-where
mit gte; neue Indizes (pondId, createdAt)/(pondId, updatedAt) als
Migration. OpenAPI-Parameter, MCP-Parität (list_pages
created_since/updated_since), Doku (api-guide, mcp-guide,
public-api.md), DB-Test inkl. 400 bei ungültigem Datum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 00:49:52 +02:00

171 lines
5.7 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
GoneException,
HttpCode,
Param,
Patch,
Post,
Put,
Query,
Req,
Res,
} from '@nestjs/common';
import {
CollabTokenResponse,
CreatePageInput,
PageDeleteQuery,
PageListItemView,
PageStateView,
PageView,
RepositionPageInput,
UpdatePageInput,
createPageInputSchema,
pageDeleteQuerySchema,
pageListQuerySchema,
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<PageView> {
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,
@Query('createdSince') createdSince: string | undefined,
@Query('updatedSince') updatedSince: string | undefined,
@Req() request: AuthedRequest,
): Promise<PageListItemView[]> {
// Optional time filters (issue #148); an invalid instant → 400.
const query = new ZodValidationPipe(pageListQuerySchema).transform({
createdSince: createdSince || undefined,
updatedSince: updatedSince || undefined,
});
return this.pages.list(request.user!, pondId, query);
}
@Get('pages/:id')
@RequiresPagePermission('read', { idParam: 'id' })
async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise<PageStateView> {
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<CollabTokenResponse> {
return this.pages.issueCollabToken(request.user ?? null, id);
}
/** Markdown export (issue #30) — downloads `<slug>.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<string> {
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<PageStateView> {
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<PageView> {
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<PageView> {
return this.pages.update(request.user!, id, input);
}
/** Trash a page; `?mode=subtree` takes the live descendants along, the
* default `promote` re-attaches them to the page's parent (issue #107). */
@Delete('pages/:id')
@HttpCode(204)
@RequiresPagePermission('write', { idParam: 'id' })
async remove(
@Param('id') id: string,
@Query(new ZodValidationPipe(pageDeleteQuerySchema)) query: PageDeleteQuery,
@Req() request: AuthedRequest,
): Promise<void> {
await this.pages.softDelete(request.user!, id, query.mode);
}
}