diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index 9d3b08f57..3f0a814db 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -384,6 +384,20 @@ export class ReservationsService { * as upcoming when its own time is in the future, or — for timeless entries — * when its day falls on or after today. Cancelled bookings are skipped. * The default limit (6) matches the legacy inline handler. + * + * Hotels are left out on purpose (#1934). A stay covers a range rather than a + * moment, so a week in one hotel would hold a slot in a six-entry widget for + * the whole week and push out the bookings that actually happen on a day. The + * accommodation belongs to the day plan, which shows it across its span. + * + * They also never worked here. A hotel keeps its dates on the linked stay, not + * on the reservation: the booking form writes reservation_time = NULL and no + * day_id, so a form-created hotel matched neither arm of the date test and was + * invisible. The one path that did produce a visible row, the accommodation + * panel's auto-created booking, stamps the start day into reservation_time + * once and never restamps it, so moving the stay left the widget showing the + * old date. Both halves of the report come from reading a hotel's date off + * fields hotels do not use. */ listUpcoming(userId: number, limit = 6) { const today = new Date().toISOString().slice(0, 10); @@ -402,6 +416,7 @@ export class ReservationsService { WHERE (t.user_id = ? OR tm.user_id IS NOT NULL) AND t.is_archived = 0 AND r.status != 'cancelled' + AND COALESCE(r.type, '') != 'hotel' AND ( (r.reservation_time IS NOT NULL AND r.reservation_time >= ?) OR (r.reservation_time IS NULL AND d.date IS NOT NULL AND d.date >= ?) diff --git a/server/tests/e2e/reservations.e2e.test.ts b/server/tests/e2e/reservations.e2e.test.ts index 3af7e828c..bcac65060 100644 --- a/server/tests/e2e/reservations.e2e.test.ts +++ b/server/tests/e2e/reservations.e2e.test.ts @@ -112,11 +112,14 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real expect((await request(server).get('/api/reservations/upcoming')).status).toBe(401); }); - it('200 cross-trip upcoming reservations feed', async () => { + it('200 cross-trip upcoming reservations feed, without the hotels (#1934)', async () => { db.prepare("INSERT INTO reservations (trip_id, title, type, reservation_time) VALUES (?, 'Flight', 'flight', '2999-01-01T10:00:00')").run(tripId); + db.prepare("INSERT INTO reservations (trip_id, title, type, reservation_time) VALUES (?, 'Stay', 'hotel', '2999-01-01T09:00:00')").run(tripId); const res = await request(server).get('/api/reservations/upcoming').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); - expect(res.body.reservations.map((r: { title: string }) => r.title)).toContain('Flight'); + const titles = res.body.reservations.map((r: { title: string }) => r.title); + expect(titles).toContain('Flight'); + expect(titles).not.toContain('Stay'); }); it('404 when trip not accessible (reservations)', async () => { diff --git a/server/tests/unit/nest/reservations.service.test.ts b/server/tests/unit/nest/reservations.service.test.ts index 68b20d1b0..60f0d94ed 100644 --- a/server/tests/unit/nest/reservations.service.test.ts +++ b/server/tests/unit/nest/reservations.service.test.ts @@ -301,6 +301,39 @@ describe('ReservationsService (DI-native, real SQL)', () => { const rows = svc.listUpcoming(user.id) as { title: string }[]; expect(rows.map(r => r.title)).toEqual(['Future']); }); + + // #1934 — a stay covers a range, so it would hold a widget slot for its + // whole span. Both ways a hotel can be dated are kept out. + it('RESV-SVC-017b: leaves out a hotel dated through its own reservation_time', () => { + const { user, trip } = ownerTrip(); + const hotel = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' }); + testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-01-01T10:00:00', hotel.id); + const flight = createReservation(testDb, trip.id, { title: 'Flight' }); + testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2999-01-02T10:00:00', flight.id); + + const rows = svc.listUpcoming(user.id) as { title: string }[]; + expect(rows.map(r => r.title)).toEqual(['Flight']); + }); + + it('RESV-SVC-017c: leaves out a hotel dated through its day', () => { + const { user, trip } = ownerTrip(); + const day = createDay(testDb, trip.id, { date: '2999-06-01' }); + createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel', day_id: day.id }); + + const rows = svc.listUpcoming(user.id) as { title: string }[]; + expect(rows.map(r => r.title)).toEqual([]); + }); + + // The type column defaults to 'other' but nothing stops a NULL, and a + // NULL-unsafe `type != 'hotel'` would silently drop those rows. + it('RESV-SVC-017d: a reservation with no type at all still shows', () => { + const { user, trip } = ownerTrip(); + const res = createReservation(testDb, trip.id, { title: 'Untyped' }); + testDb.prepare('UPDATE reservations SET type = NULL, reservation_time = ? WHERE id = ?').run('2999-01-01T10:00:00', res.id); + + const rows = svc.listUpcoming(user.id) as { title: string }[]; + expect(rows.map(r => r.title)).toEqual(['Untyped']); + }); }); describe('resyncReservationDays', () => {