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
171 lines
5.7 KiB
TypeScript
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);
|
|
}
|
|
}
|