fix(server): fix the quirks preserved by the reservations DI migration

Two verified defects carried through the parity fold:

- The create-path accommodation metadata sync gated on the raw
  accommodation_id instead of the resolved one, so a hotel whose
  accommodation was just auto-created never received its metadata
  check-in/out times or confirmation. Now keyed off resolvedAccommodationId
  (RESV-SVC-006 re-pins the fixed behavior).
- The multi-statement writes ran outside transactions: create (accommodation
  insert + reservation insert + endpoint save + metadata sync), update
  (accommodation upsert + update + endpoint replace + sync), remove (the
  3-delete cascade), setReservationTravelers (delete + inserts) and
  resyncReservationDays now run in db.transaction() — a mid-write failure no
  longer leaves partial state (RESV-FIX-001/002 pin the rollbacks).

Left as-is on purpose: the truthy updatePositions dayId check, the
empty-string COALESCE keeps on title/status/type, the TEXT accommodation_id
normalization, the optional day_plan_position wire tolerance and the
swallowed notifyBookingChange catches — all contract or intentional
behavior, not defects.
This commit is contained in:
jubnl
2026-07-27 19:52:00 +02:00
parent c7dde2af2e
commit d952eaf4d4
2 changed files with 90 additions and 37 deletions
@@ -113,8 +113,11 @@ type AccommodationTimesMeta = {
/**
* Reservations domain service — owns the reservation SQL (moved 1:1 from the
* legacy services/reservationService.ts: identical statements, the `||`
* falsy-coercion defaults, the COALESCE update semantics, the post-write
* re-selects and the un-transactioned multi-statement writes). Trip access,
* falsy-coercion defaults, the COALESCE update semantics and the post-write
* re-selects; the multi-statement writes gained db.transaction() wrappers in
* the post-fold quirk-fix commit, and the accommodation metadata sync now
* keys off the resolved accommodation id so auto-created accommodations get
* their check-in/out times too). Trip access,
* the 'reservation_edit' permission and the WebSocket broadcast keep their
* legacy call paths. The legacy route's budget side effects (auto-create /
* update / delete a linked budget item) and the booking notification are
@@ -240,11 +243,13 @@ export class ReservationsService {
setReservationTravelers(reservationId: number | string, tripId: string | number, userIds: number[]): void {
const allowed = this.assignableUserIds(tripId);
const ids = [...new Set(userIds)].filter(uid => allowed.has(uid));
this.db.run('DELETE FROM reservation_travelers WHERE reservation_id = ?', reservationId);
if (ids.length > 0) {
const insert = this.db.prepare('INSERT OR IGNORE INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)');
for (const uid of ids) insert.run(reservationId, uid);
}
this.db.transaction(() => {
this.db.run('DELETE FROM reservation_travelers WHERE reservation_id = ?', reservationId);
if (ids.length > 0) {
const insert = this.db.prepare('INSERT OR IGNORE INTO reservation_travelers (reservation_id, user_id) VALUES (?, ?)');
for (const uid of ids) insert.run(reservationId, uid);
}
});
}
/** Assign trip members / named guests to a reservation (#1517). Null when off-trip. */
@@ -300,16 +305,18 @@ export class ReservationsService {
tripId
);
const update = this.db.prepare('UPDATE reservations SET day_id = ?, end_day_id = ? WHERE id = ?');
for (const r of rows) {
const newDayId = this.resolveDayIdFromTime(tripId, r.reservation_time, false);
if (newDayId == null) continue;
const newEndDayId = r.reservation_end_time
? (this.resolveDayIdFromTime(tripId, r.reservation_end_time, false) ?? r.end_day_id)
: r.end_day_id;
if (newDayId !== r.day_id || newEndDayId !== r.end_day_id) {
update.run(newDayId, newEndDayId, r.id);
this.db.transaction(() => {
for (const r of rows) {
const newDayId = this.resolveDayIdFromTime(tripId, r.reservation_time, false);
if (newDayId == null) continue;
const newEndDayId = r.reservation_end_time
? (this.resolveDayIdFromTime(tripId, r.reservation_end_time, false) ?? r.end_day_id)
: r.end_day_id;
if (newDayId !== r.day_id || newEndDayId !== r.end_day_id) {
update.run(newDayId, newEndDayId, r.id);
}
}
}
});
}
private saveEndpoints(reservationId: number, endpoints: EndpointInput[]): void {
@@ -436,7 +443,13 @@ export class ReservationsService {
return row;
}
/** The accommodation insert, the reservation insert, the endpoint save and
* the metadata sync are one logical write — all-or-nothing. */
create(tripId: string | number, data: CreateReservationData): { reservation: ReservationRow; accommodationCreated: boolean } {
return this.db.transaction(() => this.createInTx(tripId, data));
}
private createInTx(tripId: string | number, data: CreateReservationData): { reservation: ReservationRow; accommodationCreated: boolean } {
const {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, url, day_id, end_day_id, place_id, assignment_id,
@@ -500,19 +513,22 @@ export class ReservationsService {
this.saveEndpoints(Number(result.lastInsertRowid), endpoints);
}
// Sync check-in/out to accommodation if linked
if (accommodation_id && metadata) {
// Sync check-in/out to accommodation if linked. Keyed off the RESOLVED id
// (quirk fix): the legacy gate read the raw accommodation_id, so a hotel
// whose accommodation was just auto-created above never received its
// metadata check-in/out times or confirmation.
if (resolvedAccommodationId && metadata) {
const meta = (typeof metadata === 'string' ? JSON.parse(metadata) : metadata) as AccommodationTimesMeta;
if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) {
this.db.run(
'UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_in_end = COALESCE(?, check_in_end), check_out = COALESCE(?, check_out) WHERE id = ?',
meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null, accommodation_id
meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null, resolvedAccommodationId
);
}
if (confirmation_number) {
this.db.run(
'UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?',
confirmation_number, accommodation_id
confirmation_number, resolvedAccommodationId
);
}
}
@@ -546,7 +562,13 @@ export class ReservationsService {
return this.db.get<Reservation>('SELECT * FROM reservations WHERE id = ? AND trip_id = ?', id, tripId);
}
/** The accommodation upsert, the reservation update, the endpoint replace
* and the metadata sync are one logical write — all-or-nothing. */
update(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): { reservation: ReservationRow; accommodationChanged: boolean } {
return this.db.transaction(() => this.updateInTx(id, tripId, data, current));
}
private updateInTx(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): { reservation: ReservationRow; accommodationChanged: boolean } {
const {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, url, day_id, end_day_id, place_id, assignment_id,
@@ -685,25 +707,29 @@ export class ReservationsService {
return { reservation, accommodationChanged };
}
/** The accommodation + budget-item + reservation deletes are one logical
* cascade — all-or-nothing. */
remove(id: string | number, tripId: string | number): { deleted: { id: number; title: string; type: string; accommodation_id: number | null } | undefined; accommodationDeleted: boolean; deletedBudgetItemId: number | null } {
const reservation = this.db.get<{ id: number; title: string; type: string; accommodation_id: number | null }>(
'SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?', id, tripId
);
if (!reservation) return { deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null };
return this.db.transaction(() => {
const reservation = this.db.get<{ id: number; title: string; type: string; accommodation_id: number | null }>(
'SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?', id, tripId
);
if (!reservation) return { deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null };
let accommodationDeleted = false;
if (reservation.accommodation_id) {
this.db.run('DELETE FROM day_accommodations WHERE id = ?', reservation.accommodation_id);
accommodationDeleted = true;
}
let accommodationDeleted = false;
if (reservation.accommodation_id) {
this.db.run('DELETE FROM day_accommodations WHERE id = ?', reservation.accommodation_id);
accommodationDeleted = true;
}
const linkedBudget = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id);
if (linkedBudget) {
this.db.run('DELETE FROM budget_items WHERE id = ?', linkedBudget.id);
}
const linkedBudget = this.db.get<{ id: number }>('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?', tripId, id);
if (linkedBudget) {
this.db.run('DELETE FROM budget_items WHERE id = ?', linkedBudget.id);
}
this.db.run('DELETE FROM reservations WHERE id = ?', id);
return { deleted: reservation, accommodationDeleted, deletedBudgetItemId: linkedBudget ? linkedBudget.id : null };
this.db.run('DELETE FROM reservations WHERE id = ?', id);
return { deleted: reservation, accommodationDeleted, deletedBudgetItemId: linkedBudget ? linkedBudget.id : null };
});
}
/** POST side effect: auto-create a linked budget item when a price is provided. */
@@ -134,7 +134,7 @@ describe('ReservationsService (DI-native, real SQL)', () => {
expect(rows).toEqual([{ name: 'A', sequence: 0 }, { name: 'B', sequence: 1 }]);
});
it('RESV-SVC-006 (quirk preserved): metadata check-in sync is gated on the raw accommodation_id, so an auto-created accommodation is not updated', () => {
it('RESV-SVC-006 (quirk fixed): metadata check-in sync keys off the resolved id, so an auto-created accommodation gets its times too', () => {
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
const place = createPlace(testDb, trip.id);
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
@@ -143,8 +143,9 @@ describe('ReservationsService (DI-native, real SQL)', () => {
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[1].id },
metadata: { check_in_time: '16:00' },
});
// The legacy gate read the raw accommodation_id and left this NULL.
const acc = testDb.prepare('SELECT check_in FROM day_accommodations WHERE trip_id = ?').get(trip.id) as { check_in: string | null };
expect(acc.check_in).toBeNull(); // the L416 gate reads accommodation_id, not resolvedAccommodationId
expect(acc.check_in).toBe('16:00');
});
});
@@ -734,3 +735,29 @@ describe('ReservationsService — legacy branch parity (coverage of the folded c
expect(budget.deleteBudgetItem).not.toHaveBeenCalled();
});
});
describe('ReservationsService — quirk fixes (post-fold)', () => {
it('RESV-FIX-001: create is atomic — a failing endpoint save rolls back the reservation AND the auto-created accommodation', () => {
const { trip } = ownerTrip({ start_date: '2030-05-01', end_date: '2030-05-02' });
const place = createPlace(testDb, trip.id);
const days = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY day_number').all(trip.id) as { id: number }[];
// sequence is an unbindable object -> the endpoint INSERT throws mid-write.
const badEndpoints = [{ role: 'from', name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_time: null, local_date: null, sequence: {} }];
expect(() => svc.create(String(trip.id), {
title: 'Hotel', type: 'hotel',
create_accommodation: { place_id: place.id, start_day_id: days[0].id, end_day_id: days[1].id },
endpoints: badEndpoints,
} as never)).toThrow();
expect(testDb.prepare('SELECT COUNT(*) as c FROM reservations WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 });
expect(testDb.prepare('SELECT COUNT(*) as c FROM day_accommodations WHERE trip_id = ?').get(trip.id)).toEqual({ c: 0 });
});
it('RESV-FIX-002: update is atomic — a failing endpoint save rolls back the field update', () => {
const { trip } = ownerTrip();
const res = createReservation(testDb, trip.id, { title: 'Old' });
const current = svc.getReservation(String(res.id), String(trip.id))!;
const badEndpoints = [{ role: 'from', name: 'A', code: null, lat: 1, lng: 2, timezone: null, local_time: null, local_date: null, sequence: {} }];
expect(() => svc.update(String(res.id), String(trip.id), { title: 'New', endpoints: badEndpoints } as never, current)).toThrow();
expect(testDb.prepare('SELECT title FROM reservations WHERE id = ?').get(res.id)).toEqual({ title: 'Old' });
});
});