feat(server): adopt zod DTOs for the day and accommodation body contracts

This commit is contained in:
jubnl
2026-07-27 20:56:45 +02:00
parent d952eaf4d4
commit e62628bf93
8 changed files with 70 additions and 19 deletions
@@ -8,8 +8,6 @@
* here — the gate throws on stale entries, so removal is enforced too.
*/
export const BODY_CONTRACT_ALLOW_LIST: string[] = [
'AccommodationsController.create',
'AccommodationsController.update',
'AdminController.createInvite',
'AdminController.createPackingTemplate',
'AdminController.createTemplateCategory',
@@ -64,10 +62,6 @@ export const BODY_CONTRACT_ALLOW_LIST: string[] = [
'CategoriesController.update',
'CollectionsController.deleteMany',
'CollectionsController.reorder',
'DaysController.create',
'DaysController.reorder',
'DaysController.transport',
'DaysController.update',
'ImmichMemoriesController.putSettings',
'ImmichMemoriesController.search',
'ImmichMemoriesController.test',
+6 -6
View File
@@ -11,8 +11,8 @@ import {
UseGuards,
} from '@nestjs/common';
import type { User } from '../../types';
import { DaysService } from './days.service';
import { DayReorderError } from '../../services/dayService';
import { DaysService, DayReorderError } from './days.service';
import { DayCreateDto, DayReorderDto, DayTransportDto, DayUpdateDto } from './days.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
@@ -53,7 +53,7 @@ export class DaysController {
create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: { date?: string; notes?: string; position?: number },
@Body() body: DayCreateDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
@@ -74,7 +74,7 @@ export class DaysController {
reorder(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: { orderedIds?: number[] },
@Body() body: DayReorderDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
@@ -99,7 +99,7 @@ export class DaysController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: { notes?: string; title?: string | null },
@Body() body: DayUpdateDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
@@ -118,7 +118,7 @@ export class DaysController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: { transport_mode?: string | null },
@Body() body: DayTransportDto,
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
+18
View File
@@ -0,0 +1,18 @@
import { createZodDto } from 'nestjs-zod';
import {
dayCreateRequestSchema,
dayReorderRequestSchema,
dayTransportRequestSchema,
dayUpdateRequestSchema,
} from '@trek/shared';
/**
* Server-side createZodDto wrappers over the @trek/shared day 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 DayCreateDto extends createZodDto(dayCreateRequestSchema) {}
export class DayReorderDto extends createZodDto(dayReorderRequestSchema) {}
export class DayUpdateDto extends createZodDto(dayUpdateRequestSchema) {}
export class DayTransportDto extends createZodDto(dayTransportRequestSchema) {}
@@ -12,6 +12,7 @@ import {
} from '@nestjs/common';
import type { User } from '../../types';
import { AccommodationsService } from './accommodations.service';
import { AccommodationCreateDto, AccommodationUpdateDto } from './reservations.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
@@ -65,9 +66,10 @@ export class AccommodationsController {
create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: AccommodationBody,
@Body() rawBody: AccommodationCreateDto,
@Headers('x-socket-id') socketId?: string,
) {
const body = rawBody as AccommodationBody;
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = body;
@@ -89,9 +91,10 @@ export class AccommodationsController {
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: AccommodationBody,
@Body() rawBody: AccommodationUpdateDto,
@Headers('x-socket-id') socketId?: string,
) {
const body = rawBody as AccommodationBody;
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
const existing = this.accommodations.get(id, tripId);
@@ -1,5 +1,7 @@
import { createZodDto } from 'nestjs-zod';
import {
accommodationCreateBodySchema,
accommodationUpdateRequestSchema,
reservationCreateRequestSchema,
reservationUpdateRequestSchema,
reservationPositionsRequestSchema,
@@ -19,3 +21,5 @@ export class ReservationCreateDto extends createZodDto(reservationCreateRequestS
export class ReservationUpdateDto extends createZodDto(reservationUpdateRequestSchema) {}
export class ReservationPositionsDto extends createZodDto(reservationPositionsRequestSchema) {}
export class ReservationTravelersDto extends createZodDto(reservationTravelersRequestSchema) {}
export class AccommodationCreateDto extends createZodDto(accommodationCreateBodySchema) {}
export class AccommodationUpdateDto extends createZodDto(accommodationUpdateRequestSchema) {}
+15 -5
View File
@@ -6,11 +6,11 @@ import { z } from 'zod';
* Day + day-note API contract — single source of truth for the
* /api/trips/:tripId/days and /api/trips/:tripId/days/:dayId/notes endpoints.
*
* Trip-scoped, both gated by the 'day_edit' permission. The legacy routes
* (server/src/routes/days.ts + routes/dayNotes.ts) wrap dayService /
* dayNoteService. Day rows (with their assignments) are wide and DB-derived, so
* list responses stay open. Day notes cap text at 500 and time at 150 chars
* (the legacy validateStringLengths middleware) — reproduced in the controller.
* Trip-scoped, both gated by the 'day_edit' permission. Served by the Nest
* DaysController / DayNotesController (server/src/nest/days/). Day rows (with
* their assignments) are wide and DB-derived, so list responses stay open. Day
* notes cap text at 500 and time at 150 chars (the legacy
* validateStringLengths middleware) — reproduced in the controller.
*/
/**
@@ -68,6 +68,16 @@ export const dayUpdateRequestSchema = z.object({
});
export type DayUpdateRequest = z.infer<typeof dayUpdateRequestSchema>;
/**
* Whole-day default route mode (#1281). The client always sends
* `{ transport_mode: string | null }`; null clears the default. Kept loose (no
* enum, no max) — the legacy raw-body route accepted any string.
*/
export const dayTransportRequestSchema = z.object({
transport_mode: z.string().nullable().optional(),
});
export type DayTransportRequest = z.infer<typeof dayTransportRequestSchema>;
// `time`/`icon` accept null on the wire: the client's moveDayNote re-sends the
// entity fields (both nullable on dayNoteSchema) through create, and an explicit
// null time on update means "clear" (the legacy raw-body route accepted both).
@@ -1,6 +1,7 @@
import {
reservationCreateRequestSchema,
reservationPositionsRequestSchema,
accommodationCreateBodySchema,
accommodationCreateRequestSchema,
} from './reservation.schema';
@@ -45,4 +46,11 @@ describe('accommodationCreateRequestSchema', () => {
).toBe(true);
expect(accommodationCreateRequestSchema.safeParse({ place_id: 2 }).success).toBe(false);
});
it('body variant keeps missing refs schema-valid (bespoke controller 400)', () => {
// The REST endpoint requires all three refs, but their absence is answered
// by the controller's bespoke 400 body — the pipe must not pre-empt it.
expect(accommodationCreateBodySchema.safeParse({ place_id: 2 }).success).toBe(true);
expect(accommodationCreateBodySchema.safeParse({ check_in: 5 }).success).toBe(false);
});
});
@@ -182,6 +182,20 @@ export const accommodationCreateRequestSchema = z.object({
});
export type AccommodationCreateRequest = z.infer<typeof accommodationCreateRequestSchema>;
/**
* REST body variant of the create contract: the three refs are required by the
* endpoint but optional on the wire — their absence answers with the bespoke
* controller 400 ('place_id, start_day_id, and end_day_id are required'),
* which the validation pipe must not pre-empt. The plugin RPC host keeps the
* strict schema above (missing refs are BAD_PARAMS there).
*/
export const accommodationCreateBodySchema = accommodationCreateRequestSchema.partial({
place_id: true,
start_day_id: true,
end_day_id: true,
});
export type AccommodationCreateBody = z.infer<typeof accommodationCreateBodySchema>;
export const accommodationUpdateRequestSchema = open;
export type AccommodationUpdateRequest = z.infer<typeof accommodationUpdateRequestSchema>;