dorfteich/apps/api/src/public-api/public-api.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

325 lines
10 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Put,
Query,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import {
commentListQuerySchema,
createCommentInputSchema,
createLabelInputSchema,
pageListQuerySchema,
publicCreatePageInputSchema,
publicSearchQuerySchema,
publicUpdateLabelInputSchema,
publicUpdatePageInputSchema,
type CreateCommentInput,
type CreateLabelInput,
type LabelTreeNode,
type LabelView,
type PageCommentsView,
type PublicCommentView,
type PublicCreatePageInput,
type PublicMeView,
type PublicPageListItemView,
type PublicPageView,
type PublicPondView,
type PublicSearchQuery,
type PublicSearchResultView,
type PublicUpdateLabelInput,
type PublicUpdatePageInput,
} from '@dorfteich/shared';
import type { Response } from 'express';
import { Public } from '../auth/auth.guard';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { ExportService } from '../import-export/export.service';
import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators';
import { PublicApiGuard, RequiresWriteScope, type PublicApiRequest } from './public-api.guard';
import { PublicApiService } from './public-api.service';
/** Shorthands: every page route names the page the same way. */
const PAGE = { pondSlugParam: 'pondSlug', slugParam: 'pageSlug' } as const;
const POND = { slugParam: 'pondSlug' } as const;
/**
* The public REST API v1 (issue #104), served outside the SPA prefix at
* `/api/public/v1` (main.ts excludes it from the global prefix). `@Public()`
* only skips the cookie-session AuthGuard — the {@link PublicApiGuard}
* enforces PAT bearer auth, the instance switch, scope, per-token rate
* limits, and the pond opt-in; the method-level permission decorators then
* apply the unchanged permission model (404-vs-403 per #60) as the token's
* user.
*/
@Controller('api/public/v1')
@Public()
@UseGuards(PublicApiGuard)
export class PublicApiController {
constructor(
private readonly publicApi: PublicApiService,
private readonly exports: ExportService,
) {}
@Get('me')
me(@Req() request: PublicApiRequest): Promise<PublicMeView> {
return this.publicApi.me(request.user!, request.apiToken!);
}
@Get('ponds')
listPonds(@Req() request: PublicApiRequest): Promise<PublicPondView[]> {
return this.publicApi.listPonds(request.user!, request.apiToken!);
}
@Get('ponds/:pondSlug')
@RequiresPondRole('reader', POND)
getPond(@Param('pondSlug') pondSlug: string): Promise<PublicPondView> {
return this.publicApi.getPond(pondSlug);
}
@Get('ponds/:pondSlug/pages')
@RequiresPondRole('reader', POND)
listPages(
@Param('pondSlug') pondSlug: string,
@Query('createdSince') createdSince: string | undefined,
@Query('updatedSince') updatedSince: string | undefined,
@Req() request: PublicApiRequest,
): Promise<PublicPageListItemView[]> {
// Optional time filters (issue #148); an invalid instant → 400.
const query = new ZodValidationPipe(pageListQuerySchema).transform({
createdSince: createdSince || undefined,
updatedSince: updatedSince || undefined,
});
return this.publicApi.listPages(request.user!, pondSlug, query);
}
@Post('ponds/:pondSlug/pages')
@RequiresWriteScope()
@RequiresPondRole('editor', POND)
createPage(
@Param('pondSlug') pondSlug: string,
@Body(new ZodValidationPipe(publicCreatePageInputSchema)) input: PublicCreatePageInput,
@Req() request: PublicApiRequest,
): Promise<PublicPageView> {
return this.publicApi.createPage(request.user!, request.apiToken!, pondSlug, input);
}
@Get('ponds/:pondSlug/pages/:pageSlug')
@RequiresPagePermission('read', PAGE)
getPage(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Req() request: PublicApiRequest,
): Promise<PublicPageView> {
return this.publicApi.getPage(request.user!, pondSlug, pageSlug);
}
@Patch('ponds/:pondSlug/pages/:pageSlug')
@RequiresWriteScope()
@RequiresPagePermission('write', PAGE)
updatePage(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Body(new ZodValidationPipe(publicUpdatePageInputSchema)) input: PublicUpdatePageInput,
@Req() request: PublicApiRequest,
): Promise<PublicPageView> {
return this.publicApi.updatePage(request.user!, request.apiToken!, pondSlug, pageSlug, input);
}
@Delete('ponds/:pondSlug/pages/:pageSlug')
@RequiresWriteScope()
@RequiresPagePermission('write', PAGE)
@HttpCode(204)
async deletePage(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Req() request: PublicApiRequest,
): Promise<void> {
await this.publicApi.deletePage(request.user!, request.apiToken!, pondSlug, pageSlug);
}
@Get('search')
search(
@Query('q') q: string,
@Query('pond') pond: string | undefined,
@Query('label') label: string | undefined,
@Req() request: PublicApiRequest,
): Promise<PublicSearchResultView[]> {
const query: PublicSearchQuery = new ZodValidationPipe(publicSearchQuerySchema).transform({
q,
pond: pond || undefined,
label: label || undefined,
});
return this.publicApi.searchPages(request.user!, request.apiToken!, query);
}
@Get('ponds/:pondSlug/export/markdown')
@RequiresPondRole('reader', POND)
async exportMarkdown(
@Param('pondSlug') pondSlug: string,
@Req() request: PublicApiRequest,
@Res() res: Response,
): Promise<void> {
const pond = await this.publicApi.requirePond(pondSlug);
await this.exports.streamPondMarkdownZip(request.user!, pond.id, res);
}
@Get('ponds/:pondSlug/labels')
@RequiresPondRole('reader', POND)
listLabels(
@Param('pondSlug') pondSlug: string,
@Req() request: PublicApiRequest,
): Promise<LabelTreeNode[]> {
return this.publicApi.listLabels(request.user!, pondSlug);
}
@Post('ponds/:pondSlug/labels')
@RequiresWriteScope()
@RequiresPondRole('pond_admin', POND)
createLabel(
@Param('pondSlug') pondSlug: string,
@Body(new ZodValidationPipe(createLabelInputSchema)) input: CreateLabelInput,
@Req() request: PublicApiRequest,
): Promise<LabelView> {
return this.publicApi.createLabel(request.user!, request.apiToken!, pondSlug, input);
}
@Patch('ponds/:pondSlug/labels/:labelId')
@RequiresWriteScope()
@RequiresPondRole('pond_admin', POND)
updateLabel(
@Param('pondSlug') pondSlug: string,
@Param('labelId') labelId: string,
@Body(new ZodValidationPipe(publicUpdateLabelInputSchema)) input: PublicUpdateLabelInput,
@Req() request: PublicApiRequest,
): Promise<LabelView> {
return this.publicApi.updateLabel(request.user!, request.apiToken!, pondSlug, labelId, input);
}
@Delete('ponds/:pondSlug/labels/:labelId')
@RequiresWriteScope()
@RequiresPondRole('pond_admin', POND)
@HttpCode(204)
async deleteLabel(
@Param('pondSlug') pondSlug: string,
@Param('labelId') labelId: string,
@Req() request: PublicApiRequest,
): Promise<void> {
await this.publicApi.deleteLabel(request.user!, request.apiToken!, pondSlug, labelId);
}
@Put('ponds/:pondSlug/pages/:pageSlug/labels/:labelId')
@RequiresWriteScope()
@RequiresPagePermission('write', PAGE)
assignLabel(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Param('labelId') labelId: string,
@Req() request: PublicApiRequest,
): Promise<LabelView[]> {
return this.publicApi.assignLabel(
request.user!,
request.apiToken!,
pondSlug,
pageSlug,
labelId,
);
}
@Delete('ponds/:pondSlug/pages/:pageSlug/labels/:labelId')
@RequiresWriteScope()
@RequiresPagePermission('write', PAGE)
@HttpCode(204)
async unassignLabel(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Param('labelId') labelId: string,
@Req() request: PublicApiRequest,
): Promise<void> {
await this.publicApi.unassignLabel(
request.user!,
request.apiToken!,
pondSlug,
pageSlug,
labelId,
);
}
@Get('ponds/:pondSlug/pages/:pageSlug/comments')
@RequiresPagePermission('read', PAGE)
listComments(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Query('filter') filter: string | undefined,
): Promise<PageCommentsView> {
const parsed = commentListQuerySchema.parse({ filter: filter || undefined });
return this.publicApi.listComments(pondSlug, pageSlug, parsed.filter);
}
@Post('ponds/:pondSlug/pages/:pageSlug/comments')
@RequiresWriteScope()
// Read at route level: whether the token's user may COMMENT is the pond's
// comment policy, enforced in CommentsService (readers vs editors, #91).
@RequiresPagePermission('read', PAGE)
createComment(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Body(new ZodValidationPipe(createCommentInputSchema)) input: CreateCommentInput,
@Req() request: PublicApiRequest,
): Promise<PublicCommentView> {
return this.publicApi.createComment(
request.user!,
request.apiToken!,
pondSlug,
pageSlug,
input,
);
}
@Post('ponds/:pondSlug/pages/:pageSlug/comments/:commentId/resolve')
@RequiresWriteScope()
@RequiresPagePermission('read', PAGE)
resolveComment(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Param('commentId') commentId: string,
@Req() request: PublicApiRequest,
): Promise<PublicCommentView> {
return this.publicApi.setCommentResolved(
request.user!,
request.apiToken!,
pondSlug,
pageSlug,
commentId,
true,
);
}
@Delete('ponds/:pondSlug/pages/:pageSlug/comments/:commentId/resolve')
@RequiresWriteScope()
@RequiresPagePermission('read', PAGE)
unresolveComment(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Param('commentId') commentId: string,
@Req() request: PublicApiRequest,
): Promise<PublicCommentView> {
return this.publicApi.setCommentResolved(
request.user!,
request.apiToken!,
pondSlug,
pageSlug,
commentId,
false,
);
}
}