diff --git a/server/src/nest/common/body-contract-allow-list.ts b/server/src/nest/common/body-contract-allow-list.ts index ffac7898a..2cc4302d8 100644 --- a/server/src/nest/common/body-contract-allow-list.ts +++ b/server/src/nest/common/body-contract-allow-list.ts @@ -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', diff --git a/server/src/nest/days/days.controller.ts b/server/src/nest/days/days.controller.ts index 2b0fdefef..13d43d67c 100644 --- a/server/src/nest/days/days.controller.ts +++ b/server/src/nest/days/days.controller.ts @@ -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); diff --git a/server/src/nest/days/days.dto.ts b/server/src/nest/days/days.dto.ts new file mode 100644 index 000000000..53f42ede5 --- /dev/null +++ b/server/src/nest/days/days.dto.ts @@ -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) {} diff --git a/server/src/nest/reservations/accommodations.controller.ts b/server/src/nest/reservations/accommodations.controller.ts index 92a85320d..29bca1d85 100644 --- a/server/src/nest/reservations/accommodations.controller.ts +++ b/server/src/nest/reservations/accommodations.controller.ts @@ -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); diff --git a/server/src/nest/reservations/reservations.dto.ts b/server/src/nest/reservations/reservations.dto.ts index 3f2cdea8b..f4e718efd 100644 --- a/server/src/nest/reservations/reservations.dto.ts +++ b/server/src/nest/reservations/reservations.dto.ts @@ -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) {} diff --git a/shared/src/day/day.schema.ts b/shared/src/day/day.schema.ts index e1dc166df..10638a416 100644 --- a/shared/src/day/day.schema.ts +++ b/shared/src/day/day.schema.ts @@ -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; +/** + * 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; + // `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). diff --git a/shared/src/reservation/reservation.schema.spec.ts b/shared/src/reservation/reservation.schema.spec.ts index 3f2f3bcbb..d75e3ebb7 100644 --- a/shared/src/reservation/reservation.schema.spec.ts +++ b/shared/src/reservation/reservation.schema.spec.ts @@ -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); + }); }); diff --git a/shared/src/reservation/reservation.schema.ts b/shared/src/reservation/reservation.schema.ts index 349a73038..893d5bbca 100644 --- a/shared/src/reservation/reservation.schema.ts +++ b/shared/src/reservation/reservation.schema.ts @@ -182,6 +182,20 @@ export const accommodationCreateRequestSchema = z.object({ }); export type AccommodationCreateRequest = z.infer; +/** + * 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; + export const accommodationUpdateRequestSchema = open; export type AccommodationUpdateRequest = z.infer;