fix(reservations): a booking read on its own carries its day slots, so an edit no longer drops them

The create and update answers, the reservation:updated broadcast and the MCP
reservation tools all hand back the single booking read, and that read never
loaded reservation_day_positions. The day list puts a booking moved on a day by
that slot before its time, and the client replaces the booking with the answer,
so a booking moved on a day jumped back to its time after every edit until the
next reload. The read now attaches day_positions in the shape the trip list
already sends.

Refs #2631
This commit is contained in:
Maurice
2026-10-12 12:32:24 +02:00
parent c2dd66986c
commit eaab7dc4e4
12 changed files with 108 additions and 7 deletions
@@ -32,6 +32,20 @@ export class ReservationDayPositionsRepository extends TrekRepository<Reservatio
.execute<{ reservation_id: number; day_id: number; position: number }[]>('all', false);
}
/**
* `SELECT day_id, position FROM reservation_day_positions WHERE reservation_id = ?`:
* one booking's slots, for the single-booking read the create, update and
* broadcast payloads come from. Without them a booking moved on a day lost
* that slot in the list after every edit until the next reload (#2631).
*/
async listForReservation(reservation_id: number): Promise<{ day_id: number; position: number }[]> {
return this.kysely<ReservationDayPositionsKyselyDB>()
.selectFrom('reservation_day_positions')
.select(['day_id', 'position'])
.where('reservation_id', '=', reservation_id)
.execute();
}
/**
* RS31 (`ReservationsService.updatePositions`'s day branch) — `INSERT OR
* REPLACE INTO reservation_day_positions (reservation_id, day_id,
@@ -1,3 +1,4 @@
import { ReservationDayPositions } from '../../db/entities/ReservationDayPositions.entity';
import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.entity';
import { ReservationTravelers } from '../../db/entities/ReservationTravelers.entity';
import { Reservations } from '../../db/entities/Reservations.entity';
@@ -11,14 +12,17 @@ import { Module } from '@nestjs/common';
* in — which would re-close the cycle that airtrail.bridge used to dodge.
*
* `MikroOrmModule.forFeature([Reservations, ReservationEndpoints,
* ReservationTravelers])` registers `ReservationsRepository`/
* `ReservationEndpointsRepository`/`ReservationTravelersRepository` for
* ReservationTravelers, ReservationDayPositions])` registers `ReservationsRepository`/
* `ReservationEndpointsRepository`/`ReservationTravelersRepository`/
* `ReservationDayPositionsRepository` for
* `ReservationsReadService`'s `@InjectRepository` constructor params (Plan
* 3d Task 2, R11) — the entity classes only, not `ReservationsModule`
* itself, so this module stays the same leaf it always was.
*/
@Module({
imports: [MikroOrmModule.forFeature([Reservations, ReservationEndpoints, ReservationTravelers])],
imports: [
MikroOrmModule.forFeature([Reservations, ReservationEndpoints, ReservationTravelers, ReservationDayPositions]),
],
providers: [ReservationsReadService],
exports: [ReservationsReadService],
})
@@ -1,6 +1,8 @@
import { ReservationDayPositions } from '../../db/entities/ReservationDayPositions.entity';
import { ReservationEndpoints } from '../../db/entities/ReservationEndpoints.entity';
import { ReservationTravelers } from '../../db/entities/ReservationTravelers.entity';
import { Reservations } from '../../db/entities/Reservations.entity';
import type { ReservationDayPositionsRepository } from '../../db/repositories/ReservationDayPositions.repository';
import type { ReservationEndpointsRepository } from '../../db/repositories/ReservationEndpoints.repository';
import type { ReservationTravelersRepository } from '../../db/repositories/ReservationTravelers.repository';
import type { ReservationsRepository } from '../../db/repositories/Reservations.repository';
@@ -53,6 +55,7 @@ export class ReservationsReadService {
@InjectRepository(Reservations) private readonly reservationsRepo: ReservationsRepository,
@InjectRepository(ReservationEndpoints) private readonly endpointsRepo: ReservationEndpointsRepository,
@InjectRepository(ReservationTravelers) private readonly travelersRepo: ReservationTravelersRepository,
@InjectRepository(ReservationDayPositions) private readonly dayPositionsRepo: ReservationDayPositionsRepository,
) {}
async getReservationWithJoins(id: string | number): Promise<ReservationRow | undefined> {
@@ -62,6 +65,7 @@ export class ReservationsReadService {
const result: ReservationRow = { ...row };
result.endpoints = await this.loadEndpoints(row.id);
result.travelers = await this.loadTravelers(row.id);
result.day_positions = await this.loadDayPositions(row.id);
// accommodation_id is a TEXT column; the integer FK reads back as a numeric
// string (e.g. "14.0"). Normalize to an int so clients can parse it.
result.accommodation_id = row.accommodation_id == null ? null : Math.trunc(Number(row.accommodation_id));
@@ -74,6 +78,17 @@ export class ReservationsReadService {
return (await this.endpointsRepo.listForReservation(reservationId)) as ReservationEndpoint[];
}
/**
* The booking's slot per day, in the shape `ReservationsService.list` sends:
* `{ [day_id]: position }`, or null without any. The list reads it before the
* booking's time, so a payload without it put a moved booking back by the clock.
*/
async loadDayPositions(reservationId: number): Promise<Record<number, number> | null> {
const rows = await this.dayPositionsRepo.listForReservation(reservationId);
if (rows.length === 0) return null;
return Object.fromEntries(rows.map((dp) => [dp.day_id, dp.position]));
}
async loadTravelers(reservationId: number | string): Promise<ReservationTraveler[]> {
// RR3 — no ORDER BY (unlike RS6), matching the legacy statement's own row order.
const rows = await this.travelersRepo.listForReservation(Number(reservationId));