diff --git a/README.md b/README.md
index 08fa843..0394814 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire | OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) | | XML Processing | fast-xml-parser | Booking.com OTA XML protocol | | Package Manager | pnpm workspaces | Monorepo management | -| Testing | Vitest (1417 tests across 200 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | +| Testing | Vitest (1423 tests across 200 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | | Containers | Docker + docker-compose | Local dev and production deployment | | CI/CD | GitHub Actions | Automated testing, builds, and releases | @@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment. ### Run tests ```bash -# All tests (1417 tests across 200 test files) +# All tests (1423 tests across 200 test files) # API tests only pnpm --filter @telivityhaip/api test @@ -1188,7 +1188,7 @@ HAIP is built in public and contributions are welcome. pnpm install # Install dependencies pnpm build # Build all workspace packages pnpm dev # Start API in dev mode (hot reload) -pnpm test # Run all tests (1417 tests, 200 files) +pnpm test # Run all tests (1423 tests, 200 files) pnpm lint # ESLint ``` diff --git a/apps/api/src/modules/reservation/dto/add-reservation-guest.dto.ts b/apps/api/src/modules/reservation/dto/add-reservation-guest.dto.ts index cc6d3fb..b890fc0 100644 --- a/apps/api/src/modules/reservation/dto/add-reservation-guest.dto.ts +++ b/apps/api/src/modules/reservation/dto/add-reservation-guest.dto.ts @@ -1,8 +1,16 @@ -import { IsUUID } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID, IsOptional, IsBoolean } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class AddReservationGuestDto { @ApiProperty({ description: 'Guest profile to attach as an accompanying occupant' }) @IsUUID() guestId!: string; + + @ApiPropertyOptional({ + description: + 'Explicit staff override to exceed the room type\'s configured maxOccupancy (e.g. extra bed/crib for a family)', + }) + @IsOptional() + @IsBoolean() + overrideMaxOccupancy?: boolean; } diff --git a/apps/api/src/modules/reservation/dto/move-reservation-guest.dto.ts b/apps/api/src/modules/reservation/dto/move-reservation-guest.dto.ts index f9841f8..339b4fb 100644 --- a/apps/api/src/modules/reservation/dto/move-reservation-guest.dto.ts +++ b/apps/api/src/modules/reservation/dto/move-reservation-guest.dto.ts @@ -17,4 +17,12 @@ export class MoveReservationGuestDto { @IsOptional() @IsBoolean() makePrimary?: boolean; + + @ApiPropertyOptional({ + description: + "Explicit staff override to exceed the target room type's configured maxOccupancy (e.g. extra bed/crib for a family)", + }) + @IsOptional() + @IsBoolean() + overrideMaxOccupancy?: boolean; } diff --git a/apps/api/src/modules/reservation/dto/split-reservation.dto.ts b/apps/api/src/modules/reservation/dto/split-reservation.dto.ts index e9bed83..7f8d789 100644 --- a/apps/api/src/modules/reservation/dto/split-reservation.dto.ts +++ b/apps/api/src/modules/reservation/dto/split-reservation.dto.ts @@ -7,6 +7,7 @@ import { Min, IsString, MaxLength, + IsBoolean, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsMoneyString } from '../../../common/validation/is-money-string.validator'; @@ -58,4 +59,12 @@ export class SplitReservationDto { @IsInt() @Min(0) children?: number; + + @ApiPropertyOptional({ + description: + 'Explicit staff override to exceed the destination room type\'s configured maxOccupancy (e.g. extra bed/crib for a family)', + }) + @IsOptional() + @IsBoolean() + overrideMaxOccupancy?: boolean; } diff --git a/apps/api/src/modules/reservation/reservation-party.service.spec.ts b/apps/api/src/modules/reservation/reservation-party.service.spec.ts index 180583a..0a713a0 100644 --- a/apps/api/src/modules/reservation/reservation-party.service.spec.ts +++ b/apps/api/src/modules/reservation/reservation-party.service.spec.ts @@ -160,6 +160,78 @@ describe('ReservationPartyService', () => { BadRequestException, ); }); + + it('rejects when adding would exceed max occupancy and no override is given', async () => { + mockSelectSequence([ + [sourceReservation], // requireReservation + [{ id: GUEST_NEW, isDnr: false, isDeleted: false }], // guest profile + [], // existing on this res + [], // not on sibling + [ + { + id: 'rg-1', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_PRIMARY, + role: 'primary', + firstName: 'Pat', + lastName: 'Primary', + email: null, + }, + ], // loadOccupants (1 occupant already) + [{ maxOccupancy: 1 }], // room type cap — adding a 2nd named guest exceeds it + ]); + + await expect(svc.addGuest(RES_A, PROPERTY, { guestId: GUEST_NEW })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('skips the max occupancy check when overrideMaxOccupancy is true', async () => { + mockSelectSequence([ + [sourceReservation], // requireReservation + [{ id: GUEST_NEW, isDnr: false, isDeleted: false }], // guest profile + [], // existing on this res + [], // not on sibling + [ + { + id: 'rg-1', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_PRIMARY, + role: 'primary', + firstName: 'Pat', + lastName: 'Primary', + email: null, + }, + ], // loadOccupants (1 occupant already) + // No room type cap lookup should occur — assertWithinMaxOccupancy is skipped entirely. + ]); + + const inserted = { + id: 'rg-new', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_NEW, + role: 'accompanying', + }; + db.insert.mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([inserted]), + }), + }); + db.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }); + + const result = await svc.addGuest(RES_A, PROPERTY, { + guestId: GUEST_NEW, + overrideMaxOccupancy: true, + }); + expect(result.guestId).toBe(GUEST_NEW); + }); }); describe('removeGuest', () => { @@ -295,6 +367,136 @@ describe('ReservationPartyService', () => { }), ).rejects.toBeInstanceOf(BadRequestException); }); + + it('rejects when the destination room type max occupancy is exceeded and no override is given', async () => { + const guestAcc2 = 'guest-a2'; + mockSelectSequence([ + [sourceReservation], // requireReservation + [ + { + id: 'rg-1', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_PRIMARY, + role: 'primary', + firstName: 'Pat', + lastName: 'Primary', + email: null, + }, + { + id: 'rg-2', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_ACC, + role: 'accompanying', + firstName: 'Ann', + lastName: 'Acc', + email: null, + }, + { + id: 'rg-3', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: guestAcc2, + role: 'accompanying', + firstName: 'Ann2', + lastName: 'Acc2', + email: null, + }, + ], // occupants + [{ id: 'rt-002' }], // room type fk + [{ id: 'rp-002' }], // rate plan fk + [{ maxOccupancy: 1 }], // destination room type cap — moving 2 guests exceeds it + ]); + + await expect( + svc.split(RES_A, PROPERTY, { + guestIds: [GUEST_ACC, guestAcc2], + roomTypeId: 'rt-002', + ratePlanId: 'rp-002', + totalAmount: '200.00', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows exceeding the destination max occupancy when overrideMaxOccupancy is true', async () => { + const guestAcc2 = 'guest-a2'; + mockSelectSequence([ + [sourceReservation], // requireReservation + [ + { + id: 'rg-1', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_PRIMARY, + role: 'primary', + firstName: 'Pat', + lastName: 'Primary', + email: null, + }, + { + id: 'rg-2', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_ACC, + role: 'accompanying', + firstName: 'Ann', + lastName: 'Acc', + email: null, + }, + { + id: 'rg-3', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: guestAcc2, + role: 'accompanying', + firstName: 'Ann2', + lastName: 'Acc2', + email: null, + }, + ], // occupants + [{ id: 'rt-002' }], // room type fk + [{ id: 'rp-002' }], // rate plan fk + // No room type cap lookup should occur — assertWithinMaxOccupancy is skipped entirely. + ]); + + const created = { + id: 'res-new', + propertyId: PROPERTY, + bookingId: BOOKING, + guestId: GUEST_ACC, + roomTypeId: 'rt-002', + arrivalDate: '2026-08-01', + departureDate: '2026-08-03', + status: 'confirmed', + }; + + db.transaction.mockImplementation(async (cb: any) => { + const tx = { + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([created]), + }), + }), + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), + }; + return cb(tx); + }); + + const result = await svc.split(RES_A, PROPERTY, { + guestIds: [GUEST_ACC, guestAcc2], + roomTypeId: 'rt-002', + ratePlanId: 'rp-002', + totalAmount: '200.00', + overrideMaxOccupancy: true, + }); + + expect(result.reservation.id).toBe('res-new'); + }); }); describe('moveGuest', () => { @@ -378,6 +580,123 @@ describe('ReservationPartyService', () => { svc.moveGuest(RES_A, PROPERTY, GUEST_ACC, { targetReservationId: RES_B }), ).rejects.toBeInstanceOf(BadRequestException); }); + + it('rejects when the target room type max occupancy is exceeded and no override is given', async () => { + const target = { + ...sourceReservation, + id: RES_B, + guestId: 'guest-other', + adults: 1, + }; + mockSelectSequence([ + [sourceReservation], // source + [target], // target + [ + { + id: 'rg-1', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_PRIMARY, + role: 'primary', + firstName: 'Pat', + lastName: 'Primary', + email: null, + }, + { + id: 'rg-2', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_ACC, + role: 'accompanying', + firstName: 'Ann', + lastName: 'Acc', + email: null, + }, + ], // source occupants + [ + { + id: 'rg-3', + propertyId: PROPERTY, + reservationId: RES_B, + guestId: 'guest-other', + role: 'primary', + firstName: 'Other', + lastName: 'Guest', + email: null, + }, + ], // target occupants (already 1) + [{ maxOccupancy: 1 }], // target cap — moving in a 2nd guest exceeds it + ]); + + await expect( + svc.moveGuest(RES_A, PROPERTY, GUEST_ACC, { targetReservationId: RES_B }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('skips the target max occupancy check when overrideMaxOccupancy is true', async () => { + const target = { + ...sourceReservation, + id: RES_B, + guestId: 'guest-other', + adults: 1, + }; + mockSelectSequence([ + [sourceReservation], // source + [target], // target + [ + { + id: 'rg-1', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_PRIMARY, + role: 'primary', + firstName: 'Pat', + lastName: 'Primary', + email: null, + }, + { + id: 'rg-2', + propertyId: PROPERTY, + reservationId: RES_A, + guestId: GUEST_ACC, + role: 'accompanying', + firstName: 'Ann', + lastName: 'Acc', + email: null, + }, + ], // source occupants + [ + { + id: 'rg-3', + propertyId: PROPERTY, + reservationId: RES_B, + guestId: 'guest-other', + role: 'primary', + firstName: 'Other', + lastName: 'Guest', + email: null, + }, + ], // target occupants + // No room type cap lookup — assertWithinMaxOccupancy is skipped entirely. + ]); + + db.transaction.mockImplementation(async (cb: any) => { + const tx = { + update: vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), + }; + return cb(tx); + }); + + const result = await svc.moveGuest(RES_A, PROPERTY, GUEST_ACC, { + targetReservationId: RES_B, + overrideMaxOccupancy: true, + }); + expect(result.toReservationId).toBe(RES_B); + }); }); describe('listGuests', () => { diff --git a/apps/api/src/modules/reservation/reservation-party.service.ts b/apps/api/src/modules/reservation/reservation-party.service.ts index d9ce940..9b803b0 100644 --- a/apps/api/src/modules/reservation/reservation-party.service.ts +++ b/apps/api/src/modules/reservation/reservation-party.service.ts @@ -116,7 +116,9 @@ export class ReservationPartyService { await this.assertNotOnSibling(reservation.bookingId, propertyId, dto.guestId); const occupants = await this.loadOccupants(reservationId, propertyId); - await this.assertWithinMaxOccupancy(reservation.roomTypeId, propertyId, occupants.length + 1); + if (!dto.overrideMaxOccupancy) { + await this.assertWithinMaxOccupancy(reservation.roomTypeId, propertyId, occupants.length + 1); + } const [row] = await this.db .insert(reservationGuests) @@ -215,6 +217,9 @@ export class ReservationPartyService { await this.assertSamePropertyFk(roomTypes, dto.roomTypeId, propertyId, 'room type'); await this.assertSamePropertyFk(ratePlans, dto.ratePlanId, propertyId, 'rate plan'); + if (!dto.overrideMaxOccupancy) { + await this.assertWithinMaxOccupancy(dto.roomTypeId, propertyId, moving.length); + } await this.ratePlanService.assertSellable( propertyId, dto.ratePlanId, @@ -403,11 +408,13 @@ export class ReservationPartyService { if (targetOccupants.some((o) => o.guestId === guestId)) { throw new ConflictException('Guest is already on the target reservation'); } - await this.assertWithinMaxOccupancy( - target.roomTypeId, - propertyId, - targetOccupants.length + 1, - ); + if (!dto.overrideMaxOccupancy) { + await this.assertWithinMaxOccupancy( + target.roomTypeId, + propertyId, + targetOccupants.length + 1, + ); + } const makePrimary = dto.makePrimary === true || targetOccupants.length === 0; diff --git a/apps/dashboard/src/components/guests/FindGuest.tsx b/apps/dashboard/src/components/guests/FindGuest.tsx index b21822f..ad8da9a 100644 --- a/apps/dashboard/src/components/guests/FindGuest.tsx +++ b/apps/dashboard/src/components/guests/FindGuest.tsx @@ -13,6 +13,10 @@ export interface FindGuestProps { onSelectGuest: (guest: Guest | null) => void; placeholder?: string; label?: string; + /** Guest IDs already picked elsewhere in the same form — hidden from search results. */ + excludeGuestIds?: string[]; + /** Flags this picker as missing a required selection (e.g. after a failed submit). */ + error?: boolean; } export default function FindGuest({ @@ -20,6 +24,8 @@ export default function FindGuest({ onSelectGuest, placeholder, label, + excludeGuestIds, + error, }: FindGuestProps) { const { t } = useTranslation(); const { propertyId } = useProperty(); @@ -65,7 +71,9 @@ export default function FindGuest({ enabled: !!propertyId && dropdownOpen, }); - const guests: Guest[] = data?.data ?? data ?? []; + const guests: Guest[] = (data?.data ?? data ?? []).filter( + (g: Guest) => !excludeGuestIds?.includes(g.id), + ); return (
{t('reservations.addGuestHint')}
{t('frontDesk.room')} 1
@@ -1504,6 +1571,8 @@ export default function FrontDesk() { label={t('reservations.guest')} selectedGuest={wiGuest} onSelectGuest={setWiGuest} + excludeGuestIds={wiSelectedGuestIds} + error={walkInMutation.isError && !wiGuest} />
{t('frontDesk.room')} {idx + 2}
@@ -1584,8 +1669,9 @@ export default function FrontDesk() {