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 { return this.publicApi.me(request.user!, request.apiToken!); } @Get('ponds') listPonds(@Req() request: PublicApiRequest): Promise { return this.publicApi.listPonds(request.user!, request.apiToken!); } @Get('ponds/:pondSlug') @RequiresPondRole('reader', POND) getPond(@Param('pondSlug') pondSlug: string): Promise { 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 { // 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { return this.publicApi.setCommentResolved( request.user!, request.apiToken!, pondSlug, pageSlug, commentId, false, ); } }