diff --git a/README.md b/README.md index 08fa843..0394814 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ NestJS PostgreSQL Apache 2.0 License -1417 Tests Passing 12 AI Agents +1423 Tests Passing 12 AI Agents

@@ -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 (

@@ -126,7 +134,9 @@ export default function FindGuest({ }} onFocus={() => setDropdownOpen(true)} placeholder={placeholder || t('guests.searchOrRegister')} - className="w-full border border-gray-200 rounded-lg pl-9 pr-24 py-2 text-sm focus:outline-none focus:border-telivity-teal bg-white" + className={`w-full border rounded-lg pl-9 pr-24 py-2 text-sm focus:outline-none focus:border-telivity-teal bg-white ${ + error ? 'border-telivity-orange ring-1 ring-telivity-orange' : 'border-gray-200' + }`} />
)} - { setAddOpen(false); setSelectedGuest(null); }} title={t('reservations.addGuest')}> + { + setAddOpen(false); + setSelectedGuest(null); + setAddOverrideOccupancy(false); + }} + title={t('reservations.addGuest')} + >

{t('reservations.addGuestHint')}

+
+ @@ -1495,8 +1562,8 @@ export default function FrontDesk() { title={t('frontDesk.walkInTitle')} wide > -
-
+
+

{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} />
@@ -1557,7 +1626,11 @@ export default function FrontDesk() { prev.map((r) => (r.roomId === next ? { ...r, roomId: '' } : r)), ); }} - className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm" + className={`w-full border rounded-lg px-3 py-2 text-sm ${ + walkInMutation.isError && !wiRoomId + ? 'border-telivity-orange ring-1 ring-telivity-orange' + : 'border-gray-200' + }`} > {walkInRooms @@ -1570,13 +1643,25 @@ export default function FrontDesk() {
+
{wiExtraRooms.map((extra, idx) => { const plans = plansForRoomType(extra.roomTypeId); const rooms = roomsForExtra(extra); return ( -
+

{t('frontDesk.room')} {idx + 2} @@ -1584,8 +1669,9 @@ export default function FrontDesk() {

@@ -1593,6 +1679,8 @@ export default function FrontDesk() { label={t('frontDesk.partyGuest', { room: idx + 2 })} selectedGuest={extra.guest} onSelectGuest={(guest) => updateExtraRoom(extra.key, { guest })} + excludeGuestIds={wiSelectedGuestIds} + error={walkInMutation.isError && !extra.guest} />
@@ -1642,7 +1730,11 @@ export default function FrontDesk() {
+ updateExtraRoom(extra.key, { overrideOccupancy: checked })} + onChange={(guests) => updateExtraRoom(extra.key, { additionalGuests: guests })} + excludeGuestIds={wiSelectedGuestIds} + t={t} + />
); })} @@ -1666,8 +1767,9 @@ export default function FrontDesk() { emptyWalkInExtraRoom({ roomTypeId: wiRoomTypeId, ratePlanId: wiRatePlanId }), ]) } - className="text-sm font-semibold text-telivity-teal hover:underline" + className="w-full flex items-center justify-center gap-1.5 border-2 border-dashed border-telivity-teal/40 text-telivity-teal rounded-xl py-2.5 text-sm font-semibold hover:bg-telivity-teal/5 hover:border-telivity-teal/60 transition-colors" > + {t('frontDesk.addAnotherRoom')} ) : ( @@ -1860,3 +1962,74 @@ function formatLabel(s: string, t: (key: string, options?: Record c.toUpperCase()), }); } + +/** Pickers for extra occupants sharing one room (up to the room type's max occupancy). */ +function AdditionalGuestsSection({ + guests, + max, + overrideChecked, + onOverrideChange, + onChange, + excludeGuestIds, + t, +}: { + guests: Guest[]; + max?: number; + overrideChecked: boolean; + onOverrideChange: (checked: boolean) => void; + onChange: (guests: Guest[]) => void; + excludeGuestIds?: string[]; + t: (key: string, options?: Record) => string; +}) { + const currentCount = 1 + guests.length; + const overCapacity = max != null && currentCount > max; + return ( +
+
+ + {max != null && ( + + {currentCount}/{max} + + )} +
+ {guests.map((guest, i) => ( + + // Clearing a picker (next === null) drops it from the list — no separate remove button. + onChange( + next + ? guests.map((g, idx) => (idx === i ? next : g)) + : guests.filter((_, idx) => idx !== i), + ) + } + /> + ))} + { + if (guest) onChange([...guests, guest]); + }} + /> + {overCapacity && ( + + )} +
+ ); +} diff --git a/docs/test-stats.json b/docs/test-stats.json index 227ea5a..2abaecb 100644 --- a/docs/test-stats.json +++ b/docs/test-stats.json @@ -1,5 +1,5 @@ { - "tests": 1417, + "tests": 1423, "files": 200, - "updatedAt": "2026-08-03T00:56:38.269Z" + "updatedAt": "2026-08-07T06:47:12.381Z" }