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
325 lines
10 KiB
TypeScript
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,
|
|
);
|
|
}
|
|
}
|