Files
TREK/server/src/nest/reservations/reservations.controller.ts
T
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

215 lines
8.8 KiB
TypeScript

import type { User } from '../../types';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { AirtrailLinkService } from '../integrations/airtrail-link.service';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import {
ReservationCreateDto,
ReservationUpdateDto,
ReservationPositionsDto,
ReservationTravelersDto,
} from './reservations.dto';
import { ReservationsService } from './reservations.service';
import { Body, Controller, Delete, Get, Headers, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
type ReservationBody = Record<string, unknown> & {
title?: string;
type?: string;
create_budget_entry?: { total_price?: number; category?: string; currency?: string | null };
};
/**
* /api/trips/:tripId/reservations — trip-scoped bookings.
*
* Byte-identical to the legacy Express route (server/src/routes/reservations.ts):
* trip access (404), 'reservation_edit' permission (403), create 201 / rest 200,
* the bespoke 404 bodies, the accommodation + budget side effects, the booking
* notifications, and all WebSocket broadcasts with the forwarded X-Socket-Id.
* Invalid bodies (missing title, non-array positions/user_ids) now 400 through
* the global ZodValidationPipe envelope instead of the bespoke strings.
* /positions is declared before /:id so it wins over the param.
*/
@Controller('api/trips/:tripId/reservations')
// TripAccessGuard resolves :tripId and 404s a trip the user cannot reach; mutations
// add @RequirePermission('reservation_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 ReservationsController {
constructor(
private readonly reservations: ReservationsService,
// Injected from AirtrailCoreModule — the split that retired airtrail.bridge.
private readonly airtrailLink: AirtrailLinkService,
) {}
@Get()
async list(@CurrentUser() user: User, @Param('tripId') tripId: string) {
return { reservations: await this.reservations.list(tripId) };
}
@RequirePermission('reservation_edit')
@Post()
async create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() rawBody: ReservationCreateDto,
@Headers('x-socket-id') socketId?: string,
) {
const body = rawBody as ReservationBody & { title: string };
await this.rejectForeignReferences(tripId, body);
// Before the writes: the price keeps the currency it was quoted in,
// at a rate frozen now (#2525).
const budgetEntry = await this.reservations.withFrozenRate(tripId, body.create_budget_entry);
// The booking and its linked cost are one write.
const { reservation, accommodationCreated, costEvents } = await this.reservations.createWithCost(
tripId,
body as never,
budgetEntry,
);
if (accommodationCreated) {
this.reservations.broadcast(tripId, 'accommodation:created', {}, socketId);
}
this.reservations.announceCost(tripId, costEvents, socketId);
this.reservations.broadcast(tripId, 'reservation:created', { reservation }, socketId);
await this.reservations.notifyBookingChange(tripId, user.id, body.title, body.type ?? '');
return { reservation };
}
@RequirePermission('reservation_edit')
@Put('positions')
async updatePositions(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: ReservationPositionsDto,
@Headers('x-socket-id') socketId?: string,
) {
// The legacy signature declares day_plan_position required, but the wire
// contract tolerates absent values (bind NULL) — see the shared schema.
await this.reservations.updatePositions(
tripId,
body.positions as { id: number; day_plan_position: number }[],
body.day_id,
);
this.reservations.broadcast(
tripId,
'reservation:positions',
{ positions: body.positions, day_id: body.day_id },
socketId,
);
return { success: true };
}
@RequirePermission('reservation_edit')
@Put(':id')
async update(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() rawBody: ReservationUpdateDto,
@Headers('x-socket-id') socketId?: string,
) {
const body = rawBody as ReservationBody;
const current = await this.reservations.getReservation(id, tripId);
if (!current) {
throw new HttpException({ error: 'Reservation not found' }, 404);
}
await this.rejectForeignReferences(tripId, body);
// The booking and its linked cost are one write.
const { reservation, accommodationChanged, costEvents } = await this.reservations.updateWithCost(
id,
tripId,
body as never,
current,
body.create_budget_entry,
);
if (accommodationChanged) {
this.reservations.broadcast(tripId, 'accommodation:updated', {}, socketId);
}
const cur = current as { title: string; type?: string };
this.reservations.announceCost(tripId, costEvents, socketId);
this.reservations.broadcast(tripId, 'reservation:updated', { reservation }, socketId);
// Push a locally-edited AirTrail flight back to AirTrail (fire-and-forget,
// under the importer's credentials — see airtrailSync). #214
if ((reservation as any)?.external_source === 'airtrail' && (reservation as any)?.sync_enabled) {
void this.airtrailLink.pushReservationToAirtrail(Number((reservation as any).id), Number(tripId)).catch(() => {});
}
await this.reservations.notifyBookingChange(tripId, user.id, body.title || cur.title, body.type || cur.type || '');
return { reservation };
}
@RequirePermission('reservation_edit')
@Put(':id/travelers')
async updateTravelers(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Body() body: ReservationTravelersDto,
@Headers('x-socket-id') socketId?: string,
) {
const result = await this.reservations.setTravelers(id, tripId, body.user_ids);
if (!result) {
throw new HttpException({ error: 'Reservation not found' }, 404);
}
this.reservations.broadcast(
tripId,
'reservation:travelers-updated',
{ reservationId: Number(id), travelers: result.travelers },
socketId,
);
return { travelers: result.travelers, reservation: result.reservation };
}
@RequirePermission('reservation_edit')
@Delete(':id')
async remove(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
const { deleted, accommodationDeleted, deletedBudgetItemIds } = await this.reservations.remove(id, tripId);
if (!deleted) {
throw new HttpException({ error: 'Reservation not found' }, 404);
}
if (accommodationDeleted) {
this.reservations.broadcast(
tripId,
'accommodation:deleted',
{ accommodationId: deleted.accommodation_id },
socketId,
);
}
for (const itemId of deletedBudgetItemIds) {
this.reservations.broadcast(tripId, 'budget:deleted', { itemId }, socketId);
}
this.reservations.broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, socketId);
await this.reservations.notifyBookingChange(tripId, user.id, deleted.title, deleted.type || '');
return { success: true };
}
/**
* The write contracts are open records, so day_id, place_id, assignment_id and
* accommodation_id arrive unvalidated. reservation_edit on :tripId says the
* caller may write HERE — it says nothing about the ids they put in the body,
* and a foreign accommodation_id used to be stored verbatim and deleted with
* the reservation. The MCP tools have refused foreign ids since they were
* written; this is the REST half of the same rule.
*
* Two questions, two answers. Reaching into another trip is the older one and
* keeps its wording. An id that resolves to nothing at all is the other half
* of the same rule — it used to reach the statement and come back as the bare
* 500 SQLite's foreign keys produce (#2355) — and saying "not part of this
* trip" about an id that is part of nothing would send the caller looking in
* the wrong place.
*/
private async rejectForeignReferences(tripId: string, body: ReservationBody): Promise<void> {
const offenders = await this.reservations.referencesOutsideTrip(tripId, body as never);
if (offenders.length > 0) {
throw new HttpException({ error: `Not part of this trip: ${offenders.join(', ')}` }, 400);
}
const unknown = await this.reservations.unresolvedReferences(tripId, body as never);
if (unknown.length > 0) {
throw new HttpException({ error: `Unknown reference: ${unknown.join(', ')}` }, 400);
}
}
}