feat(server): adopt zod DTOs for the assignment body contracts

This commit is contained in:
jubnl
2026-07-26 20:34:06 +02:00
parent 164f5152be
commit 41b51849b7
7 changed files with 153 additions and 73 deletions
@@ -12,6 +12,14 @@ import {
} from '@nestjs/common';
import type { User } from '../../types';
import { AssignmentsService } from './assignments.service';
import {
AssignmentCreateDto,
AssignmentReorderDto,
AssignmentMoveDto,
AssignmentTimeDto,
AssignmentTransportDto,
AssignmentParticipantsDto,
} from './assignments.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
@@ -35,10 +43,11 @@ function requireEdit(svc: AssignmentsService, trip: Trip, user: User): void {
/**
* /api/trips/:tripId/days/:dayId/assignments — the day's ordered itinerary items.
*
* Byte-identical to the legacy Express route (server/src/routes/assignments.ts):
* trip access (404), 'day_edit' on mutations (403, GET is access-only), create
* 201 / rest 200, the bespoke "Day not found" / "Place not found" / "Assignment
* not found" bodies, the journey skeleton reconcile, and WebSocket broadcasts.
* Parity with the retired legacy Express route: trip access (404), 'day_edit'
* on mutations (403, GET is access-only), create 201 / rest 200, the bespoke
* "Day not found" / "Place not found" / "Assignment not found" bodies, the
* journey skeleton reconcile, and WebSocket broadcasts. Bodies are validated
* by the @trek/shared Zod contracts via assignments.dto.ts (global pipe).
*/
@Controller('api/trips/:tripId/days/:dayId/assignments')
@UseGuards(JwtAuthGuard)
@@ -59,7 +68,7 @@ export class DayAssignmentsController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Body() body: { place_id?: unknown; notes?: string | null },
@Body() body: AssignmentCreateDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = requireTrip(this.assignments, tripId, user);
@@ -81,7 +90,7 @@ export class DayAssignmentsController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('dayId') dayId: string,
@Body('orderedIds') orderedIds: number[],
@Body() body: AssignmentReorderDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = requireTrip(this.assignments, tripId, user);
@@ -89,8 +98,8 @@ export class DayAssignmentsController {
if (!this.assignments.dayExists(dayId, tripId)) {
throw new HttpException({ error: 'Day not found' }, 404);
}
this.assignments.reorderAssignments(dayId, orderedIds);
this.assignments.broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds }, socketId);
this.assignments.reorderAssignments(dayId, body.orderedIds);
this.assignments.broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds: body.orderedIds }, socketId);
return { success: true };
}
@@ -128,20 +137,18 @@ export class AssignmentOpsController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: { new_day_id?: unknown; order_index?: number },
@Body() body: AssignmentMoveDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = requireTrip(this.assignments, tripId, user);
requireEdit(this.assignments, trip, user);
const existing = this.assignments.getAssignmentForTrip(id, tripId);
if (!existing) {
if (!this.assignments.getAssignmentForTrip(id, tripId)) {
throw new HttpException({ error: 'Assignment not found' }, 404);
}
if (!this.assignments.dayExists(String(body.new_day_id), tripId)) {
throw new HttpException({ error: 'Target day not found' }, 404);
}
const oldDayId = (existing as { day_id: number }).day_id;
const { assignment } = this.assignments.moveAssignment(id, body.new_day_id, body.order_index, oldDayId);
const { assignment, oldDayId } = this.assignments.moveAssignment(id, body.new_day_id, body.order_index);
this.assignments.broadcast(tripId, 'assignment:moved', { assignment, oldDayId: Number(oldDayId), newDayId: Number(body.new_day_id) }, socketId);
this.assignments.reconcile(tripId, socketId);
return { assignment };
@@ -158,7 +165,7 @@ export class AssignmentOpsController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: { place_time?: string | null; end_time?: string | null },
@Body() body: AssignmentTimeDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = requireTrip(this.assignments, tripId, user);
@@ -177,7 +184,7 @@ export class AssignmentOpsController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: { transport_mode?: string | null },
@Body() body: AssignmentTransportDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = requireTrip(this.assignments, tripId, user);
@@ -195,15 +202,12 @@ export class AssignmentOpsController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body('user_ids') userIds: unknown,
@Body() body: AssignmentParticipantsDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = requireTrip(this.assignments, tripId, user);
requireEdit(this.assignments, trip, user);
if (!Array.isArray(userIds)) {
throw new HttpException({ error: 'user_ids must be an array' }, 400);
}
const participants = this.assignments.setParticipants(id, userIds);
const participants = this.assignments.setParticipants(id, body.user_ids);
this.assignments.broadcast(tripId, 'assignment:participants', { assignmentId: Number(id), participants }, socketId);
return { participants };
}
@@ -0,0 +1,23 @@
import { createZodDto } from 'nestjs-zod';
import {
assignmentCreateRequestSchema,
assignmentReorderRequestSchema,
assignmentMoveRequestSchema,
assignmentTimeRequestSchema,
assignmentTransportRequestSchema,
assignmentParticipantsRequestSchema,
} from '@trek/shared';
/**
* Server-side createZodDto wrappers over the @trek/shared assignment
* contracts. The global ZodValidationPipe (APP_PIPE in app.module.ts)
* validates any @Body() parameter typed with one of these classes by
* metatype — the Zod schemas in shared/ remain the single source of truth for
* the wire contract.
*/
export class AssignmentCreateDto extends createZodDto(assignmentCreateRequestSchema) {}
export class AssignmentReorderDto extends createZodDto(assignmentReorderRequestSchema) {}
export class AssignmentMoveDto extends createZodDto(assignmentMoveRequestSchema) {}
export class AssignmentTimeDto extends createZodDto(assignmentTimeRequestSchema) {}
export class AssignmentTransportDto extends createZodDto(assignmentTransportRequestSchema) {}
export class AssignmentParticipantsDto extends createZodDto(assignmentParticipantsRequestSchema) {}
@@ -30,10 +30,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [
'AdminController.updateTemplateCategory',
'AdminController.updateTemplateItem',
'AdminController.updateUser',
'AssignmentOpsController.move',
'AssignmentOpsController.setParticipants',
'AssignmentOpsController.time',
'AssignmentOpsController.transport',
'AtlasController.createBucketItem',
'AtlasController.markRegion',
'AtlasController.updateBucketItem',
@@ -74,8 +70,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [
'CollabController.votePoll',
'CollectionsController.deleteMany',
'CollectionsController.reorder',
'DayAssignmentsController.create',
'DayAssignmentsController.reorder',
'DaysController.create',
'DaysController.reorder',
'DaysController.transport',