4. Write the controller
// backend/src/tasks/tasks.controller.ts
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
NotFoundException,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { TasksService } from './tasks.service';
import {
CreateTaskDto,
TaskDto,
TaskListDto,
UpdateTaskDto,
extractTaskDtoFromRow,
} from './tasks.schemas';
import { DeleteResultDto } from 'src/common/common.schemas';
import { SessionGuard } from 'src/auth/auth.guard';
import { CurrentUser } from 'src/auth/decorators';
import { UserRow } from 'src/db/types';
import { clampLimit, parseOffset } from 'src/common/pagination';
@ApiTags('tasks')
@Controller('tasks')
@UseGuards(SessionGuard)
@ApiBearerAuth()
export class TasksController {
constructor(private readonly tasks: TasksService) {}
@Get()
@ApiOperation({ summary: 'List my tasks' })
@ApiQuery({ name: 'offset', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiOkResponse({ type: TaskListDto })
async list(
@CurrentUser() user: UserRow,
@Query('offset') offset?: string,
@Query('limit') limit?: string,
): Promise<TaskListDto> {
const result = await this.tasks.findByUserPaginated(
user.id,
parseOffset(offset),
clampLimit(limit),
);
return {
items: result.items.map(extractTaskDtoFromRow),
total: result.total,
};
}
@Post()
@ApiOperation({ summary: 'Create a task' })
@ApiCreatedResponse({ type: TaskDto })
async create(
@Body() body: CreateTaskDto,
@CurrentUser() user: UserRow,
): Promise<TaskDto> {
const row = await this.tasks.create({ title: body.title, userId: user.id });
return extractTaskDtoFromRow(row);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a task' })
@ApiOkResponse({ type: TaskDto })
async update(
@Param('id') id: string,
@Body() body: UpdateTaskDto,
@CurrentUser() user: UserRow,
): Promise<TaskDto> {
const existing = await this.tasks.findById(id);
if (!existing) throw new NotFoundException('Task not found');
if (existing.user_id !== user.id && !user.is_admin) {
throw new ForbiddenException('Not your task');
}
const row = await this.tasks.update({ id, ...body });
if (!row) throw new NotFoundException('Task not found');
return extractTaskDtoFromRow(row);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a task' })
@ApiOkResponse({ type: DeleteResultDto })
async delete(
@Param('id') id: string,
@CurrentUser() user: UserRow,
): Promise<DeleteResultDto> {
const existing = await this.tasks.findById(id);
if (!existing) throw new NotFoundException('Task not found');
if (existing.user_id !== user.id && !user.is_admin) {
throw new ForbiddenException('Not your task');
}
const success = await this.tasks.delete(id);
return { success };
}
}
Guards go by access level: omit @UseGuards for a public route, @UseGuards(SessionGuard) + @ApiBearerAuth() for a logged-in one, @UseGuards(SessionGuard, AdminGuard) for admin. Decorate them per route rather than per class when the surface is mixed, the way items.controller.ts does.
Nest's default success status for POST is 201, with no decorator needed. A POST that creates a persisted resource (create() above) documents that with @ApiCreatedResponse instead of @ApiOkResponse, so the generated spec matches the wire. A POST that performs an action rather than creating a resource (queuing a deletion, minting an external session URL) stays 200: add @HttpCode(HttpStatus.OK) to override Nest's default and keep @ApiOkResponse. Picking one without the other is exactly how the spec drifts from the wire.
For non-trivial query/param shapes (anything beyond a string), define a small zod schema and .safeParse() inline: see blog.controller.ts's tag parameter.