Files
TREK/server/src/nest/todo/todo.controller.ts
T

132 lines
4.7 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
Headers,
HttpException,
Param,
Post,
Put,
UseGuards,
} from '@nestjs/common';
import type { User } from '../../types';
import { TodoService } from './todo.service';
import { TodoCreateItemDto, TodoUpdateItemDto, TodoReorderDto, TodoCategoryAssigneesDto } from './todo.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
/**
* /api/trips/:tripId/todo — trip-scoped task list.
*
* Every handler verifies trip access (404); mutations check the 'packing_edit'
* permission (403); create is 201, the rest 200; mutations broadcast over
* WebSocket with the forwarded X-Socket-Id. /reorder is declared before /:id so
* it wins over the param. Bodies validate against the @trek/shared todo schemas
* via the DTO classes in todo.dto.ts + the global ZodValidationPipe (400 with
* the standard `{ error }` envelope on mismatch — this replaced the legacy
* bespoke 'Item name is required' check).
*/
@Controller('api/trips/:tripId/todo')
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations
// add @RequirePermission('packing_edit'), the same action string the service's canEdit
// passes, so the HTTP and MCP paths cannot demand different rights.
@UseGuards(JwtAuthGuard, TripAccessGuard)
export class TodoController {
constructor(private readonly todo: TodoService) {}
@Get()
async list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { items: await this.todo.listItems(tripId) };
}
@RequirePermission('packing_edit')
@Post()
async create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: TodoCreateItemDto,
@Headers('x-socket-id') socketId?: string,
) {
const { name, category, due_date, description, assigned_user_id, priority } = body;
const item = await this.todo.createItem(tripId, { name, category, due_date, description, assigned_user_id, priority });
this.todo.broadcast(tripId, 'todo:created', { item }, socketId);
return { item };
}
@RequirePermission('packing_edit')
@Put('reorder')
async reorder(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: TodoReorderDto,
) {
await this.todo.reorderItems(tripId, body.orderedIds);
return { success: true };
}
@RequirePermission('packing_edit')
@Put(':id')
async update(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: TodoUpdateItemDto,
@Headers('x-socket-id') socketId?: string,
) {
const { name, checked, category, due_date, description, assigned_user_id, priority } = body;
// bodyKeys carries which keys the request actually provided (the null-clear
// protocol); the parsed body only ever holds known schema keys.
const updated = await this.todo.updateItem(
tripId,
id,
// checked arrives as boolean or legacy 0/1 — normalize to the 0/1 the SQL binds.
{ name, checked: checked === undefined ? undefined : checked ? 1 : 0, category, due_date, description, assigned_user_id, priority },
Object.keys(body),
);
if (!updated) {
throw new HttpException({ error: 'Item not found' }, 404);
}
this.todo.broadcast(tripId, 'todo:updated', { item: updated }, socketId);
return { item: updated };
}
@RequirePermission('packing_edit')
@Delete(':id')
async remove(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
if (!(await this.todo.deleteItem(tripId, id))) {
throw new HttpException({ error: 'Item not found' }, 404);
}
this.todo.broadcast(tripId, 'todo:deleted', { itemId: Number(id) }, socketId);
return { success: true };
}
@Get('category-assignees')
async categoryAssignees(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { assignees: await this.todo.getCategoryAssignees(tripId) };
}
@RequirePermission('packing_edit')
@Put('category-assignees/:categoryName')
async updateCategoryAssignees(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('categoryName') categoryName: string,
@Body() body: TodoCategoryAssigneesDto,
@Headers('x-socket-id') socketId?: string,
) {
const category = decodeURIComponent(categoryName);
const rows = await this.todo.updateCategoryAssignees(tripId, category, body.user_ids);
this.todo.broadcast(tripId, 'todo:assignees', { category, assignees: rows }, socketId);
return { assignees: rows };
}
}