From bf3599ac011dd424927ce9893d16d42292642ce4 Mon Sep 17 00:00:00 2001 From: raedaltawil19-cmyk Date: Sun, 6 Sep 2026 15:22:55 +0200 Subject: [PATCH 1/3] fix(connect): serialize agent bookings on inventory --- .../connect/connect-booking.service.ts | 92 ++++++++++++------- 1 file changed, 59 insertions(+), 33 deletions(-) diff --git a/apps/api/src/modules/connect/connect-booking.service.ts b/apps/api/src/modules/connect/connect-booking.service.ts index 5e67470a..3ca8532f 100644 --- a/apps/api/src/modules/connect/connect-booking.service.ts +++ b/apps/api/src/modules/connect/connect-booking.service.ts @@ -3,7 +3,10 @@ import { eq, and, ne } from 'drizzle-orm'; import Decimal from 'decimal.js'; import { bookings, reservations, guests, ratePlans, roomTypes, folios, rooms } from '@telivityhaip/database'; import { DRIZZLE } from '../../database/database.module'; -import { AvailabilityService } from '../reservation/availability.service'; +import { + assertFullStayAvailability, + AvailabilityService, +} from '../reservation/availability.service'; import { ReservationService } from '../reservation/reservation.service'; import { WebhookService } from '../webhook/webhook.service'; import { RatePlanService } from '../rate-plan/rate-plan.service'; @@ -82,39 +85,62 @@ export class ConnectBookingService { // the confirmation number is itself a bearer credential for the booking. const confirmationNumber = `HAIP-${generateConfirmationToken()}`; - // 6. Create booking - const [booking] = await this.db - .insert(bookings) - .values({ - propertyId: dto.propertyId, - guestId: guest.id, - confirmationNumber, - externalConfirmation: dto.externalReference, - source: 'agent', - channelCode: dto.agentId ?? 'otaip', - }) - .returning(); + // 6-7. Create the booking and auto-confirmed reservation atomically under + // the same room-type mutex used by ReservationService.create/modify. The + // early availability check above is only a fast rejection; this locked + // re-check is the authoritative guard against two agents consuming the + // final room concurrently under READ COMMITTED. + const reservation = await this.db.transaction(async (tx: any) => { + await this.reservationService.lockInventory(dto.propertyId, dto.roomTypeId, tx); + + const lockedAvailability = await this.availabilityService.searchAvailability( + dto.propertyId, + dto.checkIn, + dto.checkOut, + dto.roomTypeId, + tx, + ); + assertFullStayAvailability( + lockedAvailability, + dto.roomTypeId, + dto.checkIn, + dto.checkOut, + ); - // 7. Create reservation — auto-confirm for agent bookings - const [reservation] = await this.db - .insert(reservations) - .values({ - propertyId: dto.propertyId, - bookingId: booking.id, - guestId: guest.id, - arrivalDate: dto.checkIn, - departureDate: dto.checkOut, - nights, - roomTypeId: dto.roomTypeId, - ratePlanId: dto.ratePlanId, - totalAmount: totalAmountDec.toFixed(2), - currencyCode: ratePlan.currencyCode, - adults: dto.adults, - children: dto.children ?? 0, - specialRequests: dto.specialRequests, - status: 'confirmed', // Agent bookings skip pending - }) - .returning(); + const [booking] = await tx + .insert(bookings) + .values({ + propertyId: dto.propertyId, + guestId: guest.id, + confirmationNumber, + externalConfirmation: dto.externalReference, + source: 'agent', + channelCode: dto.agentId ?? 'otaip', + }) + .returning(); + + const [createdReservation] = await tx + .insert(reservations) + .values({ + propertyId: dto.propertyId, + bookingId: booking.id, + guestId: guest.id, + arrivalDate: dto.checkIn, + departureDate: dto.checkOut, + nights, + roomTypeId: dto.roomTypeId, + ratePlanId: dto.ratePlanId, + totalAmount: totalAmountDec.toFixed(2), + currencyCode: ratePlan.currencyCode, + adults: dto.adults, + children: dto.children ?? 0, + specialRequests: dto.specialRequests, + status: 'confirmed', // Agent bookings skip pending + }) + .returning(); + + return createdReservation; + }); // 8. Build nightly breakdown const settings = await this.getPropertySettings(dto.propertyId); From a45d27e9463ef27f4f53981f07732334e8e1fdf1 Mon Sep 17 00:00:00 2001 From: raedaltawil19-cmyk Date: Sun, 6 Sep 2026 15:23:04 +0200 Subject: [PATCH 2/3] test(connect): cover locked availability recheck --- .../connect/connect-booking.service.spec.ts | 73 ++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/apps/api/src/modules/connect/connect-booking.service.spec.ts b/apps/api/src/modules/connect/connect-booking.service.spec.ts index 1a2c0997..124be329 100644 --- a/apps/api/src/modules/connect/connect-booking.service.spec.ts +++ b/apps/api/src/modules/connect/connect-booking.service.spec.ts @@ -7,6 +7,7 @@ describe('ConnectBookingService', () => { let mockDb: any; let mockAvailabilityService: any; let mockWebhookService: any; + let mockReservationService: any; const mockRatePlan = { id: 'rp-1', @@ -20,6 +21,7 @@ describe('ConnectBookingService', () => { beforeEach(() => { let insertCallCount = 0; mockDb = { + transaction: vi.fn().mockImplementation(async (callback) => callback(mockDb)), select: vi.fn().mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]), @@ -54,7 +56,8 @@ describe('ConnectBookingService', () => { mockWebhookService = { emit: vi.fn().mockResolvedValue(undefined) }; const mockRatePlanService = { assertSellable: vi.fn().mockResolvedValue(undefined) }; - const mockReservationService = { + mockReservationService = { + lockInventory: vi.fn().mockResolvedValue(undefined), cancel: vi.fn().mockResolvedValue({ id: 'res-1', status: 'cancelled', @@ -121,6 +124,74 @@ describe('ConnectBookingService', () => { expect(result.nightlyBreakdown).toHaveLength(2); }); + it('should lock inventory and re-check availability inside the booking transaction', async () => { + let selectCallCount = 0; + mockDb.select.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockImplementation(() => { + selectCallCount++; + if (selectCallCount === 1) return Promise.resolve([mockRatePlan]); + if (selectCallCount === 2) return Promise.resolve([]); + if (selectCallCount === 3) return Promise.resolve([{ settings: {} }]); + return Promise.resolve([]); + }), + }), + })); + + await service.book({ + propertyId: 'prop-1', + roomTypeId: 'rt-1', + ratePlanId: 'rp-1', + checkIn: '2024-06-01', + checkOut: '2024-06-03', + guestFirstName: 'John', + guestLastName: 'Smith', + adults: 2, + }); + + expect(mockDb.transaction).toHaveBeenCalledOnce(); + expect(mockReservationService.lockInventory).toHaveBeenCalledWith('prop-1', 'rt-1', mockDb); + expect(mockAvailabilityService.searchAvailability).toHaveBeenLastCalledWith( + 'prop-1', + '2024-06-01', + '2024-06-03', + 'rt-1', + mockDb, + ); + }); + + it('should reject when locked availability is consumed after the early check', async () => { + mockAvailabilityService.searchAvailability + .mockResolvedValueOnce([ + { roomTypeId: 'rt-1', date: '2024-06-01', totalRooms: 1, sold: 0, available: 1, overbookingBuffer: 0 }, + ]) + .mockResolvedValueOnce([ + { roomTypeId: 'rt-1', date: '2024-06-01', totalRooms: 1, sold: 1, available: 0, overbookingBuffer: 0 }, + ]); + mockDb.select.mockImplementation(() => ({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValueOnce([mockRatePlan]), + }), + })); + + await expect(service.book({ + propertyId: 'prop-1', + roomTypeId: 'rt-1', + ratePlanId: 'rp-1', + checkIn: '2024-06-01', + checkOut: '2024-06-02', + guestFirstName: 'Jane', + guestLastName: 'Doe', + adults: 1, + })).rejects.toThrow(BadRequestException); + + expect(mockDb.transaction).toHaveBeenCalledOnce(); + // The guest may be created before inventory contention is resolved, but + // neither a booking nor a reservation is inserted after the locked + // availability check fails. + expect(mockDb.insert).toHaveBeenCalledTimes(1); + }); + it('should reuse existing guest matched by email', async () => { let selectCallCount = 0; const existingGuest = { id: 'guest-existing', firstName: 'John', lastName: 'Smith', email: 'john@example.com' }; From 8c11e17e6d9f70fdb20e7aa2bfa7b72f1733b562 Mon Sep 17 00:00:00 2001 From: raedaltawil19-cmyk Date: Sun, 6 Sep 2026 16:04:10 +0200 Subject: [PATCH 3/3] fix(hardening): close production readiness gaps - make deposit authorization failures explicit and staff-visible - alert on channel sync failures without notification storms - add Prometheus metrics, Grafana dashboard, and alert rules - enforce production Keycloak and secret configuration - add owner isolation probes and guest property checks - route Connect modifications through locked canonical inventory checks --- .env.production.example | 15 +- README.md | 2 +- apps/api/src/app.module.ts | 2 + .../modules/channel/channel.service.spec.ts | 61 ++++++++ .../src/modules/channel/channel.service.ts | 38 ++++- .../connect/connect-booking.service.spec.ts | 34 ++++- .../connect/connect-booking.service.ts | 83 +++++------ .../src/modules/metrics/metrics.controller.ts | 19 +++ .../modules/metrics/metrics.interceptor.ts | 49 ++++++ .../api/src/modules/metrics/metrics.module.ts | 15 ++ .../modules/metrics/metrics.service.spec.ts | 31 ++++ .../src/modules/metrics/metrics.service.ts | 141 ++++++++++++++++++ .../src/modules/reservation/check-in.spec.ts | 36 ++++- .../reservation-assert-sellable.spec.ts | 45 +++++- .../reservation-fk-ownership.spec.ts | 37 ++++- .../reservation/reservation.service.ts | 87 +++++++++-- .../staff-notification.listener.spec.ts | 70 +++++++++ .../staff-notification.listener.ts | 44 ++++++ apps/dashboard/src/locales/en.json | 1 + apps/dashboard/src/pages/FrontDesk.tsx | 12 +- docker-compose.prod.yml | 46 ++++-- docs/deployment.md | 19 ++- docs/observability.md | 45 ++++++ ops/harden/.env.harden.example | 15 ++ ops/harden/CHECKLIST.md | 4 +- ops/harden/README.md | 2 +- ops/harden/TENANT_ISOLATION.md | 11 +- ops/harden/cli/harden.mjs | 3 + ops/harden/cli/lib.mjs | 10 +- ops/harden/cli/probes/local.mjs | 28 ++++ ops/harden/cli/probes/owner-isolation.mjs | 108 ++++++++++++++ ops/harden/grafana-haip-overview.json | 65 ++++++++ ops/harden/prometheus-alerts.yml | 41 +++++ .../base-21-deposit-and-channel-alerts.md | 22 +++ 34 files changed, 1143 insertions(+), 98 deletions(-) create mode 100644 apps/api/src/modules/metrics/metrics.controller.ts create mode 100644 apps/api/src/modules/metrics/metrics.interceptor.ts create mode 100644 apps/api/src/modules/metrics/metrics.module.ts create mode 100644 apps/api/src/modules/metrics/metrics.service.spec.ts create mode 100644 apps/api/src/modules/metrics/metrics.service.ts create mode 100644 docs/observability.md create mode 100644 ops/harden/cli/probes/owner-isolation.mjs create mode 100644 ops/harden/grafana-haip-overview.json create mode 100644 ops/harden/prometheus-alerts.yml create mode 100644 ops/harden/vignettes/base-21-deposit-and-channel-alerts.md diff --git a/.env.production.example b/.env.production.example index d9d6000e..a18f0622 100644 --- a/.env.production.example +++ b/.env.production.example @@ -7,8 +7,10 @@ # Used by: docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up # ── Database (REQUIRED) ─────────────────────────────────────────────────────── -# Default matches docker-compose postgres service (user/password/db: haip). -DATABASE_URL=postgresql://haip:haip@postgres:5432/haip +# Use a unique generated password; the compose interpolation uses the same value +# for PostgreSQL, Keycloak's database connection, and this application URL. +POSTGRES_PASSWORD=REPLACE_WITH_A_LONG_RANDOM_PASSWORD +DATABASE_URL=postgresql://haip:REPLACE_WITH_A_LONG_RANDOM_PASSWORD@postgres:5432/haip # Set to `transaction` when connecting through a transaction-pooling pooler # (pgbouncer, RDS Proxy, Supabase). Disables named prepared statements, which # cannot work when each query may land on a different backend connection. @@ -43,6 +45,15 @@ AUTH_ENABLED=true KEYCLOAK_URL=http://keycloak:8080 KEYCLOAK_REALM=haip KEYCLOAK_CLIENT_ID=haip-api +# Public HTTPS origin baked into the dashboard at image build time and used by +# Keycloak for issuer/redirect URLs. Never use localhost on a remote deployment. +KEYCLOAK_PUBLIC_URL=https://auth.example.com +VITE_KEYCLOAK_CLIENT_ID=haip-dashboard +# Required bootstrap credentials; rotate/store them in a secret manager after setup. +KEYCLOAK_ADMIN=REPLACE_WITH_ADMIN_USERNAME +KEYCLOAK_ADMIN_PASSWORD=REPLACE_WITH_A_LONG_RANDOM_PASSWORD +# Reverse proxy must overwrite X-Forwarded-* headers before forwarding. +KEYCLOAK_PROXY_HEADERS=xforwarded # KEYCLOAK_AUDIENCE=haip-api # Connect API (OTAIP agents) — comma-separated API keys (REQUIRED when AUTH_ENABLED=true). diff --git a/README.md b/README.md index a485b78a..e2bfdf5b 100644 --- a/README.md +++ b/README.md @@ -574,7 +574,7 @@ Quick start: cp .env.production.example .env.production # Edit .env.production — set DATABASE_URL, Stripe keys, Keycloak, CONNECT_API_KEY, etc. -docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build +docker compose --env-file .env.production -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build ``` Auth is on (`AUTH_ENABLED=true`); do not set `HAIP_ALLOW_INSECURE`. diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index bc87c17e..4a25db79 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -53,6 +53,7 @@ import { LoyaltyModule } from './modules/loyalty/loyalty.module'; import { IntegrationsModule } from './modules/integrations/integrations.module'; import { IcalModule } from './modules/ical/ical.module'; import { FiscalModule } from './modules/fiscal/fiscal.module'; +import { MetricsModule } from './modules/metrics/metrics.module'; import { bookingRequestsModules } from './booking-requests.bootstrap'; const imports: any[] = [ @@ -114,6 +115,7 @@ const imports: any[] = [ IntegrationsModule, IcalModule, FiscalModule, + MetricsModule, ]; // Serve the bundled dashboard as static files. Enabled in production, or diff --git a/apps/api/src/modules/channel/channel.service.spec.ts b/apps/api/src/modules/channel/channel.service.spec.ts index 369c4501..2667f2d4 100644 --- a/apps/api/src/modules/channel/channel.service.spec.ts +++ b/apps/api/src/modules/channel/channel.service.spec.ts @@ -161,6 +161,11 @@ describe('ChannelService', () => { describe('updateSyncStatus', () => { it('should update sync status fields', async () => { + mockDb.where.mockResolvedValueOnce([{ + id: 'conn-1', + propertyId: 'prop-1', + lastSyncStatus: 'success', + }]); const updateChain = { where: vi.fn().mockResolvedValue(undefined) }; mockDb.chain.set.mockReturnValue(updateChain); @@ -170,6 +175,14 @@ describe('ChannelService', () => { }); it('should include error message when provided', async () => { + mockDb.where.mockResolvedValueOnce([{ + id: 'conn-1', + propertyId: 'prop-1', + channelCode: 'booking_com', + channelName: 'Booking.com', + adapterType: 'booking_com', + lastSyncStatus: 'success', + }]); const updateChain = { where: vi.fn().mockResolvedValue(undefined) }; mockDb.chain.set.mockReturnValue(updateChain); @@ -178,6 +191,54 @@ describe('ChannelService', () => { expect(mockDb.chain.set).toHaveBeenCalledWith( expect.objectContaining({ lastSyncError: 'Timeout' }), ); + expect(mockWebhookService.emit).toHaveBeenCalledWith( + 'channel.sync_failed', + 'channel_connection', + 'conn-1', + expect.objectContaining({ + connectionId: 'conn-1', + adapterType: 'booking_com', + error: 'Timeout', + }), + 'prop-1', + ); + }); + + it('should not emit another failure event while the connection remains failed', async () => { + mockDb.where.mockResolvedValueOnce([{ + id: 'conn-1', + propertyId: 'prop-1', + lastSyncStatus: 'failed', + }]); + const updateChain = { where: vi.fn().mockResolvedValue(undefined) }; + mockDb.chain.set.mockReturnValue(updateChain); + + await service.updateSyncStatus('conn-1', 'prop-1', 'failed', 'Still timing out'); + + expect(mockWebhookService.emit).not.toHaveBeenCalled(); + }); + + it('should emit a recovery event after a failed connection succeeds', async () => { + mockDb.where.mockResolvedValueOnce([{ + id: 'conn-1', + propertyId: 'prop-1', + channelCode: 'booking_com', + channelName: 'Booking.com', + adapterType: 'booking_com', + lastSyncStatus: 'failed', + }]); + const updateChain = { where: vi.fn().mockResolvedValue(undefined) }; + mockDb.chain.set.mockReturnValue(updateChain); + + await service.updateSyncStatus('conn-1', 'prop-1', 'success'); + + expect(mockWebhookService.emit).toHaveBeenCalledWith( + 'channel.sync_completed', + 'channel_connection', + 'conn-1', + expect.objectContaining({ recoveredFromFailure: true }), + 'prop-1', + ); }); }); }); diff --git a/apps/api/src/modules/channel/channel.service.ts b/apps/api/src/modules/channel/channel.service.ts index f0a49d7b..0739e581 100644 --- a/apps/api/src/modules/channel/channel.service.ts +++ b/apps/api/src/modules/channel/channel.service.ts @@ -170,6 +170,10 @@ export class ChannelService { status: string, error?: string, ) { + const connection = await this.findById(id, propertyId); + const previousStatus = connection.lastSyncStatus as string | null | undefined; + const safeError = error?.slice(0, 500); + // propertyId is part of the WHERE (not just the caller's responsibility): every // property-scoped write must filter by propertyId so this stays safe even if a // future caller passes a client-supplied connection id. @@ -178,10 +182,42 @@ export class ChannelService { .set({ lastSyncAt: new Date(), lastSyncStatus: status, - lastSyncError: error ?? null, + lastSyncError: safeError ?? null, updatedAt: new Date(), }) .where(and(eq(channelConnections.id, id), eq(channelConnections.propertyId, propertyId))); + + // Emit only on state transitions. Retries that keep a connection in + // `failed` update diagnostics but do not create notification storms. + if (status === 'failed' && previousStatus !== 'failed') { + await this.webhookService.emit( + 'channel.sync_failed', + 'channel_connection', + id, + { + connectionId: id, + channelCode: connection.channelCode, + channelName: connection.channelName, + adapterType: connection.adapterType, + error: safeError ?? 'Channel sync failed without an adapter error message', + }, + propertyId, + ); + } else if (status === 'success') { + await this.webhookService.emit( + 'channel.sync_completed', + 'channel_connection', + id, + { + connectionId: id, + channelCode: connection.channelCode, + channelName: connection.channelName, + adapterType: connection.adapterType, + recoveredFromFailure: previousStatus === 'failed', + }, + propertyId, + ); + } } /** diff --git a/apps/api/src/modules/connect/connect-booking.service.spec.ts b/apps/api/src/modules/connect/connect-booking.service.spec.ts index 124be329..4d16c765 100644 --- a/apps/api/src/modules/connect/connect-booking.service.spec.ts +++ b/apps/api/src/modules/connect/connect-booking.service.spec.ts @@ -58,6 +58,16 @@ describe('ConnectBookingService', () => { const mockRatePlanService = { assertSellable: vi.fn().mockResolvedValue(undefined) }; mockReservationService = { lockInventory: vi.fn().mockResolvedValue(undefined), + modify: vi.fn().mockImplementation(async (_id, propertyId, dto, internal) => ({ + reservation: { + id: 'res-1', + propertyId, + status: 'confirmed', + totalAmount: dto.totalAmount ?? '399.98', + currencyCode: internal?.currencyCode ?? 'USD', + updatedAt: new Date(), + }, + })), cancel: vi.fn().mockResolvedValue({ id: 'res-1', status: 'cancelled', @@ -122,6 +132,7 @@ describe('ConnectBookingService', () => { expect(result.confirmationNumber).toBeDefined(); expect(result.confirmationCodes.external).toBe('OTAIP-123'); expect(result.nightlyBreakdown).toHaveLength(2); + expect(mockDb.insert).toHaveBeenCalledTimes(4); // guest + booking + reservation + roster }); it('should lock inventory and re-check availability inside the booking transaction', async () => { @@ -234,7 +245,8 @@ describe('ConnectBookingService', () => { }); expect(result.success).toBe(true); - // Only 2 inserts (booking + reservation), not 3 (guest skipped) + // Only booking + reservation use returning(); the roster insert is also + // issued, while a new guest insert is skipped. expect(insertCount).toBe(2); }); @@ -280,8 +292,8 @@ describe('ConnectBookingService', () => { }); expect(result.success).toBe(true); - // A fresh guest row is created (guest + booking + reservation = 3 inserts), - // NOT linked to the foreign-property guest. + // A fresh guest row is created (three returning inserts); the roster insert + // is issued separately and the foreign-property guest is never reused. expect(insertCount).toBe(3); }); @@ -485,7 +497,7 @@ describe('ConnectBookingService', () => { expect(result.costDifference).toBe(0); }); - it('should re-check availability for date changes', async () => { + it('should delegate date changes to the locked canonical modification path', async () => { let selectCallCount = 0; mockDb.select.mockImplementation(() => ({ from: vi.fn().mockReturnValue({ @@ -509,7 +521,19 @@ describe('ConnectBookingService', () => { }); expect(result.success).toBe(true); - expect(mockAvailabilityService.searchAvailability).toHaveBeenCalled(); + expect(mockReservationService.modify).toHaveBeenCalledWith( + 'res-1', + 'prop-1', + expect.objectContaining({ + arrivalDate: '2024-06-01', + departureDate: '2024-06-04', + roomTypeId: 'rt-1', + ratePlanId: 'rp-1', + totalAmount: '599.97', + }), + { currencyCode: 'USD' }, + ); + expect(mockAvailabilityService.searchAvailability).not.toHaveBeenCalled(); }); it('forks a property-local guest on name change when the guest is shared with another property', async () => { diff --git a/apps/api/src/modules/connect/connect-booking.service.ts b/apps/api/src/modules/connect/connect-booking.service.ts index 3ca8532f..dd098554 100644 --- a/apps/api/src/modules/connect/connect-booking.service.ts +++ b/apps/api/src/modules/connect/connect-booking.service.ts @@ -1,7 +1,16 @@ import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common'; import { eq, and, ne } from 'drizzle-orm'; import Decimal from 'decimal.js'; -import { bookings, reservations, guests, ratePlans, roomTypes, folios, rooms } from '@telivityhaip/database'; +import { + bookings, + reservations, + reservationGuests, + guests, + ratePlans, + roomTypes, + folios, + rooms, +} from '@telivityhaip/database'; import { DRIZZLE } from '../../database/database.module'; import { assertFullStayAvailability, @@ -139,6 +148,13 @@ export class ConnectBookingService { }) .returning(); + await tx.insert(reservationGuests).values({ + propertyId: dto.propertyId, + reservationId: createdReservation.id, + guestId: guest.id, + role: 'primary', + }); + return createdReservation; }); @@ -274,8 +290,6 @@ export class ConnectBookingService { throw new BadRequestException(`Cannot modify reservation in ${reservation.status} status`); } - const updateFields: Record = { updatedAt: new Date() }; - let costDifferenceDec = new Decimal(0); const previousAmountDec = new Decimal(reservation.totalAmount); const previousAmount = previousAmountDec.toNumber(); @@ -332,10 +346,11 @@ export class ConnectBookingService { } } - // Handle simple field updates - if (dto.specialRequests !== undefined) updateFields['specialRequests'] = dto.specialRequests; - if (dto.adults !== undefined) updateFields['adults'] = dto.adults; - if (dto.children !== undefined) updateFields['children'] = dto.children; + const reservationChanges: Record = {}; + if (dto.specialRequests !== undefined) reservationChanges['specialRequests'] = dto.specialRequests; + if (dto.adults !== undefined) reservationChanges['adults'] = dto.adults; + if (dto.children !== undefined) reservationChanges['children'] = dto.children; + let currencyCode: string | undefined; // Handle date/room/rate changes (triggers re-calculation) if (dto.checkIn || dto.checkOut || dto.roomTypeId || dto.ratePlanId) { @@ -356,22 +371,6 @@ export class ConnectBookingService { if (!rt) throw new BadRequestException(`room type ${newRoomTypeId} not found in this property`); } - // Re-check availability - const availability = await this.availabilityService.searchAvailability( - booking.propertyId, - newCheckIn, - newCheckOut, - newRoomTypeId, - ); - - const minAvailable = availability.length > 0 - ? Math.min(...availability.map((a) => a.available)) - : 0; - - if (minAvailable <= 0) { - throw new BadRequestException('No availability for modified dates/room type'); - } - // Re-calculate rate — same-property scoped (was bare-id before). const [ratePlan] = await this.db .select() @@ -387,28 +386,26 @@ export class ConnectBookingService { const nights = Math.ceil((departure.getTime() - arrival.getTime()) / (1000 * 60 * 60 * 24)); const newTotalDec = new Decimal(ratePlan.baseAmount).times(nights); - updateFields['arrivalDate'] = newCheckIn; - updateFields['departureDate'] = newCheckOut; - updateFields['nights'] = nights; - updateFields['roomTypeId'] = newRoomTypeId; - updateFields['ratePlanId'] = newRatePlanId; - updateFields['totalAmount'] = newTotalDec.toFixed(2); - updateFields['currencyCode'] = ratePlan.currencyCode; - - costDifferenceDec = newTotalDec.minus(previousAmountDec); + reservationChanges['arrivalDate'] = newCheckIn; + reservationChanges['departureDate'] = newCheckOut; + reservationChanges['roomTypeId'] = newRoomTypeId; + reservationChanges['ratePlanId'] = newRatePlanId; + reservationChanges['totalAmount'] = newTotalDec.toFixed(2); + currencyCode = ratePlan.currencyCode; } - // Apply update - const [updated] = await this.db - .update(reservations) - .set(updateFields) - .where( - and( - eq(reservations.id, reservation.id), - eq(reservations.propertyId, booking.propertyId), - ), - ) - .returning(); + // Use the canonical reservation mutation path. It owns the inventory lock, + // full-stay availability check, rate restriction check, tenant scoping, and + // accepted-pricing safeguards, so Connect modifications cannot race a + // dashboard/API modification for the final room. + const amendment = await this.reservationService.modify( + reservation.id, + booking.propertyId, + reservationChanges, + currencyCode === undefined ? undefined : { currencyCode }, + ); + const updated = amendment.reservation; + const costDifferenceDec = new Decimal(updated.totalAmount).minus(previousAmountDec); // Emit webhook await this.webhookService.emit( diff --git a/apps/api/src/modules/metrics/metrics.controller.ts b/apps/api/src/modules/metrics/metrics.controller.ts new file mode 100644 index 00000000..85caba02 --- /dev/null +++ b/apps/api/src/modules/metrics/metrics.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get, Header } from '@nestjs/common'; +import { ApiOperation, ApiProduces, ApiTags } from '@nestjs/swagger'; +import { Public } from '../auth/public.decorator'; +import { MetricsService } from './metrics.service'; + +@ApiTags('metrics') +@Controller('metrics') +export class MetricsController { + constructor(private readonly metrics: MetricsService) {} + + @Public() + @Get() + @Header('Content-Type', 'text/plain; version=0.0.4; charset=utf-8') + @ApiProduces('text/plain') + @ApiOperation({ summary: 'Prometheus metrics (restrict to monitoring network at reverse proxy)' }) + scrape() { + return this.metrics.render(); + } +} diff --git a/apps/api/src/modules/metrics/metrics.interceptor.ts b/apps/api/src/modules/metrics/metrics.interceptor.ts new file mode 100644 index 00000000..8af76006 --- /dev/null +++ b/apps/api/src/modules/metrics/metrics.interceptor.ts @@ -0,0 +1,49 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import type { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { MetricsService } from './metrics.service'; + +@Injectable() +export class MetricsInterceptor implements NestInterceptor { + constructor(private readonly metrics: MetricsService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType() !== 'http') return next.handle(); + + const http = context.switchToHttp(); + const request = http.getRequest<{ + method?: string; + baseUrl?: string; + route?: { path?: string }; + }>(); + const response = http.getResponse<{ statusCode?: number }>(); + const method = request.method ?? 'UNKNOWN'; + // baseUrl + route.path are router templates (e.g. /reservations/:id), not + // raw URLs. This prevents guest ids, confirmation numbers, and property ids + // from becoming unbounded Prometheus labels. + const route = `${request.baseUrl ?? ''}${request.route?.path ?? ''}` || 'unmatched'; + const started = process.hrtime.bigint(); + let recorded = false; + const record = (statusCode: number) => { + if (recorded) return; + recorded = true; + const durationSeconds = Number(process.hrtime.bigint() - started) / 1_000_000_000; + this.metrics.observeHttp(method, route, statusCode, durationSeconds); + }; + + return next.handle().pipe(tap({ + next: () => record(response.statusCode ?? 200), + error: (error: unknown) => { + const status = typeof (error as { getStatus?: unknown })?.getStatus === 'function' + ? (error as { getStatus: () => number }).getStatus() + : 500; + record(status); + }, + })); + } +} diff --git a/apps/api/src/modules/metrics/metrics.module.ts b/apps/api/src/modules/metrics/metrics.module.ts new file mode 100644 index 00000000..ecb34f66 --- /dev/null +++ b/apps/api/src/modules/metrics/metrics.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { MetricsController } from './metrics.controller'; +import { MetricsInterceptor } from './metrics.interceptor'; +import { MetricsService } from './metrics.service'; + +@Module({ + controllers: [MetricsController], + providers: [ + MetricsService, + { provide: APP_INTERCEPTOR, useClass: MetricsInterceptor }, + ], + exports: [MetricsService], +}) +export class MetricsModule {} diff --git a/apps/api/src/modules/metrics/metrics.service.spec.ts b/apps/api/src/modules/metrics/metrics.service.spec.ts new file mode 100644 index 00000000..52f8fa1a --- /dev/null +++ b/apps/api/src/modules/metrics/metrics.service.spec.ts @@ -0,0 +1,31 @@ +import { IS_PUBLIC_KEY } from '../auth/public.decorator'; +import { MetricsController } from './metrics.controller'; +import { MetricsService } from './metrics.service'; + +describe('MetricsService', () => { + it('renders Prometheus HTTP, booking, channel, audit, and webhook signals', () => { + const metrics = new MetricsService(); + metrics.observeHttp('POST', '/api/v1/booking-engine/book', 201, 0.25); + metrics.observeHttp('POST', '/api/v1/connect/book', 500, 0.5); + metrics.onChannelSyncCompleted({} as any); + metrics.onChannelSyncFailed({} as any); + metrics.onAuditCompleted({ data: { businessDate: '2026-09-06', errors: [] } } as any); + metrics.onWebhookDeliveryFailed({} as any); + + const output = metrics.render(); + expect(output).toContain('haip_http_requests_total'); + expect(output).toContain('haip_booking_create_total{outcome="success"} 1'); + expect(output).toContain('haip_booking_create_total{outcome="failed"} 1'); + expect(output).toContain('haip_channel_sync_total{outcome="success"} 1'); + expect(output).toContain('haip_channel_sync_total{outcome="failed"} 1'); + expect(output).toContain('haip_night_audit_runs_total{outcome="success"} 1'); + expect(output).toContain('haip_webhook_delivery_failures_total 1'); + expect(output).not.toContain('property_id='); + }); + + it('mounts the scrape action as an explicitly public endpoint', () => { + expect(Reflect.getMetadata(IS_PUBLIC_KEY, MetricsController.prototype.scrape)).toBe(true); + const controller = new MetricsController(new MetricsService()); + expect(controller.scrape()).toContain('# TYPE haip_http_requests_total counter'); + }); +}); diff --git a/apps/api/src/modules/metrics/metrics.service.ts b/apps/api/src/modules/metrics/metrics.service.ts new file mode 100644 index 00000000..e6424766 --- /dev/null +++ b/apps/api/src/modules/metrics/metrics.service.ts @@ -0,0 +1,141 @@ +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import type { WebhookPayload } from '../webhook/webhook.service'; +import { + WEBHOOK_DELIVERY_FAILED, + type WebhookDeliveryFailedEvent, +} from '../webhook/webhook-delivery.service'; + +type HttpMetric = { + count: number; + errors: number; + durationSeconds: number; +}; + +function escapeLabel(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); +} + +@Injectable() +export class MetricsService { + private readonly http = new Map(); + private readonly bookingCreates = { success: 0, failed: 0 }; + private readonly channelSyncs = { success: 0, failed: 0 }; + private readonly nightAudits = { success: 0, failed: 0 }; + private webhookDeliveryFailures = 0; + private lastAuditBusinessDate = 0; + + observeHttp(method: string, route: string, statusCode: number, durationSeconds: number) { + const normalizedMethod = method.toUpperCase(); + const normalizedRoute = route || 'unmatched'; + const key = `${normalizedMethod}\u0000${normalizedRoute}\u0000${statusCode}`; + const current = this.http.get(key) ?? { count: 0, errors: 0, durationSeconds: 0 }; + current.count += 1; + current.errors += statusCode >= 500 ? 1 : 0; + current.durationSeconds += Math.max(0, durationSeconds); + this.http.set(key, current); + + if ( + normalizedMethod === 'POST' + && /\/(booking-engine\/book|connect\/book|reservations)\/?$/.test(normalizedRoute) + ) { + this.bookingCreates[statusCode >= 200 && statusCode < 400 ? 'success' : 'failed'] += 1; + } + } + + @OnEvent('channel.sync_failed') + onChannelSyncFailed(_payload: WebhookPayload) { + this.channelSyncs.failed += 1; + } + + @OnEvent('channel.sync_completed') + onChannelSyncCompleted(_payload: WebhookPayload) { + this.channelSyncs.success += 1; + } + + @OnEvent('audit.completed') + onAuditCompleted(payload: WebhookPayload) { + const errors = Array.isArray(payload.data?.['errors']) ? payload.data['errors'] : []; + this.nightAudits[errors.length > 0 ? 'failed' : 'success'] += 1; + const businessDate = String(payload.data?.['businessDate'] ?? ''); + const timestamp = Date.parse(`${businessDate}T00:00:00.000Z`); + if (Number.isFinite(timestamp)) this.lastAuditBusinessDate = timestamp / 1000; + } + + @OnEvent(WEBHOOK_DELIVERY_FAILED) + onWebhookDeliveryFailed(_payload: WebhookDeliveryFailedEvent) { + this.webhookDeliveryFailures += 1; + } + + render(): string { + const lines = [ + '# HELP haip_http_requests_total HTTP requests grouped by stable route template and status.', + '# TYPE haip_http_requests_total counter', + ]; + + for (const [key, value] of [...this.http.entries()].sort(([a], [b]) => a.localeCompare(b))) { + const [method = '', route = '', status = '0'] = key.split('\u0000'); + const labels = `method="${escapeLabel(method)}",route="${escapeLabel(route)}",status="${escapeLabel(status)}"`; + lines.push(`haip_http_requests_total{${labels}} ${value.count}`); + } + + lines.push( + '# HELP haip_http_request_errors_total HTTP 5xx responses grouped by stable route template and status.', + '# TYPE haip_http_request_errors_total counter', + ); + for (const [key, value] of [...this.http.entries()].sort(([a], [b]) => a.localeCompare(b))) { + if (value.errors === 0) continue; + const [method = '', route = '', status = '0'] = key.split('\u0000'); + const labels = `method="${escapeLabel(method)}",route="${escapeLabel(route)}",status="${escapeLabel(status)}"`; + lines.push(`haip_http_request_errors_total{${labels}} ${value.errors}`); + } + + lines.push( + '# HELP haip_http_request_duration_seconds_sum Total HTTP request duration by route template.', + '# TYPE haip_http_request_duration_seconds_sum counter', + '# HELP haip_http_request_duration_seconds_count Number of timed HTTP requests by route template.', + '# TYPE haip_http_request_duration_seconds_count counter', + ); + const durationByRoute = new Map(); + for (const [key, value] of this.http.entries()) { + const [method = '', route = ''] = key.split('\u0000'); + const routeKey = `${method}\u0000${route}`; + const current = durationByRoute.get(routeKey) ?? { sum: 0, count: 0 }; + current.sum += value.durationSeconds; + current.count += value.count; + durationByRoute.set(routeKey, current); + } + for (const [key, value] of [...durationByRoute.entries()].sort(([a], [b]) => a.localeCompare(b))) { + const [method = '', route = ''] = key.split('\u0000'); + const labels = `method="${escapeLabel(method)}",route="${escapeLabel(route)}"`; + lines.push(`haip_http_request_duration_seconds_sum{${labels}} ${value.sum}`); + lines.push(`haip_http_request_duration_seconds_count{${labels}} ${value.count}`); + } + + lines.push( + '# HELP haip_booking_create_total Booking creation HTTP outcomes.', + '# TYPE haip_booking_create_total counter', + `haip_booking_create_total{outcome="success"} ${this.bookingCreates.success}`, + `haip_booking_create_total{outcome="failed"} ${this.bookingCreates.failed}`, + '# HELP haip_channel_sync_total Channel sync outcomes emitted by the channel service.', + '# TYPE haip_channel_sync_total counter', + `haip_channel_sync_total{outcome="success"} ${this.channelSyncs.success}`, + `haip_channel_sync_total{outcome="failed"} ${this.channelSyncs.failed}`, + '# HELP haip_night_audit_runs_total Night audit completion outcomes.', + '# TYPE haip_night_audit_runs_total counter', + `haip_night_audit_runs_total{outcome="success"} ${this.nightAudits.success}`, + `haip_night_audit_runs_total{outcome="failed"} ${this.nightAudits.failed}`, + '# HELP haip_night_audit_last_business_date_timestamp_seconds Last completed audit business date at UTC midnight.', + '# TYPE haip_night_audit_last_business_date_timestamp_seconds gauge', + `haip_night_audit_last_business_date_timestamp_seconds ${this.lastAuditBusinessDate}`, + '# HELP haip_webhook_delivery_failures_total Webhook deliveries that exhausted all retry attempts.', + '# TYPE haip_webhook_delivery_failures_total counter', + `haip_webhook_delivery_failures_total ${this.webhookDeliveryFailures}`, + '# HELP process_uptime_seconds Node.js process uptime.', + '# TYPE process_uptime_seconds gauge', + `process_uptime_seconds ${process.uptime()}`, + ); + + return `${lines.join('\n')}\n`; + } +} diff --git a/apps/api/src/modules/reservation/check-in.spec.ts b/apps/api/src/modules/reservation/check-in.spec.ts index 806c8009..424de690 100644 --- a/apps/api/src/modules/reservation/check-in.spec.ts +++ b/apps/api/src/modules/reservation/check-in.spec.ts @@ -328,7 +328,7 @@ describe('ReservationService — checkIn', () => { it('should call paymentService.authorizePayment when token provided', async () => { const db = createCheckInDb(); const svc = await createService(db); - await svc.checkIn('res-001', 'prop-001', { + const result = await svc.checkIn('res-001', 'prop-001', { gatewayPaymentToken: 'tok_test_123', gatewayProvider: 'stripe', }); @@ -338,16 +338,30 @@ describe('ReservationService — checkIn', () => { gatewayPaymentToken: 'tok_test_123', }), ); + expect(result.depositAuth).toEqual({ + status: 'ok', + paymentId: 'pay-001', + amount: '600.00', + currencyCode: 'USD', + }); }); it('should skip deposit auth when skipDepositAuth is true', async () => { const db = createCheckInDb(); const svc = await createService(db); - await svc.checkIn('res-001', 'prop-001', { + const result = await svc.checkIn('res-001', 'prop-001', { skipDepositAuth: true, gatewayPaymentToken: 'tok_test_123', }); expect(mockPaymentService.authorizePayment).not.toHaveBeenCalled(); + expect(result.depositAuth).toEqual({ status: 'skipped', reason: 'explicitly_skipped' }); + }); + + it('should report a missing payment token as an explicit skip', async () => { + const db = createCheckInDb(); + const svc = await createService(db); + const result = await svc.checkIn('res-001', 'prop-001'); + expect(result.depositAuth).toEqual({ status: 'skipped', reason: 'payment_token_missing' }); }); it('should not block check-in when deposit auth fails', async () => { @@ -361,7 +375,23 @@ describe('ReservationService — checkIn', () => { gatewayProvider: 'stripe', }); expect(result.reservation.status).toBe('checked_in'); - expect(result.depositAuth).toBeNull(); + expect(result.depositAuth).toEqual({ + status: 'failed', + code: 'DEPOSIT_AUTHORIZATION_FAILED', + message: 'Deposit authorization failed. Retry authorization or record an approved override.', + }); + expect(mockWebhookService.emit).toHaveBeenCalledWith( + 'reservation.checked_in', + 'reservation', + 'res-001', + expect.objectContaining({ + depositAuth: expect.objectContaining({ + status: 'failed', + code: 'DEPOSIT_AUTHORIZATION_FAILED', + }), + }), + 'prop-001', + ); }); it('should set early check-in flag when before standard time', async () => { diff --git a/apps/api/src/modules/reservation/reservation-assert-sellable.spec.ts b/apps/api/src/modules/reservation/reservation-assert-sellable.spec.ts index 73e52842..b5752954 100644 --- a/apps/api/src/modules/reservation/reservation-assert-sellable.spec.ts +++ b/apps/api/src/modules/reservation/reservation-assert-sellable.spec.ts @@ -30,8 +30,9 @@ function mkDb() { select: vi.fn().mockImplementation(() => ({ from: vi.fn().mockReturnValue({ where: vi.fn() - // guest → roomType FK → ratePlan FK + // guest → guest property links → roomType FK → ratePlan FK .mockResolvedValueOnce([{ id: 'g', isDnr: false }]) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ id: ROOM_TYPE }]) .mockResolvedValueOnce([{ id: RATE_PLAN }]), }), @@ -188,6 +189,48 @@ describe('ReservationService.create — assertSellable (BOOK path)', () => { } as any)).rejects.toThrow(/2026-07-03/); }); + it('re-checks rate-plan sellability inside the modification transaction', async () => { + const assertSellable = vi.fn().mockResolvedValue(undefined); + const updated = { + id: 'res-1', + propertyId: PROPERTY, + status: 'confirmed', + arrivalDate: '2026-07-01', + departureDate: '2026-07-03', + roomTypeId: ROOM_TYPE, + ratePlanId: 'rp-002', + totalAmount: '320.00', + }; + const update = vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([updated]) }), + }), + }); + const tx = { update }; + const db = mkDb(); + db.transaction.mockImplementation(async (callback: (conn: any) => Promise) => + callback(tx)); + const { svc } = await mkService(assertSellable, db); + vi.spyOn(svc as any, 'findByIdRaw').mockResolvedValue({ + ...updated, + ratePlanId: RATE_PLAN, + totalAmount: '300.00', + }); + + await svc.modify('res-1', PROPERTY, { + ratePlanId: 'rp-002', + totalAmount: '320.00', + }); + + expect(assertSellable).toHaveBeenCalledWith( + PROPERTY, + 'rp-002', + '2026-07-01', + '2026-07-03', + tx, + ); + }); + it.each([ [{ departureDate: '2026-07-04' }, 'stay dates'], [{ totalAmount: '325.00' }, 'accepted total'], diff --git a/apps/api/src/modules/reservation/reservation-fk-ownership.spec.ts b/apps/api/src/modules/reservation/reservation-fk-ownership.spec.ts index 203af8fd..8d7653bb 100644 --- a/apps/api/src/modules/reservation/reservation-fk-ownership.spec.ts +++ b/apps/api/src/modules/reservation/reservation-fk-ownership.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { BadRequestException } from '@nestjs/common'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { ReservationService } from './reservation.service'; import { DRIZZLE } from '../../database/database.module'; @@ -26,8 +26,9 @@ const A = 'aaaaaaaa-0000-4000-a000-000000000001'; /** * Sequenced-select mock. `create()` runs: * 1) guest lookup (NotFoundException if empty) - * 2) roomTypes FK ownership check (BadRequestException if empty) ← the audit fix - * 3) ratePlans FK ownership check (BadRequestException if empty) ← the audit fix + * 2) guest property-link lookup + * 3) roomTypes FK ownership check (BadRequestException if empty) ← the audit fix + * 4) ratePlans FK ownership check (BadRequestException if empty) ← the audit fix * `modify()` runs: * 1) findByIdRaw on reservations * 2) roomTypes FK ownership (only if dto.roomTypeId) @@ -73,7 +74,8 @@ describe('ReservationService — cross-tenant FK ownership (audit #4)', () => { // selects in order: guest (found), roomTypes FK check (empty = foreign). const db = mkDbSeq([ [{ id: 'g', isDnr: false }], // 1) guest lookup OK - [], // 2) FK check on roomTypes → not in this property + [], // 2) fresh guest has no property links yet + [], // 3) FK check on roomTypes → not in this property ]); const svc = await mkService(db); @@ -95,8 +97,9 @@ describe('ReservationService — cross-tenant FK ownership (audit #4)', () => { it('create() rejects when dto.ratePlanId belongs to another property (roomType OK)', async () => { const db = mkDbSeq([ [{ id: 'g', isDnr: false }], // 1) guest lookup OK - [{ id: 'rt-1' }], // 2) FK check on roomTypes OK - [], // 3) FK check on ratePlans → not in this property + [], // 2) fresh guest has no property links yet + [{ id: 'rt-1' }], // 3) FK check on roomTypes OK + [], // 4) FK check on ratePlans → not in this property ]); const svc = await mkService(db); @@ -115,6 +118,28 @@ describe('ReservationService — cross-tenant FK ownership (audit #4)', () => { expect(db.insert).not.toHaveBeenCalled(); }); + it('create() hides a guest linked only to another property', async () => { + const db = mkDbSeq([ + [{ id: 'guest-b', isDnr: false }], + [{ propertyId: 'bbbbbbbb-0000-4000-a000-000000000002' }], + ]); + const svc = await mkService(db); + + await expect( + svc.create({ + propertyId: A, + roomTypeId: 'rt-1', + ratePlanId: 'plan-1', + arrivalDate: '2026-07-01', + departureDate: '2026-07-03', + totalAmount: '300.00', + currencyCode: 'USD', + guestId: 'guest-b', + } as any), + ).rejects.toThrow(NotFoundException); + expect(db.insert).not.toHaveBeenCalled(); + }); + it('modify() rejects when dto.roomTypeId belongs to another property', async () => { const db = mkDbSeq([ // 1) findByIdRaw → reservation in propertyId A diff --git a/apps/api/src/modules/reservation/reservation.service.ts b/apps/api/src/modules/reservation/reservation.service.ts index 6e723da2..ae98cc00 100644 --- a/apps/api/src/modules/reservation/reservation.service.ts +++ b/apps/api/src/modules/reservation/reservation.service.ts @@ -50,6 +50,23 @@ export type ReservationAmendmentResult = { newTotalAmount: string; }; +export type DepositAuthorizationOutcome = + | { + status: 'ok'; + paymentId: string | null; + amount: string; + currencyCode: string; + } + | { + status: 'skipped'; + reason: 'explicitly_skipped' | 'payment_token_missing'; + } + | { + status: 'failed'; + code: 'DEPOSIT_AUTHORIZATION_FAILED'; + message: string; + }; + @Injectable() export class ReservationService { constructor( @@ -89,6 +106,23 @@ export class ReservationService { ); } + // Guests are global rows, but once linked to a property their PII must not + // be reused by another tenant merely because an id was supplied. A fresh + // guest (no roster links yet) remains valid for the walk-in create flow. + const guestPropertyLinks = await db + .select({ propertyId: reservationGuests.propertyId }) + .from(reservationGuests) + .where(eq(reservationGuests.guestId, dto.guestId)); + const linkedPropertyIds = guestPropertyLinks + .map((row: { propertyId?: unknown }) => row.propertyId) + .filter((value: unknown): value is string => typeof value === 'string'); + if ( + linkedPropertyIds.length > 0 + && !linkedPropertyIds.includes(dto.propertyId) + ) { + throw new NotFoundException(`Guest ${dto.guestId} not found`); + } + // Calculate nights const arrival = new Date(dto.arrivalDate); const departure = new Date(dto.departureDate); @@ -677,13 +711,15 @@ export class ReservationService { // Deposit authorization (if token provided and not skipped) // Accept paymentMethodId (Stripe Elements) as alias for gatewayPaymentToken const paymentToken = dto.paymentMethodId ?? dto.gatewayPaymentToken; - let depositAuth: unknown = null; + let depositAuth: DepositAuthorizationOutcome = dto.skipDepositAuth + ? { status: 'skipped', reason: 'explicitly_skipped' } + : { status: 'skipped', reason: 'payment_token_missing' }; if (!dto.skipDepositAuth && paymentToken) { const depositAmount = dto.depositAmount ? String(dto.depositAmount) : new Decimal(reservation.totalAmount).times('1.2').toFixed(2); try { - depositAuth = await this.paymentService.authorizePayment({ + const authorization = await this.paymentService.authorizePayment({ folioId: folio.id, propertyId: reservation.propertyId, amount: depositAmount, @@ -693,8 +729,20 @@ export class ReservationService { cardLastFour: dto.cardLastFour, cardBrand: dto.cardBrand, }); + depositAuth = { + status: 'ok', + paymentId: (authorization as { id?: string } | null)?.id ?? null, + amount: depositAmount, + currencyCode: reservation.currencyCode, + }; } catch { - // Deposit auth failure does not block check-in + // Check-in remains non-blocking by policy, but the API response and the + // reservation.checked_in event must make the financial risk explicit. + depositAuth = { + status: 'failed', + code: 'DEPOSIT_AUTHORIZATION_FAILED', + message: 'Deposit authorization failed. Retry authorization or record an approved override.', + }; } } @@ -721,7 +769,7 @@ export class ReservationService { 'reservation.checked_in', 'reservation', updated.id, - { roomId, folioId: folio.id, isEarlyCheckin }, + { roomId, folioId: folio.id, isEarlyCheckin, depositAuth }, reservation.propertyId, ); @@ -1075,7 +1123,12 @@ export class ReservationService { return { data, total: Number(countResult[0]?.count ?? 0) }; } - async modify(id: string, propertyId: string, dto: ModifyReservationDto) { + async modify( + id: string, + propertyId: string, + dto: ModifyReservationDto, + internal?: { currencyCode?: string }, + ) { const reservation = await this.findByIdRaw(id, propertyId); // Booking Request acceptance freezes the operational tariff. Until the @@ -1110,9 +1163,14 @@ export class ReservationService { const updates: Record = { updatedAt: new Date() }; - const arrivalChanged = dto.arrivalDate && dto.arrivalDate !== reservation.arrivalDate; - const departureChanged = dto.departureDate && dto.departureDate !== reservation.departureDate; - const roomTypeChanged = dto.roomTypeId && dto.roomTypeId !== reservation.roomTypeId; + const arrivalChanged = dto.arrivalDate !== undefined + && dto.arrivalDate !== reservation.arrivalDate; + const departureChanged = dto.departureDate !== undefined + && dto.departureDate !== reservation.departureDate; + const roomTypeChanged = dto.roomTypeId !== undefined + && dto.roomTypeId !== reservation.roomTypeId; + const ratePlanChanged = dto.ratePlanId !== undefined + && dto.ratePlanId !== reservation.ratePlanId; if (dto.arrivalDate || dto.departureDate) { const arrival = dto.arrivalDate ?? reservation.arrivalDate; @@ -1134,7 +1192,8 @@ export class ReservationService { await this.assertSamePropertyFk(ratePlans, dto.ratePlanId, propertyId, 'rate plan'); updates['ratePlanId'] = dto.ratePlanId; } - if (dto.totalAmount) updates['totalAmount'] = dto.totalAmount; + if (dto.totalAmount !== undefined) updates['totalAmount'] = dto.totalAmount; + if (internal?.currencyCode !== undefined) updates['currencyCode'] = internal.currencyCode; if (dto.adults !== undefined) updates['adults'] = dto.adults; if (dto.children !== undefined) updates['children'] = dto.children; if (dto.specialRequests !== undefined) @@ -1148,6 +1207,16 @@ export class ReservationService { // Use the same room-type inventory mutex as canonical creation so a modify // cannot race another create/modify for the final unit. const updated: ReservationRow = await this.db.transaction(async (tx: any) => { + if (arrivalChanged || departureChanged || roomTypeChanged || ratePlanChanged) { + await this.ratePlanService.assertSellable( + propertyId, + (dto.ratePlanId ?? reservation.ratePlanId) as string, + (dto.arrivalDate ?? reservation.arrivalDate) as string, + (dto.departureDate ?? reservation.departureDate) as string, + tx, + ); + } + if (arrivalChanged || departureChanged || roomTypeChanged) { const newArrival = (dto.arrivalDate ?? reservation.arrivalDate) as string; const newDeparture = (dto.departureDate ?? reservation.departureDate) as string; diff --git a/apps/api/src/modules/staff-notifications/staff-notification.listener.spec.ts b/apps/api/src/modules/staff-notifications/staff-notification.listener.spec.ts index b269319a..1c28f6c9 100644 --- a/apps/api/src/modules/staff-notifications/staff-notification.listener.spec.ts +++ b/apps/api/src/modules/staff-notifications/staff-notification.listener.spec.ts @@ -58,4 +58,74 @@ describe('StaffNotificationListener — webhook.delivery_failed', () => { await listener.onWebhookDeliveryFailed({ ...basePayload, propertyId: '' }); expect(staffNotifications.create).not.toHaveBeenCalled(); }); + + it('creates a critical notification for a failed check-in deposit authorization', async () => { + await listener.onReservationCheckedIn({ + event: 'reservation.checked_in', + propertyId: 'prop-1', + entityType: 'reservation', + entityId: 'res-1', + data: { + depositAuth: { + status: 'failed', + code: 'DEPOSIT_AUTHORIZATION_FAILED', + }, + }, + timestamp: new Date().toISOString(), + }); + + expect(staffNotifications.create).toHaveBeenCalledWith( + expect.objectContaining({ + propertyId: 'prop-1', + type: 'deposit_authorization_failed', + severity: 'critical', + sourceEvent: 'reservation.checked_in', + sourceEntityType: 'reservation', + sourceEntityId: 'res-1', + }), + ); + }); + + it('does not notify when deposit authorization succeeded or was skipped', async () => { + for (const status of ['ok', 'skipped']) { + await listener.onReservationCheckedIn({ + event: 'reservation.checked_in', + propertyId: 'prop-1', + entityType: 'reservation', + entityId: 'res-1', + data: { depositAuth: { status } }, + timestamp: new Date().toISOString(), + }); + } + + expect(staffNotifications.create).not.toHaveBeenCalled(); + }); + + it('creates a property-scoped critical notification for a channel sync transition to failed', async () => { + await listener.onChannelSyncFailed({ + event: 'channel.sync_failed', + propertyId: 'prop-1', + entityType: 'channel_connection', + entityId: 'conn-1', + data: { + channelName: 'Booking.com', + adapterType: 'booking_com', + error: 'HTTP 503', + }, + timestamp: new Date().toISOString(), + }); + + expect(staffNotifications.create).toHaveBeenCalledWith( + expect.objectContaining({ + propertyId: 'prop-1', + type: 'channel_sync_failed', + severity: 'critical', + sourceEvent: 'channel.sync_failed', + sourceEntityId: 'conn-1', + }), + ); + const notification = staffNotifications.create.mock.calls[0]![0]; + expect(notification.title).toContain('Booking.com'); + expect(notification.message).toContain('HTTP 503'); + }); }); diff --git a/apps/api/src/modules/staff-notifications/staff-notification.listener.ts b/apps/api/src/modules/staff-notifications/staff-notification.listener.ts index 3eb778ed..e2018a92 100644 --- a/apps/api/src/modules/staff-notifications/staff-notification.listener.ts +++ b/apps/api/src/modules/staff-notifications/staff-notification.listener.ts @@ -76,6 +76,50 @@ export class StaffNotificationListener { }); } + @OnEvent('reservation.checked_in') + async onReservationCheckedIn(payload: WebhookPayload) { + if (!payload.propertyId) return; + + const depositAuth = payload.data?.['depositAuth'] as Record | undefined; + if (depositAuth?.['status'] !== 'failed') return; + + await this.staffNotifications.create({ + propertyId: payload.propertyId, + type: 'deposit_authorization_failed', + title: 'Deposit authorization failed after check-in', + message: + 'The guest was checked in without a successful deposit authorization. ' + + 'Retry the authorization now or record an approved override for shift handover.', + severity: 'critical', + sourceEvent: 'reservation.checked_in', + sourceEntityType: 'reservation', + sourceEntityId: payload.entityId, + }); + } + + @OnEvent('channel.sync_failed') + async onChannelSyncFailed(payload: WebhookPayload) { + if (!payload.propertyId) return; + + const data = payload.data ?? {}; + const channel = String(data['channelName'] ?? data['channelCode'] ?? 'channel'); + const adapter = String(data['adapterType'] ?? 'unknown adapter'); + const error = String(data['error'] ?? 'No error detail').slice(0, 500); + + await this.staffNotifications.create({ + propertyId: payload.propertyId, + type: 'channel_sync_failed', + title: `Channel sync failed: ${channel}`, + message: + `${adapter}: ${error}. Inventory or rates may be stale on the OTA; ` + + 'open Channels, verify the connection, and retry the sync.', + severity: 'critical', + sourceEvent: 'channel.sync_failed', + sourceEntityType: 'channel_connection', + sourceEntityId: payload.entityId, + }); + } + @OnEvent('audit.completed') async onAuditCompleted(payload: WebhookPayload) { if (!payload.propertyId) return; diff --git a/apps/dashboard/src/locales/en.json b/apps/dashboard/src/locales/en.json index c9a039ab..8560ceb0 100644 --- a/apps/dashboard/src/locales/en.json +++ b/apps/dashboard/src/locales/en.json @@ -623,6 +623,7 @@ "confirmation": "Confirmation", "createWalkIn": "Create & assign", "departure": "Departure", + "depositAuthFailed": "Deposit authorization failed. The guest was checked in; retry payment authorization immediately.", "departures": "Departures", "dnm": "DNM", "doorPin": "Door PIN", diff --git a/apps/dashboard/src/pages/FrontDesk.tsx b/apps/dashboard/src/pages/FrontDesk.tsx index 36ff45cb..d0aaac58 100644 --- a/apps/dashboard/src/pages/FrontDesk.tsx +++ b/apps/dashboard/src/pages/FrontDesk.tsx @@ -11,6 +11,7 @@ import Modal from '../components/ui/Modal'; import FindGuest from '../components/guests/FindGuest'; import IdSwipeCapture from '../components/guests/IdSwipeCapture'; import GuestDetailsModal from '../components/front-desk/GuestDetailsModal'; +import { useToast } from '../components/ui/Toast'; import type { ParsedIdDocument } from '../lib/id-document-swipe'; import type { Guest } from '../types/guest'; import { formatMoney } from '../lib/money'; @@ -128,6 +129,7 @@ export default function FrontDesk() { const { t } = useTranslation(); const { propertyId, currencyCode } = useProperty(); const queryClient = useQueryClient(); + const { toast } = useToast(); const today = format(new Date(), 'yyyy-MM-dd'); const tomorrow = format(addDays(new Date(), 1), 'yyyy-MM-dd'); @@ -334,7 +336,7 @@ export default function FrontDesk() { params: { propertyId }, }); } - await api.patch( + const checkInResponse = await api.patch( `/v1/reservations/${data.id}/check-in`, { roomId: data.roomId || undefined, @@ -361,11 +363,17 @@ export default function FrontDesk() { { params: { propertyId } }, ); } + return checkInResponse.data as { + depositAuth?: { status?: 'ok' | 'skipped' | 'failed'; message?: string }; + }; }, - onSuccess: () => { + onSuccess: (result) => { invalidateAll(); setCheckInModal(null); resetCheckInForm(); + if (result?.depositAuth?.status === 'failed') { + toast('error', t('frontDesk.depositAuthFailed')); + } }, }); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8bdefee8..7d9d5cad 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -4,23 +4,39 @@ # cp .env.production.example .env.production # fill credentials # # Start (Keycloak is on the `auth` profile in the base file): -# docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build +# docker compose --env-file .env.production -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build # # Services: postgres, redis, keycloak (--profile auth), init, api. # Mock channel adapters and MinIO stay on optional profiles (channels, storage). services: + postgres: + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.production} + + keycloak: + # Override the base compose's demo-only start-dev command. TLS terminates at + # the reverse proxy; Keycloak remains HTTP-only on the private compose network. + command: start --import-realm + environment: + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:?Set KEYCLOAK_ADMIN in .env.production} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?Set KEYCLOAK_ADMIN_PASSWORD in .env.production} + KC_DB_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.production} + KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL:?Set KEYCLOAK_PUBLIC_URL in .env.production} + KC_HTTP_ENABLED: 'true' + KC_PROXY_HEADERS: ${KEYCLOAK_PROXY_HEADERS:-xforwarded} + api: build: context: . dockerfile: apps/api/Dockerfile args: VITE_AUTH_ENABLED: 'true' - VITE_KEYCLOAK_URL: http://localhost:8080 - VITE_KEYCLOAK_REALM: haip - VITE_KEYCLOAK_CLIENT_ID: haip-dashboard + VITE_KEYCLOAK_URL: ${KEYCLOAK_PUBLIC_URL:?Set KEYCLOAK_PUBLIC_URL in .env.production} + VITE_KEYCLOAK_REALM: ${KEYCLOAK_REALM:-haip} + VITE_KEYCLOAK_CLIENT_ID: ${VITE_KEYCLOAK_CLIENT_ID:-haip-dashboard} # Must match the api service's HAIP_BOOKING_REQUESTS below. - VITE_HAIP_BOOKING_REQUESTS: 'false' + VITE_HAIP_BOOKING_REQUESTS: ${HAIP_BOOKING_REQUESTS:-false} env_file: - .env.production environment: @@ -28,12 +44,13 @@ services: AUTH_ENABLED: 'true' SERVE_DASHBOARD: 'true' SERVE_BOOKING: 'true' - STRIPE_MODE: test + STRIPE_MODE: ${STRIPE_MODE:?Set STRIPE_MODE in .env.production} # Override demo-only opt-out; must not be 'true' in production. HAIP_ALLOW_INSECURE: '' - KEYCLOAK_URL: http://keycloak:8080 - DATABASE_URL: postgresql://haip:haip@postgres:5432/haip - REDIS_URL: redis://redis:6379 + KEYCLOAK_URL: ${KEYCLOAK_URL:-http://keycloak:8080} + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env.production} + REDIS_URL: ${REDIS_URL:?Set REDIS_URL in .env.production} + HAIP_BOOKING_REQUESTS: ${HAIP_BOOKING_REQUESTS:-false} depends_on: postgres: condition: service_healthy @@ -50,11 +67,12 @@ services: dockerfile: apps/api/Dockerfile args: VITE_AUTH_ENABLED: 'true' - VITE_KEYCLOAK_URL: http://localhost:8080 - VITE_KEYCLOAK_REALM: haip - VITE_KEYCLOAK_CLIENT_ID: haip-dashboard - VITE_HAIP_BOOKING_REQUESTS: 'false' + VITE_KEYCLOAK_URL: ${KEYCLOAK_PUBLIC_URL:?Set KEYCLOAK_PUBLIC_URL in .env.production} + VITE_KEYCLOAK_REALM: ${KEYCLOAK_REALM:-haip} + VITE_KEYCLOAK_CLIENT_ID: ${VITE_KEYCLOAK_CLIENT_ID:-haip-dashboard} + VITE_HAIP_BOOKING_REQUESTS: ${HAIP_BOOKING_REQUESTS:-false} env_file: - .env.production environment: - DATABASE_URL: postgresql://haip:haip@postgres:5432/haip + DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env.production} + HAIP_BOOKING_REQUESTS: ${HAIP_BOOKING_REQUESTS:-false} diff --git a/docs/deployment.md b/docs/deployment.md index b7f73012..3532a4f4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -43,7 +43,7 @@ curl -s -o /dev/null -w "%{http_code}" \ cp .env.production.example .env.production # Edit .env.production — fill Stripe keys, CONNECT_API_KEY, CORS, storage, etc. -docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build +docker compose --env-file .env.production -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build ``` Services: **postgres**, **redis**, **keycloak** (`--profile auth`), **init** (migrate + seed), **api**. @@ -53,7 +53,7 @@ The API refuses to boot in `NODE_ENV=production` when `AUTH_ENABLED=false` or `S Validate compose config before deploying: ```bash -docker compose -f docker-compose.yml -f docker-compose.prod.yml config +docker compose --env-file .env.production -f docker-compose.yml -f docker-compose.prod.yml config ``` ## Required environment variables @@ -66,8 +66,11 @@ Copy [`.env.production.example`](../.env.production.example) to `.env.production | `REDIS_URL` | Yes | Redis for cache, queues, pub/sub | | `AUTH_ENABLED` | Yes | Must be `true` in production | | `KEYCLOAK_URL` | Yes | Internal URL (`http://keycloak:8080` in compose) | +| `KEYCLOAK_PUBLIC_URL` | Yes | Public HTTPS origin used by browsers and Keycloak, e.g. `https://auth.example.com` | | `KEYCLOAK_REALM` | Yes | Default `haip` | | `KEYCLOAK_CLIENT_ID` | Yes | API client, default `haip-api` | +| `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` | Yes | Unique bootstrap administrator credentials | +| `POSTGRES_PASSWORD` | Yes | Unique database password shared with the compose-managed Postgres service | | `CONNECT_API_KEY` | Yes when auth on | Comma-separated keys for OTAIP Connect API | | `STRIPE_MODE` | Yes | `test` for staging, `live` for real charges | | `STRIPE_SECRET_KEY` | Yes | Stripe secret key matching mode | @@ -77,12 +80,16 @@ Copy [`.env.production.example`](../.env.production.example) to `.env.production | `CORS_ORIGINS` | If cross-origin | Comma-separated browser origins; omit for same-origin | | `STORAGE_DRIVER` | If uploads | `s3` with bucket credentials, or `local` | -**Keycloak (production notes):** The base compose file runs Keycloak in `start-dev` mode for local exploration. For real deployments, run Keycloak in production mode with TLS, strong admin credentials, and a managed Postgres database — do not expose port 8080 publicly without a reverse proxy. +**Keycloak (production notes):** The production overlay replaces the base file's `start-dev` command with `start`, requires a public HTTPS hostname and strong bootstrap credentials, and expects TLS termination at a reverse proxy. The proxy must overwrite `X-Forwarded-*` headers. Keep Keycloak port 8080 private; expose only the required authentication paths through the proxy. -**Stripe:** Production overlay sets `STRIPE_MODE=test` by default. Switch to `live` and live keys only when ready to accept real payments. +**Stripe:** Set `STRIPE_MODE=test` for staging. Switch to `live` with matching live keys only when ready to accept real payments; the production overlay no longer overrides this value. ## TLS termination +Production metrics and alerting are documented in +[`docs/observability.md`](./observability.md). Restrict `/api/v1/metrics` to the +monitoring network at the reverse proxy. + Terminate TLS at a reverse proxy in front of the API container (port 3000). Example **Caddy** site block: ```caddyfile @@ -136,7 +143,7 @@ pg_restore -d "$STAGING_DATABASE_URL" --clean --if-exists haip-YYYYMMDD.dump 2. Pull the new image (or rebuild). 3. Run schema migration **before** switching traffic: ```bash - docker compose -f docker-compose.yml -f docker-compose.prod.yml run --rm init \ + docker compose --env-file .env.production -f docker-compose.yml -f docker-compose.prod.yml run --rm init \ sh -c "node packages/database/dist/push-schema.js" ``` 4. Restart the API: `docker compose ... up -d api`. @@ -178,7 +185,7 @@ Minimal path for a single VM (Hetzner, DigitalOcean, Linode, AWS EC2, etc.). 4. **Firewall:** allow `22`, `80`, `443` only. Do **not** expose Postgres (`5432`), Redis (`6379`), or Keycloak (`8080`) publicly — terminate TLS on the host and proxy to the API on `127.0.0.1:3000` (or a private Docker network). 5. **Start production stack:** ```bash - docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build + docker compose --env-file .env.production -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build ``` 6. **TLS:** point DNS at the VPS and put Caddy or nginx in front (see [TLS termination](#tls-termination)). Set `CORS_ORIGINS=https://pms.example.com` if the browser origin differs from the API host. 7. **Cron:** schedule night audit / group cutoffs from the host using [`scripts/cron/`](../scripts/cron/) and [`docs/operations/cron.md`](./operations/cron.md). diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 00000000..8e2a9fb1 --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,45 @@ +# Production observability + +HAIP exposes Prometheus text metrics at `GET /api/v1/metrics`. The endpoint is +public at the application layer so a Prometheus scraper does not need a hotel +staff JWT. **Restrict this path to the monitoring network at the reverse proxy +or firewall.** Do not expose it to the public internet. + +Example Prometheus scrape job: + +```yaml +scrape_configs: + - job_name: haip-api + metrics_path: /api/v1/metrics + static_configs: + - targets: ['haip-api:3000'] +``` + +The first metrics surface includes: + +- HTTP request count, 5xx count, and latency sum/count by method and stable route template +- booking-create success/failure counters for PMS, booking engine, and Connect routes +- channel sync success/failure counters +- night-audit outcome counters and last completed business date +- permanently failed webhook delivery count +- Node.js process uptime + +Import [`ops/harden/grafana-haip-overview.json`](../ops/harden/grafana-haip-overview.json) +into Grafana and load [`ops/harden/prometheus-alerts.yml`](../ops/harden/prometheus-alerts.yml) +in Prometheus or a compatible rule engine. + +## Label and privacy policy + +Metrics use route templates such as `/reservations/:id`, never raw URLs. They do +not include guest ids, confirmation numbers, email addresses, payment ids, or +`property_id`. Adding a property label to every HTTP series creates both a +privacy concern and unbounded cardinality for large installations. Property +specific operational detail remains in the authenticated dashboard, staff +notifications, channel sync logs, and webhook delivery records. + +## Queue depth + +Webhook and migration BullMQ queues are currently created lazily inside their +services. A shared queue registry is required before a truthful process-wide +depth gauge can be exposed. Until then, alert on exhausted webhook deliveries +and migration job state rather than publishing a misleading zero-valued gauge. diff --git a/ops/harden/.env.harden.example b/ops/harden/.env.harden.example index cb029b4d..801f9244 100644 --- a/ops/harden/.env.harden.example +++ b/ops/harden/.env.harden.example @@ -24,5 +24,20 @@ PROPERTY_B= # Optional: JWT missing property_ids or from wrong issuer (expect 401) # TOKEN_BAD= +# Optional but recommended: same-account multi-property owner probes. +# OWNER_TOKEN must contain both OWNER_PROPERTY_A and OWNER_PROPERTY_B. +# The reservation and guest must belong only to B; room/rate must belong to A. +# OWNER_TOKEN= +# OWNER_PROPERTY_A= +# OWNER_PROPERTY_B= +# OWNER_RESERVATION_IN_B= +# OWNER_GUEST_IN_B= +# OWNER_ROOM_TYPE_IN_A= +# OWNER_RATE_PLAN_IN_A= +# OWNER_TEST_ARRIVAL=2099-01-01 +# OWNER_TEST_DEPARTURE=2099-01-02 +# OWNER_TEST_TOTAL=100.00 +# OWNER_TEST_CURRENCY=USD + # Optional: override request timeout ms (default 15000) # HARDEN_TIMEOUT_MS=15000 diff --git a/ops/harden/CHECKLIST.md b/ops/harden/CHECKLIST.md index 6966506c..f5e25624 100644 --- a/ops/harden/CHECKLIST.md +++ b/ops/harden/CHECKLIST.md @@ -9,12 +9,14 @@ Details: [`docs/deployment.md`](../../docs/deployment.md) and - [ ] Copied `.env.production.example` → `.env.production` and filled secrets - [ ] Started with prod overlay + auth profile: ```bash - docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build + docker compose --env-file .env.production -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build ``` - [ ] `AUTH_ENABLED=true` (required in production) - [ ] `HAIP_ALLOW_INSECURE` is **unset / empty** (never `true` in production) - [ ] `STRIPE_MODE` is `test` until ready for real charges; then `live` with live keys - [ ] API boots cleanly; `GET /api/v1/health` returns `status: ok` +- [ ] Prometheus scrapes `GET /api/v1/metrics`; reverse proxy blocks public access to that path +- [ ] Grafana dashboard and alert rules from `ops/harden/` are loaded ## Auth (Keycloak) diff --git a/ops/harden/README.md b/ops/harden/README.md index aa7ae74a..49d1c02e 100644 --- a/ops/harden/README.md +++ b/ops/harden/README.md @@ -25,7 +25,7 @@ cp .env.production.example .env.production # Edit .env.production — AUTH_ENABLED=true, Stripe, CONNECT_API_KEY, etc. # 2. Bring up the prod overlay (auth on) -docker compose -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build +docker compose --env-file .env.production -f docker-compose.yml -f docker-compose.prod.yml --profile auth up -d --build # 3. Static + HTTP local checks pnpm harden:local diff --git a/ops/harden/TENANT_ISOLATION.md b/ops/harden/TENANT_ISOLATION.md index 00674a18..7a09ea13 100644 --- a/ops/harden/TENANT_ISOLATION.md +++ b/ops/harden/TENANT_ISOLATION.md @@ -16,6 +16,10 @@ Run before enabling real hotel tenants. Uses two Keycloak users / properties. | `TOKEN_A` / `TOKEN_B` | Bearer JWTs for users A and B | | `PROPERTY_A` / `PROPERTY_B` | Property UUIDs | | `RESERVATION_IN_B` | Optional — reservation id that belongs only to B | +| `OWNER_TOKEN` | Optional — owner JWT whose `property_ids` contains both A and B | +| `OWNER_PROPERTY_A` / `OWNER_PROPERTY_B` | Two properties available to that same owner | +| `OWNER_RESERVATION_IN_B` / `OWNER_GUEST_IN_B` | Entities linked only to property B | +| `OWNER_ROOM_TYPE_IN_A` / `OWNER_RATE_PLAN_IN_A` | Valid property-A ids used to exercise reservation creation | ## Automated probe @@ -41,8 +45,11 @@ An owner JWT may hold `property_ids=[A, B]`. Still required: 2. `POST /reservations` with `propertyId=A` and a `guestId` only linked at B → **404** 3. SPA property switch clears cached detail data; detail routes key by `propertyId` -These owner invariants are checklist items in v1 (exercise manually or with your own scripts). -The CLI covers the two-user cross-tenant deny path above. +The CLI executes these probes automatically when all `OWNER_*` variables are +set. When they are absent it reports one clean `SKIP`, leaving the existing +two-user tenant probes unchanged. The create probe uses configurable future +dates (`OWNER_TEST_ARRIVAL` / `OWNER_TEST_DEPARTURE`) and must return **404** +before any booking or reservation is inserted. ## After changes diff --git a/ops/harden/cli/harden.mjs b/ops/harden/cli/harden.mjs index b8589f0c..450de9e0 100644 --- a/ops/harden/cli/harden.mjs +++ b/ops/harden/cli/harden.mjs @@ -14,6 +14,7 @@ import { runLocalFileProbes } from './probes/local.mjs'; import { runHealthProbes } from './probes/health.mjs'; import { runAuthOnProbes } from './probes/auth-on.mjs'; import { runTenantIsolationProbes } from './probes/tenant-isolation.mjs'; +import { runOwnerIsolationProbes } from './probes/owner-isolation.mjs'; function usage() { console.log(`Usage: haip-harden @@ -32,6 +33,7 @@ async function runLive() { results.push(...(await runHealthProbes())); results.push(...(await runAuthOnProbes())); results.push(...(await runTenantIsolationProbes())); + results.push(...(await runOwnerIsolationProbes())); return results; } @@ -56,6 +58,7 @@ async function runLocal() { detail: 'TOKEN_A/B + PROPERTY_A/B not all set — skipped live tenant probes', }); } + results.push(...(await runOwnerIsolationProbes())); } else { results.push({ id: 'http-optional', diff --git a/ops/harden/cli/lib.mjs b/ops/harden/cli/lib.mjs index 769fdf64..92821dd8 100644 --- a/ops/harden/cli/lib.mjs +++ b/ops/harden/cli/lib.mjs @@ -30,7 +30,7 @@ export function timeoutMs() { /** * @param {string} path - path under API base, e.g. `/v1/health` - * @param {{ method?: string, token?: string | null, headers?: Record }} [opts] + * @param {{ method?: string, token?: string | null, headers?: Record, body?: unknown }} [opts] */ export async function request(path, opts = {}) { const base = apiBase(); @@ -39,9 +39,17 @@ export async function request(path, opts = {}) { if (opts.token) { headers.Authorization = `Bearer ${opts.token}`; } + if (opts.body !== undefined && !headers['Content-Type']) { + headers['Content-Type'] = 'application/json'; + } const res = await fetch(url, { method: opts.method ?? 'GET', headers, + body: opts.body === undefined + ? undefined + : typeof opts.body === 'string' + ? opts.body + : JSON.stringify(opts.body), signal: AbortSignal.timeout(timeoutMs()), }); let bodyText = ''; diff --git a/ops/harden/cli/probes/local.mjs b/ops/harden/cli/probes/local.mjs index c7cd77e4..d1d5d9fd 100644 --- a/ops/harden/cli/probes/local.mjs +++ b/ops/harden/cli/probes/local.mjs @@ -52,6 +52,16 @@ export async function runLocalFileProbes() { id: 'env:REDIS_URL', re: /^\s*REDIS_URL\s*=\s*.+/m, }, + { + id: 'env:KEYCLOAK_PUBLIC_URL', + re: /^\s*KEYCLOAK_PUBLIC_URL\s*=\s*https:\/\/.+/m, + }, + { + id: 'env:no-placeholders', + re: null, + ok: !/REPLACE_(WITH_|ME)/.test(text), + detailFail: 'replace every example secret placeholder before production', + }, ]; for (const c of checks) { if (c.re) { @@ -91,6 +101,24 @@ export async function runLocalFileProbes() { ? 'docker-compose.prod.yml sets AUTH_ENABLED=true' : 'docker-compose.prod.yml should set AUTH_ENABLED=true', }); + const keycloakProduction = /^\s*command:\s*start\s+--import-realm\s*$/m.test(text); + results.push({ + id: 'compose:keycloak-production', + ok: keycloakProduction, + detail: keycloakProduction + ? 'production overlay replaces Keycloak start-dev' + : 'production overlay must set Keycloak command: start --import-realm', + }); + const publicAuthIsConfigurable = + /VITE_KEYCLOAK_URL:\s*\$\{KEYCLOAK_PUBLIC_URL:/.test(text) + && !/VITE_KEYCLOAK_URL:\s*http:\/\/localhost/.test(text); + results.push({ + id: 'compose:keycloak-public-url', + ok: publicAuthIsConfigurable, + detail: publicAuthIsConfigurable + ? 'dashboard auth origin comes from KEYCLOAK_PUBLIC_URL' + : 'production dashboard must not bake a localhost Keycloak URL', + }); } // Datastores and Keycloak must not be published on all interfaces. Docker's diff --git a/ops/harden/cli/probes/owner-isolation.mjs b/ops/harden/cli/probes/owner-isolation.mjs new file mode 100644 index 00000000..76a8a79c --- /dev/null +++ b/ops/harden/cli/probes/owner-isolation.mjs @@ -0,0 +1,108 @@ +import { env, request, statusIn } from '../lib.mjs'; + +const REQUIRED_OWNER_ENV = [ + 'OWNER_TOKEN', + 'OWNER_PROPERTY_A', + 'OWNER_PROPERTY_B', + 'OWNER_RESERVATION_IN_B', + 'OWNER_GUEST_IN_B', + 'OWNER_ROOM_TYPE_IN_A', + 'OWNER_RATE_PLAN_IN_A', +]; + +/** + * Same-user, multi-property confused-deputy probes. The owner token is expected + * to contain both property ids, so authorization alone cannot catch an entity + * id from B paired with propertyId A; repository/service scoping must return 404. + * + * @returns {Promise} + */ +export async function runOwnerIsolationProbes() { + const missing = REQUIRED_OWNER_ENV.filter((name) => !env(name)); + if (missing.length > 0) { + return [{ + id: 'owner-isolation', + ok: true, + skip: true, + detail: `owner multi-property env not complete — missing ${missing.join(', ')}`, + }]; + } + + const token = env('OWNER_TOKEN'); + const propertyA = env('OWNER_PROPERTY_A'); + const propertyB = env('OWNER_PROPERTY_B'); + const reservationInB = env('OWNER_RESERVATION_IN_B'); + const guestInB = env('OWNER_GUEST_IN_B'); + const roomTypeInA = env('OWNER_ROOM_TYPE_IN_A'); + const ratePlanInA = env('OWNER_RATE_PLAN_IN_A'); + /** @type {import('../lib.mjs').ProbeResult[]} */ + const results = []; + + if (propertyA === propertyB) { + return [{ + id: 'owner-env', + ok: false, + detail: 'OWNER_PROPERTY_A and OWNER_PROPERTY_B must be different UUIDs', + }]; + } + + try { + const res = await request( + `/v1/reservations/${encodeURIComponent(reservationInB)}?propertyId=${encodeURIComponent(propertyA)}`, + { token }, + ); + const ok = statusIn(res.status, [404]); + results.push({ + id: 'owner-b-id-scoped-as-a', + ok, + detail: ok + ? `B reservation + propertyId=A → ${res.status}` + : `expected 404, got ${res.status}`, + }); + } catch (err) { + results.push({ + id: 'owner-b-id-scoped-as-a', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + const arrivalDate = env('OWNER_TEST_ARRIVAL', '2099-01-01'); + const departureDate = env('OWNER_TEST_DEPARTURE', '2099-01-02'); + try { + const res = await request('/v1/reservations', { + method: 'POST', + token, + body: { + propertyId: propertyA, + guestId: guestInB, + arrivalDate, + departureDate, + roomTypeId: roomTypeInA, + ratePlanId: ratePlanInA, + totalAmount: env('OWNER_TEST_TOTAL', '100.00'), + currencyCode: env('OWNER_TEST_CURRENCY', 'USD'), + adults: 1, + children: 0, + source: 'direct', + channelCode: 'harden_owner_probe', + }, + }); + const ok = statusIn(res.status, [404]); + results.push({ + id: 'owner-b-guest-scoped-as-a', + ok, + detail: ok + ? `B-only guest + propertyId=A → ${res.status}` + : `expected 404, got ${res.status}; investigate guest ownership before go-live`, + }); + } catch (err) { + results.push({ + id: 'owner-b-guest-scoped-as-a', + ok: false, + detail: `request failed: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + return results; +} diff --git a/ops/harden/grafana-haip-overview.json b/ops/harden/grafana-haip-overview.json new file mode 100644 index 00000000..6519b6ec --- /dev/null +++ b/ops/harden/grafana-haip-overview.json @@ -0,0 +1,65 @@ +{ + "annotations": { "list": [] }, + "editable": true, + "graphTooltip": 1, + "panels": [ + { + "id": 1, + "title": "HTTP requests / second", + "type": "timeseries", + "targets": [{ "expr": "sum by (route) (rate(haip_http_requests_total[5m]))", "legendFormat": "{{route}}" }], + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 } + }, + { + "id": 2, + "title": "HTTP 5xx / second", + "type": "timeseries", + "targets": [{ "expr": "sum by (route) (rate(haip_http_request_errors_total[5m]))", "legendFormat": "{{route}}" }], + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 } + }, + { + "id": 3, + "title": "Average request latency", + "type": "timeseries", + "targets": [{ "expr": "sum by (route) (rate(haip_http_request_duration_seconds_sum[5m])) / clamp_min(sum by (route) (rate(haip_http_request_duration_seconds_count[5m])), 0.001)", "legendFormat": "{{route}}" }], + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 } + }, + { + "id": 4, + "title": "Booking create outcomes", + "type": "timeseries", + "targets": [{ "expr": "sum by (outcome) (rate(haip_booking_create_total[5m]))", "legendFormat": "{{outcome}}" }], + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 } + }, + { + "id": 5, + "title": "Channel sync outcomes", + "type": "timeseries", + "targets": [{ "expr": "sum by (outcome) (increase(haip_channel_sync_total[15m]))", "legendFormat": "{{outcome}}" }], + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 } + }, + { + "id": 6, + "title": "Last night-audit business date", + "type": "stat", + "fieldConfig": { "defaults": { "unit": "dateTimeAsIso" }, "overrides": [] }, + "targets": [{ "expr": "haip_night_audit_last_business_date_timestamp_seconds * 1000", "legendFormat": "business date" }], + "gridPos": { "h": 8, "w": 6, "x": 12, "y": 16 } + }, + { + "id": 7, + "title": "Exhausted webhooks", + "type": "stat", + "targets": [{ "expr": "sum(increase(haip_webhook_delivery_failures_total[24h]))", "legendFormat": "24h" }], + "gridPos": { "h": 8, "w": 6, "x": 18, "y": 16 } + } + ], + "schemaVersion": 39, + "tags": ["haip", "hotel", "operations"], + "templating": { "list": [] }, + "time": { "from": "now-24h", "to": "now" }, + "timezone": "browser", + "title": "HAIP Production Overview", + "uid": "haip-production", + "version": 1 +} diff --git a/ops/harden/prometheus-alerts.yml b/ops/harden/prometheus-alerts.yml new file mode 100644 index 00000000..cd21f1f6 --- /dev/null +++ b/ops/harden/prometheus-alerts.yml @@ -0,0 +1,41 @@ +groups: + - name: haip-production + rules: + - alert: HaipBookingCreateErrorRate + expr: | + sum(rate(haip_booking_create_total{outcome="failed"}[10m])) + / + clamp_min(sum(rate(haip_booking_create_total[10m])), 0.001) > 0.05 + for: 10m + labels: + severity: critical + annotations: + summary: HAIP booking creation error rate is above 5% + description: Check API errors, inventory locks, payment gateway, and database health. + + - alert: HaipChannelSyncFailed + expr: increase(haip_channel_sync_total{outcome="failed"}[10m]) > 0 + for: 5m + labels: + severity: critical + annotations: + summary: HAIP channel synchronization failed + description: Inventory or rates may be stale on an OTA. Open Channels and retry/reconcile. + + - alert: HaipNightAuditMissing + expr: haip_night_audit_last_business_date_timestamp_seconds > 0 and (time() - haip_night_audit_last_business_date_timestamp_seconds) > 129600 + for: 30m + labels: + severity: warning + annotations: + summary: HAIP night audit business date is more than 36 hours old + description: Verify the audit schedule and complete the missing business date. + + - alert: HaipWebhookDeliveryExhausted + expr: increase(haip_webhook_delivery_failures_total[10m]) > 0 + for: 1m + labels: + severity: critical + annotations: + summary: A HAIP webhook exhausted all retry attempts + description: Re-deliver or manually reconcile mandatory downstream integrations. diff --git a/ops/harden/vignettes/base-21-deposit-and-channel-alerts.md b/ops/harden/vignettes/base-21-deposit-and-channel-alerts.md new file mode 100644 index 00000000..562beffd --- /dev/null +++ b/ops/harden/vignettes/base-21-deposit-and-channel-alerts.md @@ -0,0 +1,22 @@ +# Base 21 — money and channel failures are visible at the desk + +## Deposit authorization fails during check-in + +1. Check in an assigned reservation using a test payment method that the gateway rejects. +2. Confirm that the guest still reaches `checked_in` under the default warn-and-continue policy. +3. Confirm that the API returns `depositAuth.status=failed` with the safe code + `DEPOSIT_AUTHORIZATION_FAILED`. +4. Confirm that the dashboard shows an error toast and the notification bell contains a + critical `deposit_authorization_failed` item for the same property and reservation. +5. Retry authorization from the folio or record the supervisor-approved override in the + shift handover process. Never treat a successful check-in response as proof of a hold. + +## Availability/rate sync fails + +1. Make a test channel adapter return a failed ARI result. +2. Confirm that Channels records `lastSyncStatus=failed` and a truncated safe error. +3. Confirm that the notification bell contains one critical `channel_sync_failed` item + for the correct property and connection. +4. Retry while the connection remains failed; no additional notification should be created. +5. Restore the adapter and run a successful sync; confirm a `channel.sync_completed` + recovery event and reconcile OTA inventory before reopening sales.