diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ff1f0..4b95cf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ All notable changes to HAIP are documented here. This project adheres to > Version numbers and release tags are assigned automatically by the release > workflow on merge — this section is intentionally left as _Unreleased_. +### Added — DerbySoft Property Connector adapter + +- **DerbySoft channel adapter** (`adapterType: derbysoft`) — REST/JSON + OAuth Bearer, + 15 req/s client limiter, Delta/Overlay ARI (inventory/rate/availability), property + profile sync, inbound LiveCheck/Book/Modify/Cancel/Ping with Bearer auth. +- **Mock PC server** on `:4002` (`tools/mock-derbysoft`, compose profile `channels`). +- **Docs + partner checklist** — `docs/channels/derbysoft.md` (`pms.service@derbysoft.net`). +- PCI: inbound payment card fields stripped before persistence. + ### Added — Multi-arch GHCR images + VPS/cloud deploy guides - **Multi-platform GHCR publish** — release workflow builds `linux/amd64` and diff --git a/README.md b/README.md index d6731c9..0fc4c50 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ NestJS PostgreSQL Apache 2.0 License - 1129 Tests Passing + 1151 Tests Passing 12 AI Agents

@@ -133,7 +133,7 @@ graph TB - **Multi-tenant from day one** — `property_id` on every table, designed for portfolio operators managing multiple hotels - **Event-driven** — Webhook events on every state change (`reservation.created`, `folio.charge_posted`, `room.status_changed`). Build anything on top. - **AI agents as first-class citizens** — 12 built-in agents with a common interface: `analyze() → recommend() → execute()`, coordinated by a Revenue Manager orchestrator. Three operating modes: manual, suggest, autopilot. Decision logging for continuous learning. -- **ChannelAdapter pattern** — Same abstraction as OTAIP's ConnectAdapter. Booking.com + Expedia (EQC) direct adapters and a SiteMinder aggregator for 450+ OTA reach — distributing **both** ARI and descriptive content (photos/descriptions/amenities). +- **ChannelAdapter pattern** — Same abstraction as OTAIP's ConnectAdapter. Booking.com + Expedia (EQC) direct adapters plus SiteMinder and DerbySoft aggregators for 450+ OTA reach — distributing **both** ARI and descriptive content (photos/descriptions/amenities). - **Layered RBAC** — Keycloak JWT authentication **plus HAIP's own local users, roles & permissions**: a code-defined permission catalog, operator-defined custom roles, and guards (`@Roles` + `@RequirePermissions`) on every endpoint. - **Polymorphic media** — One image model for properties, room types & rooms; add by URL (zero infra) or upload to S3/MinIO, with one enforced primary per owner. - **Compliance as infrastructure** — PCI tokenization (Stripe), GDPR audit trails, jurisdiction-based tax calculation, guest registration per jurisdiction. Not bolted on — built in. @@ -341,6 +341,7 @@ executes; approval always runs the agent's own recommendation. Off by default | **Booking.com** | Direct integration | OTA XML for ARI + inbound reservation webhooks/cancellations; JSON **Photo API** for content (photos with validation, plus room/property descriptions & amenities). | | **Expedia (EQC)** | Direct integration | EQC Availability & Rates (XML) for ARI, Booking Notification push for inbound reservations, and the **Image API** for content (with Expedia's own image limits). | | **SiteMinder** | Aggregator (pmsXchange) | SOAP / OTA XML. Connect once, distribute to 450+ OTAs — ARI push, reservation delivery, rate parity. (Content is managed in the SiteMinder extranet — no PMS content push.) | +| **DerbySoft** | Aggregator (Property Connector) | REST/JSON + OAuth Bearer. ARI (inventory/rate/availability) with Delta/Overlay, property profile sync, inbound LiveCheck/Book/Modify/Cancel. See [`docs/channels/derbysoft.md`](./docs/channels/derbysoft.md). | ### Payments (Stripe) - PCI DSS compliant — never stores raw card data @@ -424,10 +425,10 @@ executes; approval always runs the agent's own recommendation. Off by default | API Spec | OpenAPI 3.0 (auto-generated) | Swagger UI at `/docs` | | Auth | Keycloak (OAuth 2.0 / OIDC) | Identity provider, JWT, RBAC | | Payments | Stripe | PCI DSS compliant payment processing | -| OTA Channels | Booking.com + Expedia (EQC) direct + SiteMinder (pmsXchange) | Direct + aggregated OTA connectivity (ARI + content) | +| 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 (1129 tests across 136 test files) | Unit and integration tests | +| Testing | Vitest (1151 tests across 139 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 | @@ -549,7 +550,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment. ### Run tests ```bash -# All tests (1129 tests across 136 test files) +# All tests (1151 tests across 139 test files) pnpm test # API tests only @@ -620,7 +621,8 @@ haip/ │ │ │ │ └── adapters/ # OTA channel adapters │ │ │ │ ├── booking-com/ # Booking.com (OTA XML + Photo API) │ │ │ │ ├── expedia/ # Expedia EQC (AR XML + Image API) -│ │ │ │ └── siteminder/ # SiteMinder pmsXchange (SOAP/OTA XML) +│ │ │ │ ├── siteminder/ # SiteMinder pmsXchange (SOAP/OTA XML) +│ │ │ │ └── derbysoft/ # DerbySoft Property Connector (REST/JSON) │ │ │ ├── connect/ # OTAIP agent API layer │ │ │ ├── media/ # Images for property / room types / rooms (URL + S3) │ │ │ ├── events/ # WebSocket gateway @@ -1065,7 +1067,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 (1129 tests, 136 files) +pnpm test # Run all tests (1151 tests, 139 files) pnpm typecheck # TypeScript strict check pnpm lint # ESLint ``` diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft-inbound.controller.spec.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft-inbound.controller.spec.ts new file mode 100644 index 0000000..25594ef --- /dev/null +++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft-inbound.controller.spec.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DerbySoftInboundController } from './derbysoft-inbound.controller'; + +describe('DerbySoftInboundController', () => { + let controller: DerbySoftInboundController; + let inboundReservationService: { processInboundReservation: ReturnType }; + let availabilityService: { searchAvailability: ReturnType }; + let dbSelect: ReturnType; + + const connection = { + id: 'conn-1', + propertyId: 'prop-1', + adapterType: 'derbysoft', + status: 'active', + config: { + hotelId: 'HOTEL1', + inboundAuth: { bearerToken: 'secret' }, + }, + roomTypeMapping: [{ roomTypeId: 'rt-1', channelRoomCode: 'KING' }], + ratePlanMapping: [{ ratePlanId: 'rp-1', channelRateCode: 'BAR' }], + }; + + beforeEach(() => { + inboundReservationService = { + processInboundReservation: vi.fn().mockResolvedValue({ confirmationNumber: 'HAIP-99' }), + }; + availabilityService = { + searchAvailability: vi.fn().mockResolvedValue([ + { date: '2026-05-01', available: 3 }, + { date: '2026-05-02', available: 2 }, + ]), + }; + dbSelect = vi.fn().mockReturnValue({ + from: () => ({ + where: async () => [connection], + }), + }); + + controller = new DerbySoftInboundController( + { select: dbSelect } as any, + inboundReservationService as any, + availabilityService as any, + { + get: (key: string, def?: string) => (key === 'AUTH_ENABLED' ? 'true' : def), + } as any, + ); + }); + + function mockRes() { + const res: any = { + statusCode: 200, + body: null, + status(code: number) { + this.statusCode = code; + return this; + }, + json(body: unknown) { + this.body = body; + return this; + }, + }; + return res; + } + + it('rejects book without matching hotelId', async () => { + const res = mockRes(); + await controller.book( + { + headers: { authorization: 'Bearer secret' }, + body: { + header: { echoToken: 'e1' }, + hotelId: 'UNKNOWN', + reservationIds: { derbyResId: 'DR1', distributorResId: 'D1' }, + stayRange: { checkin: '2026-05-01', checkout: '2026-05-03' }, + contactPerson: { firstName: 'A', lastName: 'B' }, + roomCriteria: { adultCount: 1 }, + total: { amountAfterTax: '100' }, + roomRate: { roomId: 'KING', rateId: 'BAR', currency: 'USD' }, + guests: [{ firstName: 'A', lastName: 'B' }], + distributorId: 'CTRIP', + }, + }, + res, + ); + // find returns connection only when hotelId matches — UNKNOWN → null + dbSelect.mockReturnValue({ + from: () => ({ + where: async () => [connection], + }), + }); + // Re-run with find that won't match — connection hotelId is HOTEL1 + expect(res.statusCode).toBe(500); + expect(res.body.errorCode).toBe('InvalidField'); + }); + + it('rejects unauthorized bearer', async () => { + const res = mockRes(); + await controller.book( + { + headers: { authorization: 'Bearer wrong' }, + body: { + header: { echoToken: 'e1' }, + hotelId: 'HOTEL1', + reservationIds: { derbyResId: 'DR1', distributorResId: 'D1' }, + stayRange: { checkin: '2026-05-01', checkout: '2026-05-03' }, + contactPerson: { firstName: 'A', lastName: 'B' }, + roomCriteria: { adultCount: 1 }, + total: { amountAfterTax: '100' }, + roomRate: { roomId: 'KING', rateId: 'BAR', currency: 'USD' }, + guests: [{ firstName: 'A', lastName: 'B' }], + distributorId: 'CTRIP', + }, + }, + res, + ); + expect(res.statusCode).toBe(401); + }); + + it('books when hotel + bearer match', async () => { + const res = mockRes(); + await controller.book( + { + headers: { authorization: 'Bearer secret' }, + body: { + header: { echoToken: 'e1' }, + hotelId: 'HOTEL1', + reservationIds: { derbyResId: 'DR1', distributorResId: 'D1' }, + stayRange: { checkin: '2026-05-01', checkout: '2026-05-03' }, + contactPerson: { firstName: 'A', lastName: 'B' }, + roomCriteria: { adultCount: 1 }, + total: { amountAfterTax: '100' }, + roomRate: { roomId: 'KING', rateId: 'BAR', currency: 'USD' }, + guests: [{ firstName: 'A', lastName: 'B' }], + distributorId: 'CTRIP', + payment: { cardNumber: '4111111111111111' }, + }, + }, + res, + ); + expect(res.statusCode).toBe(200); + expect(res.body.reservationIds.supplierResId).toBe('HAIP-99'); + expect(inboundReservationService.processInboundReservation).toHaveBeenCalled(); + const mapped = inboundReservationService.processInboundReservation.mock.calls[0]![1]; + expect(mapped.rawPayload).not.toHaveProperty('payment'); + }); + + it('liveCheck returns roomRates from availability', async () => { + const res = mockRes(); + await controller.liveCheck( + { + headers: { authorization: 'Bearer secret' }, + body: { + header: { echoToken: 'e1' }, + hotelId: 'HOTEL1', + stayRange: { checkin: '2026-05-01', checkout: '2026-05-03' }, + }, + }, + res, + ); + expect(res.statusCode).toBe(200); + expect(res.body.roomRates).toHaveLength(1); + expect(res.body.roomRates[0].inventory.availableInvCount).toBe(2); + }); +}); diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft-inbound.controller.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft-inbound.controller.ts new file mode 100644 index 0000000..083deb4 --- /dev/null +++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft-inbound.controller.ts @@ -0,0 +1,284 @@ +import { + Controller, + Post, + Req, + Res, + Logger, + Inject, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ApiTags, ApiOperation, ApiExcludeEndpoint } from '@nestjs/swagger'; +import { and, eq } from 'drizzle-orm'; +import { channelConnections } from '@telivityhaip/database'; +import { Public } from '../../../auth/public.decorator'; +import { DRIZZLE } from '../../../../database/database.module'; +import { InboundReservationService } from '../../inbound-reservation.service'; +import { AvailabilityService } from '../../../reservation/availability.service'; +import { + verifyBearerAuth, + getInboundAuth, + type InboundBearerAuth, +} from '../inbound-auth.util'; +import { + mapDerbySoftReservationToHaip, + mapCancelToHaip, + buildBookResponse, + buildCancelResponse, + buildErrorResponse, + buildHeader, +} from './derbysoft.mapper'; + +/** + * DerbySoft Property Connector → PMS webhooks (PUSH/PUSH). + * + * Paths mirror the PC Integration API (Live Check / Book / Modify / Cancel / Ping). + * Auth: Bearer token against the resolved connection's `config.inboundAuth.bearerToken`. + * Routing: `hotelId` in body must match connection `config.hotelId`. + * + * PCI: payment card fields from Book/Modify are never persisted (stripped in mapper). + */ +@ApiTags('Channel Manager — DerbySoft') +@Controller('channels/inbound/derbysoft') +@Public() +export class DerbySoftInboundController { + private readonly logger = new Logger(DerbySoftInboundController.name); + + constructor( + @Inject(DRIZZLE) private readonly db: any, + private readonly inboundReservationService: InboundReservationService, + private readonly availabilityService: AvailabilityService, + private readonly configService: ConfigService, + ) {} + + private get authEnabled(): boolean { + return this.configService.get('AUTH_ENABLED', 'true') !== 'false'; + } + + private isAuthorizedFor(connection: any, authHeader: string | undefined): boolean { + if (!this.authEnabled) return true; + const stored = getInboundAuth(connection.config); + return verifyBearerAuth(authHeader, stored); + } + + private async findConnection(hotelId: string | undefined) { + if (!hotelId) return null; + const rows = await this.db + .select() + .from(channelConnections) + .where( + and( + eq(channelConnections.adapterType, 'derbysoft'), + eq(channelConnections.status, 'active'), + ), + ); + return ( + rows.find((c: any) => { + const cfg = (c.config ?? {}) as Record; + return cfg['hotelId'] === hotelId || cfg['hotelCode'] === hotelId; + }) ?? null + ); + } + + private async readJson(req: any): Promise | null> { + if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) { + return req.body as Record; + } + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw) return null; + try { + return JSON.parse(raw) as Record; + } catch { + return null; + } + } + + private echoToken(body: Record | null): string | undefined { + const header = body?.['header'] as Record | undefined; + return header?.['echoToken'] != null ? String(header['echoToken']) : undefined; + } + + @Post('ping') + @ApiOperation({ summary: 'DerbySoft Ping' }) + @ApiExcludeEndpoint() + async ping(@Req() req: any, @Res() res: any) { + const body = (await this.readJson(req)) ?? {}; + return res.status(200).json({ + header: { ...buildHeader(this.echoToken(body)), timeStamp: new Date().toISOString() }, + }); + } + + /** + * Live Check — real-time availability for a stay range. + * Returns product-level open/close based on HAIP AvailabilityService. + */ + @Post('availability') + @ApiOperation({ summary: 'DerbySoft Live Check' }) + @ApiExcludeEndpoint() + async liveCheck(@Req() req: any, @Res() res: any) { + const body = await this.readJson(req); + if (!body) { + return res.status(500).json(buildErrorResponse(undefined, 'InvalidField', 'Empty body')); + } + + const hotelId = body['hotelId'] != null ? String(body['hotelId']) : undefined; + const connection = await this.findConnection(hotelId); + const authHeader = (req.headers?.['authorization'] ?? req.headers?.['Authorization']) as + | string + | undefined; + + if (!connection) { + return res + .status(500) + .json(buildErrorResponse(this.echoToken(body), 'InvalidField', 'Unknown hotelId')); + } + if (!this.isAuthorizedFor(connection, authHeader)) { + return res.status(401).json(buildErrorResponse(this.echoToken(body), 'Unauthorized', 'Unauthorized')); + } + + const stayRange = (body['stayRange'] ?? {}) as Record; + const checkin = String(stayRange['checkin'] ?? ''); + const checkout = String(stayRange['checkout'] ?? ''); + const roomMappings = (connection.roomTypeMapping ?? []) as Array<{ + roomTypeId: string; + channelRoomCode: string; + }>; + const rateMappings = (connection.ratePlanMapping ?? []) as Array<{ + ratePlanId: string; + channelRateCode: string; + }>; + + const roomRates: Array> = []; + for (const room of roomMappings) { + try { + const avail = await this.availabilityService.searchAvailability( + connection.propertyId, + checkin, + checkout, + room.roomTypeId, + ); + const minAvail = avail.length + ? Math.min(...avail.map((a: { available: number }) => a.available)) + : 0; + for (const rate of rateMappings) { + roomRates.push({ + roomId: room.channelRoomCode, + rateId: rate.channelRateCode, + inventory: { availableInvCount: Math.max(0, minAvail) }, + status: minAvail > 0 ? 'Open' : 'Close', + }); + } + } catch (err: any) { + this.logger.warn(`LiveCheck availability failed for ${room.channelRoomCode}: ${err?.message}`); + } + } + + return res.status(200).json({ + header: { ...buildHeader(this.echoToken(body)), timeStamp: new Date().toISOString() }, + hotelId, + roomRates, + }); + } + + @Post('book') + @ApiOperation({ summary: 'DerbySoft Book' }) + @ApiExcludeEndpoint() + async book(@Req() req: any, @Res() res: any) { + return this.handleReservation(req, res, 'new'); + } + + @Post('modify') + @ApiOperation({ summary: 'DerbySoft Modify' }) + @ApiExcludeEndpoint() + async modify(@Req() req: any, @Res() res: any) { + return this.handleReservation(req, res, 'modified'); + } + + @Post('cancel') + @ApiOperation({ summary: 'DerbySoft Cancel' }) + @ApiExcludeEndpoint() + async cancel(@Req() req: any, @Res() res: any) { + const body = await this.readJson(req); + if (!body) { + return res.status(500).json(buildErrorResponse(undefined, 'InvalidField', 'Empty body')); + } + + const hotelId = body['hotelId'] != null ? String(body['hotelId']) : undefined; + const connection = await this.findConnection(hotelId); + const authHeader = (req.headers?.['authorization'] ?? req.headers?.['Authorization']) as + | string + | undefined; + + if (!connection) { + return res + .status(500) + .json(buildErrorResponse(this.echoToken(body), 'InvalidField', 'Unknown hotelId')); + } + if (!this.isAuthorizedFor(connection, authHeader)) { + return res.status(401).json(buildErrorResponse(this.echoToken(body), 'Unauthorized', 'Unauthorized')); + } + + try { + const reservation = mapCancelToHaip(body); + const result = await this.inboundReservationService.processInboundReservation( + connection.id, + reservation, + ); + return res.status(200).json(buildCancelResponse(body, result.confirmationNumber)); + } catch (err: any) { + this.logger.error(`DerbySoft cancel failed: ${err?.message}`); + return res + .status(500) + .json(buildErrorResponse(this.echoToken(body), 'BusinessError', err?.message ?? 'Cancel failed')); + } + } + + private async handleReservation( + req: any, + res: any, + status: 'new' | 'modified', + ) { + const body = await this.readJson(req); + if (!body) { + return res.status(500).json(buildErrorResponse(undefined, 'InvalidField', 'Empty body')); + } + + const hotelId = body['hotelId'] != null ? String(body['hotelId']) : undefined; + const connection = await this.findConnection(hotelId); + const authHeader = (req.headers?.['authorization'] ?? req.headers?.['Authorization']) as + | string + | undefined; + + if (!connection) { + return res + .status(500) + .json(buildErrorResponse(this.echoToken(body), 'InvalidField', 'Unknown hotelId')); + } + if (!this.isAuthorizedFor(connection, authHeader)) { + return res.status(401).json(buildErrorResponse(this.echoToken(body), 'Unauthorized', 'Unauthorized')); + } + + try { + const reservation = mapDerbySoftReservationToHaip(body, status); + const result = await this.inboundReservationService.processInboundReservation( + connection.id, + reservation, + ); + return res.status(200).json(buildBookResponse(body, result.confirmationNumber)); + } catch (err: any) { + this.logger.error(`DerbySoft ${status} failed: ${err?.message}`); + return res + .status(500) + .json( + buildErrorResponse( + this.echoToken(body), + 'BusinessError', + err?.message ?? `${status} failed`, + ), + ); + } + } +} diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.adapter.spec.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.adapter.spec.ts new file mode 100644 index 0000000..f1bf62f --- /dev/null +++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.adapter.spec.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { DerbySoftAdapter } from './derbysoft.adapter'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function jsonResponse(body: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + json: async () => body, + text: async () => JSON.stringify(body), + }; +} + +describe('DerbySoftAdapter', () => { + let adapter: DerbySoftAdapter; + + beforeEach(async () => { + vi.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DerbySoftAdapter, + { + provide: ConfigService, + useValue: { + get: (key: string, def?: string) => { + const config: Record = { + DERBYSOFT_HOTEL_ID: 'MOCK_DS_HOTEL', + DERBYSOFT_ACCOUNT_ID: 'haip_test', + DERBYSOFT_CLIENT_SECRET: 'test_password', + DERBYSOFT_TUNNEL_BASE_URL: + 'http://localhost:4002/pcapigateway/tunnel/{accountId}', + DERBYSOFT_PROFILE_BASE_URL: + 'http://localhost:4002/pcapigateway/profile/{accountId}', + DERBYSOFT_TOKEN_URL: 'http://localhost:4002/pcapigateway/account/token', + // Allow localhost in tests (assertSafeChannelEndpoint) + NODE_ENV: 'test', + CHANNEL_ALLOW_PRIVATE_ENDPOINTS: 'true', + }; + return config[key] ?? def; + }, + }, + }, + ], + }).compile(); + adapter = module.get(DerbySoftAdapter); + }); + + function mockTokenThen(...responses: ReturnType[]) { + mockFetch + .mockResolvedValueOnce(jsonResponse({ accessToken: 'tok123', tokenType: 'Bearer' })) + .mockImplementation(async () => { + const next = responses.shift(); + return next ?? jsonResponse({ header: {} }); + }); + } + + it('has adapterType derbysoft', () => { + expect(adapter.adapterType).toBe('derbysoft'); + }); + + it('pushAvailability obtains token and posts inventory', async () => { + mockTokenThen(jsonResponse({ header: { echoToken: 'e1' } })); + + const result = await adapter.pushAvailability({ + propertyId: 'p1', + channelConnectionId: 'c1', + connectionConfig: { CHANNEL_ALLOW_PRIVATE_ENDPOINTS: true }, + items: [ + { channelRoomCode: 'KING', date: '2026-04-01', available: 5, totalInventory: 10 }, + ], + }); + + expect(result.success).toBe(true); + expect(result.itemsSynced).toBe(1); + expect(mockFetch).toHaveBeenCalled(); + const tokenCall = mockFetch.mock.calls[0]!; + expect(String(tokenCall[0])).toContain('/account/token'); + expect(tokenCall[1].headers.Authorization).toMatch(/^Basic /); + const invCall = mockFetch.mock.calls[1]!; + expect(String(invCall[0])).toContain('/inventory'); + expect(invCall[1].headers.Authorization).toBe('Bearer tok123'); + const body = JSON.parse(invCall[1].body); + expect(body.roomId).toBe('KING'); + expect(body.type).toBe('Delta'); + }); + + it('pushRates posts Overlay when configured', async () => { + mockTokenThen(jsonResponse({ header: {} })); + + const result = await adapter.pushRates({ + propertyId: 'p1', + channelConnectionId: 'c1', + connectionConfig: { ariUpdateType: 'Overlay' }, + items: [ + { + channelRoomCode: 'KING', + channelRateCode: 'BAR', + date: '2026-04-01', + amount: 100, + currencyCode: 'USD', + }, + ], + }); + + expect(result.success).toBe(true); + const body = JSON.parse(mockFetch.mock.calls[1]![1].body); + expect(body.type).toBe('Overlay'); + expect(String(mockFetch.mock.calls[1]![0])).toContain('/rate'); + }); + + it('pushRestrictions posts to availability endpoint', async () => { + mockTokenThen(jsonResponse({ header: {} })); + + const result = await adapter.pushRestrictions({ + propertyId: 'p1', + channelConnectionId: 'c1', + items: [ + { + channelRoomCode: 'KING', + channelRateCode: 'BAR', + date: '2026-04-01', + stopSell: false, + closedToArrival: true, + closedToDeparture: false, + }, + ], + }); + + expect(result.success).toBe(true); + expect(String(mockFetch.mock.calls[1]![0])).toContain('/availability'); + }); + + it('syncProperty posts hotel and roomtype', async () => { + mockTokenThen(jsonResponse({ header: {} }), jsonResponse({ header: {} })); + + const result = await adapter.syncProperty({ + propertyId: 'p1', + channelConnectionId: 'c1', + property: { + name: 'Test Hotel', + images: [], + }, + roomTypes: [{ channelRoomCode: 'KING', name: 'King', images: [] }], + }); + + expect(result.success).toBe(true); + expect(result.itemsSynced).toBe(2); + expect(String(mockFetch.mock.calls[1]![0])).toContain('/hotel'); + expect(String(mockFetch.mock.calls[2]![0])).toContain('/roomtype'); + }); + + it('pullReservations returns empty (push model)', async () => { + const result = await adapter.pullReservations({ + propertyId: 'p1', + channelConnectionId: 'c1', + }); + expect(result.reservations).toEqual([]); + }); + + it('testConnection succeeds when token works', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ accessToken: 'tok', tokenType: 'Bearer' })); + const result = await adapter.testConnection({}); + expect(result.connected).toBe(true); + }); + + it('confirmReservation posts resStatus', async () => { + mockTokenThen(jsonResponse({ header: {} })); + const result = await adapter.confirmReservation({ + channelConnectionId: 'c1', + externalConfirmation: 'DR1', + pmsConfirmationNumber: 'HAIP-1', + }); + expect(result.success).toBe(true); + expect(String(mockFetch.mock.calls[1]![0])).toContain('/resStatus'); + }); +}); diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.adapter.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.adapter.ts new file mode 100644 index 0000000..99faf0d --- /dev/null +++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.adapter.ts @@ -0,0 +1,250 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { + ChannelAdapter, + AvailabilityPushParams, + RatePushParams, + RestrictionPushParams, + ContentPushParams, + ReservationPullParams, + ConfirmReservationParams, + CancelReservationParams, + ChannelSyncResult, + ChannelReservationResult, +} from '../../channel-adapter.interface'; +import { DEFAULT_DERBYSOFT_CONFIG, type DerbySoftConfig } from './derbysoft.config'; +import { DerbySoftClient } from './derbysoft.client'; +import { + mapAvailabilityToInventory, + mapRatesToDerbySoft, + mapRestrictionsToAvailability, + mapPropertyToHotelUpdate, + mapRoomTypesToUpdates, + buildHeader, + type AriUpdateType, +} from './derbysoft.mapper'; + +@Injectable() +export class DerbySoftAdapter implements ChannelAdapter { + readonly adapterType = 'derbysoft'; + private readonly logger = new Logger(DerbySoftAdapter.name); + + constructor(private readonly configService: ConfigService) {} + + private resolveConfig(connectionConfig?: Record): DerbySoftConfig { + const env = { + hotelId: this.configService.get('DERBYSOFT_HOTEL_ID', 'MOCK_DS_HOTEL'), + accountId: this.configService.get('DERBYSOFT_ACCOUNT_ID', 'haip_test'), + clientSecret: this.configService.get('DERBYSOFT_CLIENT_SECRET', 'test_password'), + tunnelBaseUrl: this.configService.get( + 'DERBYSOFT_TUNNEL_BASE_URL', + DEFAULT_DERBYSOFT_CONFIG.tunnelBaseUrl!, + ), + profileBaseUrl: this.configService.get( + 'DERBYSOFT_PROFILE_BASE_URL', + DEFAULT_DERBYSOFT_CONFIG.profileBaseUrl!, + ), + tokenUrl: this.configService.get( + 'DERBYSOFT_TOKEN_URL', + DEFAULT_DERBYSOFT_CONFIG.tokenUrl!, + ), + }; + + const cfg = { ...DEFAULT_DERBYSOFT_CONFIG, ...env, ...(connectionConfig ?? {}) } as DerbySoftConfig; + + // Accept hotelId aliases used elsewhere in HAIP channel configs. + if (!cfg.hotelId && typeof connectionConfig?.['hotelCode'] === 'string') { + cfg.hotelId = connectionConfig['hotelCode'] as string; + } + if (!cfg.accountId && typeof connectionConfig?.['clientId'] === 'string') { + cfg.accountId = connectionConfig['clientId'] as string; + } + + return cfg; + } + + private ariType(config: DerbySoftConfig, override?: AriUpdateType): AriUpdateType { + return override ?? config.ariUpdateType ?? 'Delta'; + } + + private client(config: DerbySoftConfig): DerbySoftClient { + return new DerbySoftClient(config); + } + + private async pushPayloads( + client: DerbySoftClient, + baseUrl: string, + path: string, + payloads: Array>, + itemLabel: string, + ): Promise { + const errors: Array<{ item: string; message: string }> = []; + let itemsSynced = 0; + const url = `${client.expandUrl(baseUrl).replace(/\/$/, '')}/${path}`; + + for (const payload of payloads) { + const res = await client.postJson(url, payload); + if (res.ok) { + itemsSynced += 1; + } else { + const code = String(res.data['errorCode'] ?? res.status); + const msg = String(res.data['errorMessage'] ?? `HTTP ${res.status}`); + errors.push({ item: itemLabel, message: `[${code}] ${msg}` }); + } + } + + return { success: errors.length === 0, itemsSynced, errors }; + } + + /** HAIP availability → PC Update Inventory. */ + async pushAvailability(params: AvailabilityPushParams): Promise { + const config = this.resolveConfig(params.connectionConfig); + const type = this.ariType(config, params.connectionConfig?.['ariUpdateType'] as AriUpdateType | undefined); + const payloads = mapAvailabilityToInventory(config.hotelId, params.items, type); + if (payloads.length === 0) return { success: true, itemsSynced: 0, errors: [] }; + return this.pushPayloads(this.client(config), config.tunnelBaseUrl, 'inventory', payloads, 'inventory'); + } + + async pushRates(params: RatePushParams): Promise { + const config = this.resolveConfig(params.connectionConfig); + const type = this.ariType(config, params.connectionConfig?.['ariUpdateType'] as AriUpdateType | undefined); + const payloads = mapRatesToDerbySoft(config.hotelId, params.items, type); + if (payloads.length === 0) return { success: true, itemsSynced: 0, errors: [] }; + return this.pushPayloads(this.client(config), config.tunnelBaseUrl, 'rate', payloads, 'rate'); + } + + /** HAIP restrictions → PC Update Availability (product-level). */ + async pushRestrictions(params: RestrictionPushParams): Promise { + const config = this.resolveConfig(params.connectionConfig); + const type = this.ariType(config, params.connectionConfig?.['ariUpdateType'] as AriUpdateType | undefined); + const payloads = mapRestrictionsToAvailability(config.hotelId, params.items, type); + if (payloads.length === 0) return { success: true, itemsSynced: 0, errors: [] }; + return this.pushPayloads( + this.client(config), + config.tunnelBaseUrl, + 'availability', + payloads, + 'availability', + ); + } + + /** + * Property + room-type profile sync via PC profile APIs. + * Photos are not pushed here (DerbySoft content images are channel-specific); + * descriptive hotel/room updates are sent. + */ + async pushContent(params: ContentPushParams): Promise { + return this.syncProperty(params); + } + + /** + * Explicit property sync (Update Hotel + Update RoomType). + * Used by POST /channels/connections/:id/sync-property. + */ + async syncProperty(params: ContentPushParams): Promise { + const config = this.resolveConfig(params.connectionConfig); + const client = this.client(config); + const profileBase = client.expandUrl(config.profileBaseUrl).replace(/\/$/, ''); + const errors: Array<{ item: string; message: string }> = []; + let itemsSynced = 0; + + const hotelBody = mapPropertyToHotelUpdate(config.hotelId, params.property); + const hotelRes = await client.postJson(`${profileBase}/hotel`, hotelBody); + if (hotelRes.ok) itemsSynced += 1; + else { + errors.push({ + item: 'hotel', + message: `[${hotelRes.data['errorCode'] ?? hotelRes.status}] ${hotelRes.data['errorMessage'] ?? 'failed'}`, + }); + } + + for (const roomBody of mapRoomTypesToUpdates(config.hotelId, params.roomTypes)) { + const roomRes = await client.postJson(`${profileBase}/roomtype`, roomBody); + if (roomRes.ok) itemsSynced += 1; + else { + errors.push({ + item: `roomtype:${roomBody['roomId']}`, + message: `[${roomRes.data['errorCode'] ?? roomRes.status}] ${roomRes.data['errorMessage'] ?? 'failed'}`, + }); + } + } + + return { success: errors.length === 0, itemsSynced, errors }; + } + + /** Reservations are push (Book/Modify/Cancel webhooks) — no pull. */ + async pullReservations(_params: ReservationPullParams): Promise { + return { success: true, reservations: [], errors: [] }; + } + + /** Update Reservation Status → PC /resStatus. */ + async confirmReservation(params: ConfirmReservationParams): Promise { + const config = this.resolveConfig(params.connectionConfig); + const client = this.client(config); + const url = `${client.expandUrl(config.tunnelBaseUrl).replace(/\/$/, '')}/resStatus`; + const body = { + header: buildHeader(), + hotelId: config.hotelId, + reservationIds: { + derbyResId: params.externalConfirmation, + supplierResId: params.pmsConfirmationNumber, + }, + status: 'Confirmed', + }; + const res = await client.postJson(url, body); + if (!res.ok) { + return { + success: false, + itemsSynced: 0, + errors: [ + { + item: 'resStatus', + message: `[${res.data['errorCode'] ?? res.status}] ${res.data['errorMessage'] ?? 'failed'}`, + }, + ], + }; + } + return { success: true, itemsSynced: 1, errors: [] }; + } + + async cancelReservation(params: CancelReservationParams): Promise { + const config = this.resolveConfig(params.connectionConfig); + const client = this.client(config); + const url = `${client.expandUrl(config.tunnelBaseUrl).replace(/\/$/, '')}/resStatus`; + const body = { + header: buildHeader(), + hotelId: config.hotelId, + reservationIds: { + derbyResId: params.externalConfirmation, + }, + status: 'Cancelled', + reason: params.reason, + }; + const res = await client.postJson(url, body); + if (!res.ok) { + return { + success: false, + itemsSynced: 0, + errors: [ + { + item: 'resStatus', + message: `[${res.data['errorCode'] ?? res.status}] ${res.data['errorMessage'] ?? 'failed'}`, + }, + ], + }; + } + return { success: true, itemsSynced: 1, errors: [] }; + } + + async testConnection(config: Record): Promise<{ connected: boolean; message: string }> { + try { + const resolved = this.resolveConfig(config); + const client = this.client(resolved); + await client.getAccessToken(); + return { connected: true, message: 'DerbySoft token obtained successfully' }; + } catch (err: any) { + this.logger.warn(`DerbySoft testConnection failed: ${err?.message}`); + return { connected: false, message: err?.message ?? 'Connection test failed' }; + } + } +} diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.client.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.client.ts new file mode 100644 index 0000000..997e98a --- /dev/null +++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.client.ts @@ -0,0 +1,157 @@ +import { Logger } from '@nestjs/common'; +import { assertSafeChannelEndpoint } from '../../../../common/security/url-guard'; +import { + DERBYSOFT_RATE_LIMIT_PER_SEC, + type DerbySoftConfig, +} from './derbysoft.config'; + +interface TokenCache { + accessToken: string; + /** Epoch ms when we should refresh (tokens last up to 90d; refresh early). */ + expiresAtMs: number; +} + +/** + * HTTP client for DerbySoft Property Connector with: + * - OAuth Bearer token (client credentials via Basic → /account/token) + * - 15 req/s token-bucket rate limiter + * - SSRF guard + redirect:manual + */ +export class DerbySoftClient { + private readonly logger = new Logger(DerbySoftClient.name); + private tokenCache: TokenCache | null = null; + private tokens = DERBYSOFT_RATE_LIMIT_PER_SEC; + private lastRefill = Date.now(); + + constructor(private readonly config: DerbySoftConfig) {} + + expandUrl(template: string): string { + return template.replaceAll('{accountId}', encodeURIComponent(this.config.accountId)); + } + + private async acquireRateToken(): Promise { + const now = Date.now(); + const elapsed = now - this.lastRefill; + if (elapsed >= 1000) { + this.tokens = DERBYSOFT_RATE_LIMIT_PER_SEC; + this.lastRefill = now; + } + if (this.tokens > 0) { + this.tokens -= 1; + return; + } + const waitMs = 1000 - (now - this.lastRefill) + 5; + await new Promise((r) => setTimeout(r, Math.max(waitMs, 5))); + this.tokens = DERBYSOFT_RATE_LIMIT_PER_SEC - 1; + this.lastRefill = Date.now(); + } + + async getAccessToken(): Promise { + if (this.tokenCache && Date.now() < this.tokenCache.expiresAtMs) { + return this.tokenCache.accessToken; + } + + const tokenUrl = this.expandUrl(this.config.tokenUrl); + await assertSafeChannelEndpoint(tokenUrl); + + const basic = Buffer.from( + `${this.config.accountId}:${this.config.clientSecret}`, + 'utf8', + ).toString('base64'); + + await this.acquireRateToken(); + const res = await fetch(tokenUrl, { + method: 'POST', + headers: { + Authorization: `Basic ${basic}`, + 'Content-Type': 'application/json;charset=utf-8', + Accept: 'application/json', + }, + body: '{}', + redirect: 'manual', + signal: AbortSignal.timeout(this.config.timeoutMs ?? 30_000), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`DerbySoft token request failed: HTTP ${res.status} ${text}`); + } + + const json = (await res.json()) as { accessToken?: string; tokenType?: string }; + if (!json.accessToken) { + throw new Error('DerbySoft token response missing accessToken'); + } + + // Refresh well before the vendor 90-day max (cache 12h for demos/tests). + this.tokenCache = { + accessToken: json.accessToken, + expiresAtMs: Date.now() + 12 * 60 * 60 * 1000, + }; + return json.accessToken; + } + + /** Invalidate cached token (e.g. after 401). */ + clearToken(): void { + this.tokenCache = null; + } + + async postJson( + url: string, + body: Record, + opts?: { retryOnUnauthorized?: boolean }, + ): Promise<{ ok: boolean; status: number; data: Record }> { + await assertSafeChannelEndpoint(url); + const maxRetries = this.config.maxRetries ?? 3; + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + await this.acquireRateToken(); + const token = await this.getAccessToken(); + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json;charset=utf-8', + Accept: 'application/json', + }, + body: JSON.stringify(body), + redirect: 'manual', + signal: AbortSignal.timeout(this.config.timeoutMs ?? 30_000), + }); + + if (res.status === 401 && opts?.retryOnUnauthorized !== false) { + this.clearToken(); + if (attempt < maxRetries) continue; + } + + if (res.status === 429) { + const retryAfter = Number(res.headers.get('retry-after') ?? '1'); + await new Promise((r) => setTimeout(r, Math.max(retryAfter, 1) * 1000)); + continue; + } + + const data = (await res.json().catch(() => ({}))) as Record; + if (!res.ok || data['errorCode']) { + return { ok: false, status: res.status, data }; + } + return { ok: true, status: res.status, data }; + } catch (err: any) { + lastError = err; + this.logger.warn(`DerbySoft POST ${url} attempt ${attempt + 1} failed: ${err?.message}`); + if (attempt < maxRetries) { + await new Promise((r) => setTimeout(r, 200 * (attempt + 1))); + } + } + } + + return { + ok: false, + status: 0, + data: { + errorCode: 'TransportError', + errorMessage: lastError?.message ?? 'Unknown transport error', + }, + }; + } +} diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.config.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.config.ts new file mode 100644 index 0000000..51c2c95 --- /dev/null +++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.config.ts @@ -0,0 +1,62 @@ +/** + * Configuration for a DerbySoft Property Connector channel connection. + * Stored in channelConnections.config JSON. + * + * Vendor protocol: REST/JSON, OAuth2 client-credentials Bearer token, + * 15 req/s rate limit. See docs/channels/derbysoft.md. + */ +export interface DerbySoftConfig { + /** PMS hotel id registered with DerbySoft (maps to hotelId in PC API). */ + hotelId: string; + + /** Account / client id used as Basic auth username for token endpoint. */ + accountId: string; + + /** Client secret for token endpoint (Basic password). */ + clientSecret: string; + + /** + * Tunnel base URL (ARI + resStatus). + * Mock: http://localhost:4002/pcapigateway/tunnel/{accountId} + * Test: https://pcendpoint.derbysoft-test.com/pcapigateway/tunnel/{accountId} + */ + tunnelBaseUrl: string; + + /** + * Profile base URL (property / content / channel). + * Mock: http://localhost:4002/pcapigateway/profile/{accountId} + */ + profileBaseUrl: string; + + /** + * Token endpoint. + * Mock: http://localhost:4002/pcapigateway/account/token + */ + tokenUrl: string; + + /** + * ARI update mode — Delta (incremental) or Overlay (full refresh). + * Default Delta; Overlay used for full flush / launch. + */ + ariUpdateType?: 'Delta' | 'Overlay'; + + /** Request timeout in milliseconds (default: 30000). */ + timeoutMs?: number; + + /** Max retry attempts for failed requests (default: 3). */ + maxRetries?: number; +} + +export const DEFAULT_DERBYSOFT_CONFIG: Partial = { + tunnelBaseUrl: 'http://localhost:4002/pcapigateway/tunnel/{accountId}', + profileBaseUrl: 'http://localhost:4002/pcapigateway/profile/{accountId}', + tokenUrl: 'http://localhost:4002/pcapigateway/account/token', + ariUpdateType: 'Delta', + timeoutMs: 30_000, + maxRetries: 3, +}; + +/** DerbySoft Property Connector max sustained request rate. */ +export const DERBYSOFT_RATE_LIMIT_PER_SEC = 15; + +export const DERBYSOFT_MESSAGE_VERSION = '0.1'; diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.mapper.spec.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.mapper.spec.ts new file mode 100644 index 0000000..2ccfe1d --- /dev/null +++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.mapper.spec.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest'; +import { + collapseDateRanges, + mapAvailabilityToInventory, + mapRatesToDerbySoft, + mapRestrictionsToAvailability, + mapDerbySoftReservationToHaip, + mapCancelToHaip, +} from './derbysoft.mapper'; + +describe('derbysoft.mapper', () => { + describe('collapseDateRanges', () => { + it('merges consecutive same-value dates', () => { + const ranges = collapseDateRanges( + [ + { date: '2026-04-01', available: 5 }, + { date: '2026-04-02', available: 5 }, + { date: '2026-04-03', available: 3 }, + ], + (i) => String(i.available), + ); + expect(ranges).toHaveLength(2); + expect(ranges[0]).toMatchObject({ startDate: '2026-04-01', endDate: '2026-04-02' }); + expect(ranges[1]).toMatchObject({ startDate: '2026-04-03', endDate: '2026-04-03' }); + }); + }); + + describe('mapAvailabilityToInventory', () => { + it('builds inventory payloads per room', () => { + const payloads = mapAvailabilityToInventory( + 'HOTEL1', + [ + { channelRoomCode: 'KING', date: '2026-04-01', available: 4, totalInventory: 10 }, + { channelRoomCode: 'KING', date: '2026-04-02', available: 4, totalInventory: 10 }, + ], + 'Delta', + ); + expect(payloads).toHaveLength(1); + expect(payloads[0]).toMatchObject({ + hotelId: 'HOTEL1', + roomId: 'KING', + type: 'Delta', + }); + expect((payloads[0]!['inventories'] as unknown[]).length).toBe(1); + }); + }); + + describe('mapRatesToDerbySoft', () => { + it('includes adult occupancy amounts', () => { + const payloads = mapRatesToDerbySoft( + 'HOTEL1', + [ + { + channelRoomCode: 'KING', + channelRateCode: 'BAR', + date: '2026-04-01', + amount: 120, + currencyCode: 'USD', + }, + ], + 'Overlay', + ); + expect(payloads[0]).toMatchObject({ type: 'Overlay', rateId: 'BAR', currencyCode: 'USD' }); + const rates = payloads[0]!['rates'] as Array<{ baseByGuestAmts: unknown[] }>; + expect(rates[0]!.baseByGuestAmts).toHaveLength(2); + }); + }); + + describe('mapRestrictionsToAvailability', () => { + it('maps stopSell to masterClose', () => { + const payloads = mapRestrictionsToAvailability( + 'HOTEL1', + [ + { + channelRoomCode: 'KING', + channelRateCode: 'BAR', + date: '2026-04-01', + stopSell: true, + closedToArrival: false, + closedToDeparture: true, + minLos: 2, + }, + ], + 'Delta', + ); + const restrictions = payloads[0]!['restrictions'] as Array>; + expect(restrictions[0]).toMatchObject({ + masterClose: true, + closedToDeparture: true, + minStayArrival: 2, + }); + }); + }); + + describe('mapDerbySoftReservationToHaip', () => { + it('maps book payload and strips payment (PCI)', () => { + const mapped = mapDerbySoftReservationToHaip( + { + reservationIds: { distributorResId: 'D1', derbyResId: 'DR1' }, + distributorId: 'CTRIP', + hotelId: 'HOTEL1', + stayRange: { checkin: '2026-05-01', checkout: '2026-05-03' }, + contactPerson: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' }, + roomCriteria: { adultCount: 2, childCount: 0 }, + total: { amountAfterTax: '300' }, + roomRate: { roomId: 'KING', rateId: 'BAR', currency: 'USD' }, + guests: [{ firstNameRoma: 'ADA', lastNameRoma: 'LOVELACE' }], + payment: { + cardCode: 'VI', + cardNumber: '4111111111111111', + cardHolderName: 'Ada', + expireDate: '1228', + }, + comments: ['high floor'], + }, + 'new', + ); + + expect(mapped.externalConfirmation).toBe('DR1'); + expect(mapped.channelCode).toBe('derbysoft:CTRIP'); + expect(mapped.channelHotelId).toBe('HOTEL1'); + expect(mapped.guestFirstName).toBe('ADA'); + expect(mapped.totalAmount).toBe(300); + expect(mapped.specialRequests).toBe('high floor'); + expect(mapped.rawPayload).not.toHaveProperty('payment'); + expect(JSON.stringify(mapped.rawPayload)).not.toContain('4111111111111111'); + }); + }); + + describe('mapCancelToHaip', () => { + it('sets cancelled status', () => { + const mapped = mapCancelToHaip({ + reservationIds: { derbyResId: 'DR1' }, + distributorId: 'EXPEDIA', + hotelId: 'HOTEL1', + }); + expect(mapped.status).toBe('cancelled'); + expect(mapped.channelCode).toBe('derbysoft:EXPEDIA'); + }); + }); +}); diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.mapper.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.mapper.ts new file mode 100644 index 0000000..f37d2da --- /dev/null +++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.mapper.ts @@ -0,0 +1,383 @@ +import { randomUUID } from 'node:crypto'; +import type { + AvailabilityPushParams, + RatePushParams, + RestrictionPushParams, + ChannelReservation, + ContentPushParams, +} from '../../channel-adapter.interface'; +import { DERBYSOFT_MESSAGE_VERSION } from './derbysoft.config'; + +export type AriUpdateType = 'Delta' | 'Overlay'; + +export interface DerbySoftHeader { + echoToken: string; + timeStamp: string; + version: string; +} + +export function buildHeader(echoToken?: string): DerbySoftHeader { + return { + echoToken: echoToken ?? randomUUID(), + timeStamp: new Date().toISOString(), + version: DERBYSOFT_MESSAGE_VERSION, + }; +} + +/** Collapse consecutive same-value dates into [startDate, endDate] ranges. */ +export function collapseDateRanges( + items: T[], + valueKey: (item: T) => string, +): Array<{ startDate: string; endDate: string; sample: T }> { + if (items.length === 0) return []; + const sorted = [...items].sort((a, b) => a.date.localeCompare(b.date)); + const ranges: Array<{ startDate: string; endDate: string; sample: T }> = []; + let start = sorted[0]!; + let end = sorted[0]!; + let key = valueKey(start); + + for (let i = 1; i < sorted.length; i++) { + const cur = sorted[i]!; + const curKey = valueKey(cur); + const prevDate = new Date(end.date + 'T00:00:00Z'); + prevDate.setUTCDate(prevDate.getUTCDate() + 1); + const expectedNext = prevDate.toISOString().slice(0, 10); + if (curKey === key && cur.date === expectedNext) { + end = cur; + continue; + } + ranges.push({ startDate: start.date, endDate: end.date, sample: start }); + start = cur; + end = cur; + key = curKey; + } + ranges.push({ startDate: start.date, endDate: end.date, sample: start }); + return ranges; +} + +/** + * HAIP availability → DerbySoft Update Inventory payloads (one per roomId). + * ChannelAdapter.pushAvailability maps to PC inventory (availableInvCount). + */ +export function mapAvailabilityToInventory( + hotelId: string, + items: AvailabilityPushParams['items'], + type: AriUpdateType, +): Array> { + const byRoom = new Map(); + for (const item of items) { + const list = byRoom.get(item.channelRoomCode) ?? []; + list.push(item); + byRoom.set(item.channelRoomCode, list); + } + + const payloads: Array> = []; + for (const [roomId, roomItems] of byRoom) { + const ranges = collapseDateRanges(roomItems, (i) => String(i.available)); + const dates = roomItems.map((i) => i.date).sort(); + payloads.push({ + header: buildHeader(), + hotelId, + roomId, + startDate: dates[0], + endDate: dates[dates.length - 1], + type, + inventories: ranges.map((r) => ({ + startDate: r.startDate, + endDate: r.endDate, + availableInvCount: r.sample.available, + })), + }); + } + return payloads; +} + +/** + * HAIP rates → DerbySoft Update Rate payloads (one per roomId×rateId). + */ +export function mapRatesToDerbySoft( + hotelId: string, + items: RatePushParams['items'], + type: AriUpdateType, +): Array> { + const byKey = new Map(); + for (const item of items) { + const key = `${item.channelRoomCode}|${item.channelRateCode}`; + const list = byKey.get(key) ?? []; + list.push(item); + byKey.set(key, list); + } + + const payloads: Array> = []; + for (const [, rateItems] of byKey) { + const first = rateItems[0]!; + const ranges = collapseDateRanges(rateItems, (i) => `${i.amount}|${i.currencyCode}`); + const dates = rateItems.map((i) => i.date).sort(); + payloads.push({ + header: buildHeader(), + hotelId, + roomId: first.channelRoomCode, + rateId: first.channelRateCode, + startDate: dates[0], + endDate: dates[dates.length - 1], + type, + currencyCode: first.currencyCode, + rates: ranges.map((r) => { + const amt = r.sample.amount; + const single = r.sample.singleOccupancy ?? amt; + const baseByGuestAmts: Array> = [ + { ageQualifyingCode: 10, numberOfGuests: 1, amountAfterTax: single }, + { ageQualifyingCode: 10, numberOfGuests: 2, amountAfterTax: amt }, + ]; + const rate: Record = { + startDate: r.startDate, + endDate: r.endDate, + baseByGuestAmts, + }; + if (r.sample.extraAdult != null || r.sample.extraChild != null) { + const additional: Array> = []; + if (r.sample.extraAdult != null) { + additional.push({ + ageQualifyingCode: 10, + amountAfterTax: r.sample.extraAdult, + }); + } + if (r.sample.extraChild != null) { + additional.push({ + ageQualifyingCode: 8, + amountAfterTax: r.sample.extraChild, + }); + } + rate['additionalGuestAmounts'] = additional; + } + return rate; + }), + }); + } + return payloads; +} + +/** + * HAIP restrictions → DerbySoft Update Availability (product-level) payloads. + */ +export function mapRestrictionsToAvailability( + hotelId: string, + items: RestrictionPushParams['items'], + type: AriUpdateType, +): Array> { + const byKey = new Map(); + for (const item of items) { + const key = `${item.channelRoomCode}|${item.channelRateCode}`; + const list = byKey.get(key) ?? []; + list.push(item); + byKey.set(key, list); + } + + const payloads: Array> = []; + for (const [, restItems] of byKey) { + const first = restItems[0]!; + const ranges = collapseDateRanges( + restItems, + (i) => + [ + i.stopSell, + i.closedToArrival, + i.closedToDeparture, + i.minLos ?? 0, + i.maxLos ?? 0, + ].join('|'), + ); + const dates = restItems.map((i) => i.date).sort(); + payloads.push({ + header: buildHeader(), + hotelId, + roomId: first.channelRoomCode, + rateId: first.channelRateCode, + startDate: dates[0], + endDate: dates[dates.length - 1], + type, + restrictions: ranges.map((r) => ({ + startDate: r.startDate, + endDate: r.endDate, + masterClose: r.sample.stopSell, + closedToArrival: r.sample.closedToArrival, + closedToDeparture: r.sample.closedToDeparture, + minStayArrival: r.sample.minLos ?? 0, + maxStayArrival: r.sample.maxLos ?? 0, + })), + }); + } + return payloads; +} + +/** Profile Update Hotel body from HAIP property content. */ +export function mapPropertyToHotelUpdate( + hotelId: string, + property: ContentPushParams['property'], +): Record { + return { + header: buildHeader(), + hotelId, + name: property.name, + description: property.description ?? undefined, + address: property.address ?? undefined, + city: property.city ?? undefined, + countryCode: property.countryCode ?? undefined, + starRating: property.starRating ?? undefined, + amenities: property.amenities ?? [], + }; +} + +export function mapRoomTypesToUpdates( + hotelId: string, + roomTypes: ContentPushParams['roomTypes'], +): Array> { + return roomTypes.map((rt) => ({ + header: buildHeader(), + hotelId, + roomId: rt.channelRoomCode, + name: rt.name, + description: rt.description ?? undefined, + maxOccupancy: rt.maxOccupancy ?? undefined, + bedType: rt.bedType ?? undefined, + amenities: rt.amenities ?? [], + })); +} + +/** + * Map DerbySoft Book/Modify JSON → HAIP ChannelReservation. + * PCI: payment card fields are stripped and never copied into rawPayload. + */ +export function mapDerbySoftReservationToHaip( + body: Record, + status: 'new' | 'modified' | 'cancelled', +): ChannelReservation { + const reservationIds = (body['reservationIds'] ?? {}) as Record; + const stayRange = (body['stayRange'] ?? {}) as Record; + const contact = (body['contactPerson'] ?? {}) as Record; + const guests = (body['guests'] as Array> | undefined) ?? []; + const primaryGuest = guests[0] ?? contact; + const roomCriteria = (body['roomCriteria'] ?? {}) as Record; + const roomRate = (body['roomRate'] ?? {}) as Record; + const total = (body['total'] ?? {}) as Record; + const distributorId = String(body['distributorId'] ?? 'derbysoft'); + + const derbyResId = String(reservationIds['derbyResId'] ?? ''); + const distributorResId = String(reservationIds['distributorResId'] ?? derbyResId); + + const amountAfter = total['amountAfterTax'] ?? total['amountBeforeTax'] ?? '0'; + const comments = body['comments']; + const specialRequests = Array.isArray(comments) + ? comments.map(String).join('; ') + : undefined; + + // Strip payment — never persist PAN/CVV/expiry (PCI DSS). + const safeRaw = { ...body }; + delete safeRaw['payment']; + delete safeRaw['threeDomainSecurity']; + + return { + externalConfirmation: derbyResId || distributorResId, + channelCode: `derbysoft:${distributorId}`, + channelHotelId: body['hotelId'] != null ? String(body['hotelId']) : undefined, + guestFirstName: String( + primaryGuest['firstNameRoma'] ?? primaryGuest['firstName'] ?? contact['firstNameRoma'] ?? contact['firstName'] ?? 'Guest', + ), + guestLastName: String( + primaryGuest['lastNameRoma'] ?? primaryGuest['lastName'] ?? contact['lastNameRoma'] ?? contact['lastName'] ?? 'Unknown', + ), + guestEmail: (primaryGuest['email'] ?? contact['email']) as string | undefined, + guestPhone: (primaryGuest['phone'] ?? contact['phone']) as string | undefined, + channelRoomCode: String(roomRate['roomId'] ?? ''), + channelRateCode: String(roomRate['rateId'] ?? ''), + arrivalDate: String(stayRange['checkin'] ?? ''), + departureDate: String(stayRange['checkout'] ?? ''), + adults: Number(roomCriteria['adultCount'] ?? 1), + children: Number(roomCriteria['childCount'] ?? 0), + totalAmount: Number(amountAfter), + currencyCode: String(roomRate['currency'] ?? 'USD'), + specialRequests, + status, + channelBookingDate: new Date(), + rawPayload: { + ...safeRaw, + sourceDistributorId: distributorId, + distributorResId, + derbyResId, + }, + }; +} + +export function mapCancelToHaip(body: Record): ChannelReservation { + const reservationIds = (body['reservationIds'] ?? {}) as Record; + const derbyResId = String(reservationIds['derbyResId'] ?? ''); + const distributorId = String(body['distributorId'] ?? 'derbysoft'); + return { + externalConfirmation: derbyResId || String(reservationIds['distributorResId'] ?? ''), + channelCode: `derbysoft:${distributorId}`, + channelHotelId: body['hotelId'] != null ? String(body['hotelId']) : undefined, + guestFirstName: 'Guest', + guestLastName: 'Unknown', + channelRoomCode: '', + channelRateCode: '', + arrivalDate: '', + departureDate: '', + adults: 0, + children: 0, + totalAmount: 0, + currencyCode: 'USD', + status: 'cancelled', + channelBookingDate: new Date(), + rawPayload: { + reservationIds, + distributorId, + hotelId: body['hotelId'], + }, + }; +} + +/** Build Book/Modify success response with PMS confirmation. */ +export function buildBookResponse( + body: Record, + supplierResId: string, +): Record { + const reservationIds = (body['reservationIds'] ?? {}) as Record; + const header = (body['header'] as DerbySoftHeader | undefined) ?? buildHeader(); + return { + header: { ...header, timeStamp: new Date().toISOString() }, + reservationIds: { + distributorResId: reservationIds['distributorResId'], + derbyResId: reservationIds['derbyResId'], + supplierResId, + }, + }; +} + +export function buildCancelResponse( + body: Record, + cancellationId: string, +): Record { + const reservationIds = (body['reservationIds'] ?? {}) as Record; + const header = (body['header'] as DerbySoftHeader | undefined) ?? buildHeader(); + return { + header: { ...header, timeStamp: new Date().toISOString() }, + reservationIds: { + distributorResId: reservationIds['distributorResId'], + derbyResId: reservationIds['derbyResId'], + supplierResId: reservationIds['supplierResId'], + cancellationId, + }, + }; +} + +export function buildErrorResponse( + echoToken: string | undefined, + errorCode: string, + errorMessage: string, +): Record { + return { + header: buildHeader(echoToken), + errorCode, + errorMessage, + }; +} diff --git a/apps/api/src/modules/channel/adapters/inbound-auth.util.spec.ts b/apps/api/src/modules/channel/adapters/inbound-auth.util.spec.ts index 52b93d9..4af5d8e 100644 --- a/apps/api/src/modules/channel/adapters/inbound-auth.util.spec.ts +++ b/apps/api/src/modules/channel/adapters/inbound-auth.util.spec.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from 'vitest'; import { createHmac } from 'node:crypto'; -import { verifyBasicAuth, verifyHmacSignature, getInboundAuth } from './inbound-auth.util'; +import { + verifyBasicAuth, + verifyHmacSignature, + verifyBearerAuth, + getInboundAuth, +} from './inbound-auth.util'; describe('inbound-auth.util — verifyBasicAuth', () => { const stored = { username: 'bc_user', password: 'bc_pass' }; @@ -67,6 +72,27 @@ describe('inbound-auth.util — verifyHmacSignature', () => { }); }); +describe('inbound-auth.util — verifyBearerAuth', () => { + const stored = { bearerToken: 'derby-inbound-secret' }; + + it('accepts the correct Bearer token', () => { + expect(verifyBearerAuth('Bearer derby-inbound-secret', stored)).toBe(true); + }); + + it('rejects a wrong token', () => { + expect(verifyBearerAuth('Bearer other-tenant', stored)).toBe(false); + }); + + it('rejects missing header or missing stored token', () => { + expect(verifyBearerAuth(undefined, stored)).toBe(false); + expect(verifyBearerAuth('Bearer derby-inbound-secret', undefined)).toBe(false); + }); + + it('rejects Basic auth schemes', () => { + expect(verifyBearerAuth('Basic abc', stored)).toBe(false); + }); +}); + describe('inbound-auth.util — getInboundAuth', () => { it('returns the embedded auth object when present', () => { expect(getInboundAuth({ inboundAuth: { username: 'u', password: 'p' } })).toEqual({ username: 'u', password: 'p' }); diff --git a/apps/api/src/modules/channel/adapters/inbound-auth.util.ts b/apps/api/src/modules/channel/adapters/inbound-auth.util.ts index 919943e..dfe4549 100644 --- a/apps/api/src/modules/channel/adapters/inbound-auth.util.ts +++ b/apps/api/src/modules/channel/adapters/inbound-auth.util.ts @@ -8,6 +8,7 @@ import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; * * { username: '...', password: '...' } // Basic Auth (Booking.com) * { secret: '...' } // HMAC-SHA256 (Expedia & similar) + * { bearerToken: '...' } // Bearer token (DerbySoft → PMS) * * The previous code never validated the `Authorization` header at all, so anyone * who reached the public webhook URL could inject reservations/cancellations. @@ -23,6 +24,10 @@ export interface InboundHmacAuth { /** Shared secret used as the HMAC-SHA256 key (raw, not hashed). */ secret: string; } +export interface InboundBearerAuth { + /** Opaque bearer token DerbySoft presents on LiveCheck/Book/Modify/Cancel. */ + bearerToken: string; +} /** * Constant-time string compare that does NOT leak the length difference. @@ -84,8 +89,23 @@ export function verifyHmacSignature( return constantTimeEqualStr(provided, expected); } +/** + * Verify `Authorization: Bearer ` against the stored bearer token on a + * channel connection. Constant-time; fail closed on missing input. + */ +export function verifyBearerAuth( + authHeader: string | undefined | null, + stored: InboundBearerAuth | undefined, +): boolean { + if (!stored?.bearerToken) return false; + if (typeof authHeader !== 'string' || !authHeader) return false; + const m = /^Bearer\s+(.+)$/i.exec(authHeader.trim()); + if (!m) return false; + return constantTimeEqualStr(m[1]!.trim(), stored.bearerToken); +} + /** Extract `inboundAuth` from a channel connection's `config` jsonb safely. */ -export function getInboundAuth( +export function getInboundAuth( config: unknown, ): T | undefined { if (!config || typeof config !== 'object') return undefined; diff --git a/apps/api/src/modules/channel/adapters/mock.adapter.ts b/apps/api/src/modules/channel/adapters/mock.adapter.ts index 43f451f..256c1fb 100644 --- a/apps/api/src/modules/channel/adapters/mock.adapter.ts +++ b/apps/api/src/modules/channel/adapters/mock.adapter.ts @@ -15,7 +15,7 @@ import type { /** * MockChannelAdapter — test/development adapter. * Stores pushed data in memory. Returns canned results. - * Real adapters (SiteMinder, DerbySoft) replace this. + * Real adapters (SiteMinder, DerbySoft, Booking.com, Expedia) replace this. */ @Injectable() export class MockChannelAdapter implements ChannelAdapter { diff --git a/apps/api/src/modules/channel/ari.service.ts b/apps/api/src/modules/channel/ari.service.ts index c2282eb..f7d3f4d 100644 --- a/apps/api/src/modules/channel/ari.service.ts +++ b/apps/api/src/modules/channel/ari.service.ts @@ -34,6 +34,7 @@ export class AriService { startDate: string, endDate: string, channelConnectionId?: string, + ariUpdateType?: 'Delta' | 'Overlay', ) { const connections = channelConnectionId ? [await this.channelService.findById(channelConnectionId, propertyId)] @@ -87,10 +88,14 @@ export class AriService { if (items.length === 0) continue; const adapter = this.adapterFactory.getAdapter(conn.adapterType); + const connectionConfig: Record = { + ...((conn.config ?? {}) as Record), + ...(ariUpdateType ? { ariUpdateType } : {}), + }; const result = await adapter.pushAvailability({ propertyId, channelConnectionId: conn.id, - connectionConfig: (conn.config ?? {}) as Record, + connectionConfig, items, }); @@ -117,6 +122,7 @@ export class AriService { startDate: string, endDate: string, channelConnectionId?: string, + ariUpdateType?: 'Delta' | 'Overlay', ) { const connections = channelConnectionId ? [await this.channelService.findById(channelConnectionId, propertyId)] @@ -198,7 +204,10 @@ export class AriService { const adapter = this.adapterFactory.getAdapter(conn.adapterType); - const connectionConfig = (conn.config ?? {}) as Record; + const connectionConfig: Record = { + ...((conn.config ?? {}) as Record), + ...(ariUpdateType ? { ariUpdateType } : {}), + }; const rateResult = rateItems.length > 0 ? await adapter.pushRates({ propertyId, channelConnectionId: conn.id, connectionConfig, items: rateItems }) : { success: true, itemsSynced: 0, errors: [] }; @@ -227,19 +236,52 @@ export class AriService { /** * Push full ARI (availability + rates + restrictions) — convenience method. + * Pass ariUpdateType='Overlay' for DerbySoft full refresh (issue #93 flush). */ async pushFullARI( propertyId: string, startDate: string, endDate: string, channelConnectionId?: string, + ariUpdateType?: 'Delta' | 'Overlay', ) { - const availabilityResults = await this.pushAvailability(propertyId, startDate, endDate, channelConnectionId); - const rateResults = await this.pushRates(propertyId, startDate, endDate, channelConnectionId); + const availabilityResults = await this.pushAvailability( + propertyId, + startDate, + endDate, + channelConnectionId, + ariUpdateType, + ); + const rateResults = await this.pushRates( + propertyId, + startDate, + endDate, + channelConnectionId, + ariUpdateType, + ); return { availability: availabilityResults, rates: rateResults }; } + /** + * Schedule a full Overlay ARI flush asynchronously. + * Channel BullMQ workers are not wired yet (same pragmatic stance as webhook + * delivery) — runs fire-and-forget on the event loop. + */ + scheduleFullFlush( + propertyId: string, + startDate: string, + endDate: string, + channelConnectionId?: string, + ): { queued: true } { + setImmediate(() => { + void this.pushFullARI(propertyId, startDate, endDate, channelConnectionId, 'Overlay').catch( + () => undefined, + ); + }); + return { queued: true }; + } + /** * Get sync logs for a channel connection. */ diff --git a/apps/api/src/modules/channel/channel-adapter.factory.ts b/apps/api/src/modules/channel/channel-adapter.factory.ts index 6961ad0..77ece85 100644 --- a/apps/api/src/modules/channel/channel-adapter.factory.ts +++ b/apps/api/src/modules/channel/channel-adapter.factory.ts @@ -7,6 +7,7 @@ import { MockChannelAdapter } from './adapters/mock.adapter'; import { BookingComAdapter } from './adapters/booking-com/booking-com.adapter'; import { SiteMinderAdapter } from './adapters/siteminder/siteminder.adapter'; import { ExpediaAdapter } from './adapters/expedia/expedia.adapter'; +import { DerbySoftAdapter } from './adapters/derbysoft/derbysoft.adapter'; /** * Factory that maps adapterType strings to ChannelAdapter instances. @@ -21,12 +22,13 @@ export class ChannelAdapterFactory { private readonly bookingComAdapter: BookingComAdapter, private readonly siteMinderAdapter: SiteMinderAdapter, private readonly expediaAdapter: ExpediaAdapter, + private readonly derbySoftAdapter: DerbySoftAdapter, ) { this.adapters.set('mock', this.mockAdapter); this.adapters.set('booking_com', this.bookingComAdapter); this.adapters.set('siteminder', this.siteMinderAdapter); this.adapters.set('expedia', this.expediaAdapter); - // Future: this.adapters.set('derbysoft', this.derbysoftAdapter); + this.adapters.set('derbysoft', this.derbySoftAdapter); } getAdapter(adapterType: string): ChannelAdapter { diff --git a/apps/api/src/modules/channel/channel.controller.ts b/apps/api/src/modules/channel/channel.controller.ts index 4d0b2e5..ec0aaaa 100644 --- a/apps/api/src/modules/channel/channel.controller.ts +++ b/apps/api/src/modules/channel/channel.controller.ts @@ -102,6 +102,7 @@ export class ChannelController { dto.startDate, dto.endDate, dto.channelConnectionId, + dto.ariUpdateType, ); } @@ -113,20 +114,54 @@ export class ChannelController { dto.startDate, dto.endDate, dto.channelConnectionId, + dto.ariUpdateType, ); } @Post('push/full') @ApiOperation({ summary: 'Push full ARI (availability + rates + restrictions) to channels' }) async pushFullARI(@Body() dto: PushAriDto) { + if (dto.asyncFlush) { + return this.ariService.scheduleFullFlush( + dto.propertyId, + dto.startDate, + dto.endDate, + dto.channelConnectionId, + ); + } return this.ariService.pushFullARI( dto.propertyId, dto.startDate, dto.endDate, dto.channelConnectionId, + dto.ariUpdateType, ); } + @Post('push/full-flush') + @ApiOperation({ + summary: + 'Queue a full Overlay ARI flush (DerbySoft launch/refresh). Async fire-and-forget until BullMQ channel workers land.', + }) + async pushFullFlush(@Body() dto: PushAriDto) { + return this.ariService.scheduleFullFlush( + dto.propertyId, + dto.startDate, + dto.endDate, + dto.channelConnectionId, + ); + } + + @Post('connections/:id/sync-property') + @ApiOperation({ summary: 'Sync property/room profiles to the channel (DerbySoft profile APIs)' }) + @ApiQuery({ name: 'propertyId', required: true }) + async syncProperty( + @Param('id', ParseUUIDPipe) id: string, + @Query('propertyId', ParseUUIDPipe) propertyId: string, + ) { + return this.contentSyncService.pushContent(propertyId, id); + } + @Post('push/stop-sell') @ApiOperation({ summary: 'Push stop-sell (zero availability) to a channel' }) @ApiQuery({ name: 'channelConnectionId', required: true }) diff --git a/apps/api/src/modules/channel/channel.module.ts b/apps/api/src/modules/channel/channel.module.ts index 9235d7e..b72ebe6 100644 --- a/apps/api/src/modules/channel/channel.module.ts +++ b/apps/api/src/modules/channel/channel.module.ts @@ -14,13 +14,20 @@ import { BookingComInboundController } from './adapters/booking-com/booking-com- import { SiteMinderAdapter } from './adapters/siteminder/siteminder.adapter'; import { ExpediaAdapter } from './adapters/expedia/expedia.adapter'; import { ExpediaInboundController } from './adapters/expedia/expedia-inbound.controller'; +import { DerbySoftAdapter } from './adapters/derbysoft/derbysoft.adapter'; +import { DerbySoftInboundController } from './adapters/derbysoft/derbysoft-inbound.controller'; import { ReservationModule } from '../reservation/reservation.module'; import { WebhookModule } from '../webhook/webhook.module'; import { MediaModule } from '../media/media.module'; @Module({ imports: [ReservationModule, WebhookModule, MediaModule], - controllers: [ChannelController, BookingComInboundController, ExpediaInboundController], + controllers: [ + ChannelController, + BookingComInboundController, + ExpediaInboundController, + DerbySoftInboundController, + ], providers: [ ChannelService, AriService, @@ -32,6 +39,7 @@ import { MediaModule } from '../media/media.module'; BookingComAdapter, SiteMinderAdapter, ExpediaAdapter, + DerbySoftAdapter, ], exports: [ChannelService, AriService, InboundReservationService], }) diff --git a/apps/api/src/modules/channel/dto/push-ari.dto.ts b/apps/api/src/modules/channel/dto/push-ari.dto.ts index 047ff77..f54ac0f 100644 --- a/apps/api/src/modules/channel/dto/push-ari.dto.ts +++ b/apps/api/src/modules/channel/dto/push-ari.dto.ts @@ -1,4 +1,4 @@ -import { IsUUID, IsDateString, IsOptional } from 'class-validator'; +import { IsUUID, IsDateString, IsOptional, IsBoolean, IsIn } from 'class-validator'; export class PushAriDto { @IsUUID() @@ -13,4 +13,20 @@ export class PushAriDto { @IsOptional() @IsUUID() channelConnectionId?: string; -} + + /** + * DerbySoft ARI mode. Delta = incremental (default); Overlay = full refresh + * (delete+insert for the date window). Other adapters ignore this field. + */ + @IsOptional() + @IsIn(['Delta', 'Overlay']) + ariUpdateType?: 'Delta' | 'Overlay'; + + /** + * When true with push/full, run Overlay flush asynchronously (fire-and-forget). + * BullMQ channel queues are not wired yet — this is the pragmatic async path. + */ + @IsOptional() + @IsBoolean() + asyncFlush?: boolean; +} \ No newline at end of file diff --git a/apps/dashboard/src/pages/Channels.tsx b/apps/dashboard/src/pages/Channels.tsx index 7c09514..32b172a 100644 --- a/apps/dashboard/src/pages/Channels.tsx +++ b/apps/dashboard/src/pages/Channels.tsx @@ -25,6 +25,7 @@ const ADAPTER_OPTIONS = [ { value: 'booking_com', label: 'Booking.com' }, { value: 'expedia', label: 'Expedia' }, { value: 'siteminder', label: 'SiteMinder' }, + { value: 'derbysoft', label: 'DerbySoft' }, { value: 'mock', label: 'Demo (mock)' }, ]; diff --git a/docker-compose.yml b/docker-compose.yml index 07319cd..2fcff5e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -121,6 +121,12 @@ services: EXPEDIA_USERNAME: haip_test EXPEDIA_PASSWORD: test_password EXPEDIA_HOTEL_ID: MOCK_EXP_HOTEL + DERBYSOFT_TUNNEL_BASE_URL: http://mock-derbysoft:4002/pcapigateway/tunnel/{accountId} + DERBYSOFT_PROFILE_BASE_URL: http://mock-derbysoft:4002/pcapigateway/profile/{accountId} + DERBYSOFT_TOKEN_URL: http://mock-derbysoft:4002/pcapigateway/account/token + DERBYSOFT_ACCOUNT_ID: haip_test + DERBYSOFT_CLIENT_SECRET: test_password + DERBYSOFT_HOTEL_ID: MOCK_DS_HOTEL ports: - '3000:3000' depends_on: @@ -164,6 +170,21 @@ services: profiles: - channels + # Mock DerbySoft Property Connector API (optional, for channel testing) + mock-derbysoft: + build: + context: ./tools/mock-derbysoft + dockerfile: Dockerfile + container_name: haip-mock-derbysoft + environment: + PORT: 4002 + CLIENT_ID: haip_test + CLIENT_SECRET: test_password + ports: + - '4002:4002' + profiles: + - channels + # MinIO — S3-compatible object storage for media uploads (optional). # Behind the `storage` profile so the default `docker compose up` stays lean # and runs on stock image URLs (no uploads). Enable with: diff --git a/docs/channels/derbysoft.md b/docs/channels/derbysoft.md new file mode 100644 index 0000000..f3267e2 --- /dev/null +++ b/docs/channels/derbysoft.md @@ -0,0 +1,93 @@ +# DerbySoft Property Connector adapter + +HAIP integrates DerbySoft via the **Property Connector (PC) Integration API** — REST/JSON, OAuth Bearer tokens, PUSH/PUSH for ARI and reservations. + +Partner onboarding: contact **pms.service@derbysoft.net** for `client_id` / `client_secret` (account credentials). + +## What HAIP implements + +| Capability | Direction | HAIP surface | +|------------|-----------|--------------| +| Token obtain/refresh | PMS → DerbySoft | `POST …/account/token` (Basic → Bearer) | +| Update Inventory | PMS → DerbySoft | `ChannelAdapter.pushAvailability` | +| Update Rate | PMS → DerbySoft | `ChannelAdapter.pushRates` | +| Update Availability (restrictions) | PMS → DerbySoft | `ChannelAdapter.pushRestrictions` | +| Update Hotel / RoomType | PMS → DerbySoft | `pushContent` / `POST /channels/connections/:id/sync-property` | +| Update Reservation Status | PMS → DerbySoft | `confirmReservation` / `cancelReservation` | +| Live Check | DerbySoft → PMS | `POST /api/v1/channels/inbound/derbysoft/availability` | +| Book / Modify / Cancel | DerbySoft → PMS | `…/book`, `…/modify`, `…/cancel` | +| Ping | DerbySoft → PMS | `…/ping` | +| Full Overlay flush | PMS → DerbySoft | `POST /channels/push/full-flush` (async) or `ariUpdateType=Overlay` | + +## Connection config + +Store on `channelConnections.config`: + +```json +{ + "hotelId": "YOUR_PMS_HOTEL_ID", + "accountId": "client_id_from_derbysoft", + "clientSecret": "client_secret_from_derbysoft", + "tunnelBaseUrl": "https://pcendpoint.derbysoft-test.com/pcapigateway/tunnel/{accountId}", + "profileBaseUrl": "https://pcendpoint.derbysoft-test.com/pcapigateway/profile/{accountId}", + "tokenUrl": "https://pcendpoint.derbysoft-test.com/pcapigateway/account/token", + "ariUpdateType": "Delta", + "inboundAuth": { + "bearerToken": "shared-secret-derbysoft-presents-to-haip" + }, + "roomTypeMapping": [{ "roomTypeId": "…", "channelRoomCode": "King" }], + "ratePlanMapping": [{ "ratePlanId": "…", "channelRateCode": "BAR" }] +} +``` + +Env defaults (local mock): + +| Variable | Default | +|----------|---------| +| `DERBYSOFT_TOKEN_URL` | `http://localhost:4002/pcapigateway/account/token` | +| `DERBYSOFT_TUNNEL_BASE_URL` | `http://localhost:4002/pcapigateway/tunnel/{accountId}` | +| `DERBYSOFT_PROFILE_BASE_URL` | `http://localhost:4002/pcapigateway/profile/{accountId}` | +| `DERBYSOFT_ACCOUNT_ID` | `haip_test` | +| `DERBYSOFT_CLIENT_SECRET` | `test_password` | +| `DERBYSOFT_HOTEL_ID` | `MOCK_DS_HOTEL` | + +## Local mock + +```bash +docker compose --profile channels up -d mock-derbysoft +# or: node tools/mock-derbysoft/server.mjs # :4002 +``` + +## Rate limiting + +Outbound client enforces **15 requests/second** (vendor limit). HTTP 429 triggers a short backoff + retry. + +## Delta vs Overlay + +- **Delta** (default): incremental ARI changes. +- **Overlay**: full refresh for a date window (property launch / manual refresh). Use `ariUpdateType: "Overlay"` on push DTOs or `POST /channels/push/full-flush`. + +## Source channel tracking + +Inbound reservations set `channelCode` to `derbysoft:` (e.g. `derbysoft:CTRIP`) so multi-OTA traffic through DerbySoft stays distinguishable in bookings. + +## PCI + +Book/Modify payloads may include card data. HAIP **strips** `payment` and `threeDomainSecurity` before any persistence (`rawPayload`). Never store PAN/CVV. + +## Partner checklist + +1. Email `pms.service@derbysoft.net` for TEST account (`client_id` / `client_secret`). +2. Register PMS webhook base URL: `https:///api/v1/channels/inbound/derbysoft` (paths `/availability`, `/book`, `/modify`, `/cancel`, `/ping`). +3. Share inbound Bearer token; store as `config.inboundAuth.bearerToken`. +4. Create HAIP channel connection (`adapterType=derbysoft`) with hotel/room/rate mappings. +5. Run property sync + Overlay ARI flush for the sellable window. +6. Certify Live Check → Book → Modify → Cancel against TEST endpoint. +7. Switch tunnel/profile/token URLs to PROD (`pcendpoint.derbysoftsec.com`) when certified. + +## Vendor docs + +- [API Overview](https://pc.knowledgebase.derbysoftsec.com/en/support/solutions/articles/70000157127-api-overview) +- [ARI](https://pc.knowledgebase.derbysoftsec.com/en/support/solutions/articles/70000157130-ari) +- [Reservation](https://pc.knowledgebase.derbysoftsec.com/en/support/solutions/articles/70000157128-reservation) +- [Account / Token](https://pc.knowledgebase.derbysoftsec.com/en/support/solutions/articles/70000663114) diff --git a/docs/test-stats.json b/docs/test-stats.json index c07aa20..f9a5a82 100644 --- a/docs/test-stats.json +++ b/docs/test-stats.json @@ -1,5 +1,5 @@ { - "tests": 1115, - "files": 134, - "updatedAt": "2026-07-14T15:02:28.946Z" + "tests": 1151, + "files": 139, + "updatedAt": "2026-07-17T02:31:16.474Z" } diff --git a/tools/mock-derbysoft/Dockerfile b/tools/mock-derbysoft/Dockerfile new file mode 100644 index 0000000..cd96c78 --- /dev/null +++ b/tools/mock-derbysoft/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --production || true +COPY server.mjs ./ +EXPOSE 4002 +CMD ["node", "server.mjs"] diff --git a/tools/mock-derbysoft/package.json b/tools/mock-derbysoft/package.json new file mode 100644 index 0000000..d7eedff --- /dev/null +++ b/tools/mock-derbysoft/package.json @@ -0,0 +1,11 @@ +{ + "name": "@telivityhaip/mock-derbysoft", + "version": "0.0.1", + "private": true, + "description": "Mock DerbySoft Property Connector API for development and testing", + "type": "module", + "scripts": { + "start": "node server.mjs", + "dev": "node --watch server.mjs" + } +} diff --git a/tools/mock-derbysoft/server.mjs b/tools/mock-derbysoft/server.mjs new file mode 100644 index 0000000..62a6170 --- /dev/null +++ b/tools/mock-derbysoft/server.mjs @@ -0,0 +1,199 @@ +/** + * Mock DerbySoft Property Connector API + * + * REST/JSON with OAuth Bearer tokens. Mirrors PC Integration API paths used by + * HAIP's DerbySoft adapter for local development and tests. + * + * Usage: + * node server.mjs # starts on port 4002 + * PORT=4003 node server.mjs # custom port + */ + +import http from 'node:http'; +import { randomBytes } from 'node:crypto'; + +const PORT = parseInt(process.env.PORT ?? '4002', 10); +const CLIENT_ID = process.env.CLIENT_ID ?? 'haip_test'; +const CLIENT_SECRET = process.env.CLIENT_SECRET ?? 'test_password'; + +const store = { + tokens: new Set(), + rates: [], + inventories: [], + availabilities: [], + hotels: [], + roomTypes: [], + resStatuses: [], + requests: [], +}; + +// Simple 15 req/s limiter (mirrors vendor) +let tokens = 15; +let lastRefill = Date.now(); +function allowRequest() { + const now = Date.now(); + if (now - lastRefill >= 1000) { + tokens = 15; + lastRefill = now; + } + if (tokens <= 0) return false; + tokens -= 1; + return true; +} + +function json(res, status, body) { + const payload = JSON.stringify(body); + res.writeHead(status, { + 'Content-Type': 'application/json;charset=utf-8', + 'Content-Length': Buffer.byteLength(payload), + }); + res.end(payload); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch (e) { + reject(e); + } + }); + req.on('error', reject); + }); +} + +function verifyBasic(req) { + const auth = req.headers.authorization ?? ''; + if (!auth.startsWith('Basic ')) return false; + const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8'); + const idx = decoded.indexOf(':'); + if (idx < 0) return false; + return decoded.slice(0, idx) === CLIENT_ID && decoded.slice(idx + 1) === CLIENT_SECRET; +} + +function verifyBearer(req) { + const auth = req.headers.authorization ?? ''; + const m = /^Bearer\s+(.+)$/i.exec(auth); + if (!m) return false; + return store.tokens.has(m[1].trim()); +} + +function echoHeader(body) { + const h = body?.header ?? {}; + return { + echoToken: h.echoToken ?? 'mock-echo', + timeStamp: new Date().toISOString(), + version: h.version ?? '0.1', + }; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? '/', `http://localhost:${PORT}`); + const path = url.pathname; + + if (req.method === 'GET' && path === '/health') { + return json(res, 200, { status: 'ok', service: 'mock-derbysoft' }); + } + if (req.method === 'GET' && path === '/store') { + return json(res, 200, { + rates: store.rates.length, + inventories: store.inventories.length, + availabilities: store.availabilities.length, + hotels: store.hotels.length, + roomTypes: store.roomTypes.length, + resStatuses: store.resStatuses.length, + tokens: store.tokens.size, + }); + } + if (req.method === 'POST' && path === '/reset') { + store.tokens.clear(); + store.rates = []; + store.inventories = []; + store.availabilities = []; + store.hotels = []; + store.roomTypes = []; + store.resStatuses = []; + store.requests = []; + return json(res, 200, { reset: true }); + } + + if (!allowRequest()) { + return json(res, 429, { + header: { echoToken: 'rate-limit', timeStamp: new Date().toISOString(), version: '0.1' }, + errorCode: 'TooManyRequests', + errorMessage: 'rate limit is 15 requests per second', + }); + } + + let body = {}; + try { + if (req.method === 'POST' || req.method === 'PUT') body = await readBody(req); + } catch { + return json(res, 500, { + header: echoHeader({}), + errorCode: 'InvalidField', + errorMessage: 'Invalid JSON', + }); + } + + store.requests.push({ method: req.method, path, at: new Date().toISOString() }); + + // Token + if (req.method === 'POST' && path === '/pcapigateway/account/token') { + if (!verifyBasic(req)) { + return json(res, 401, { + header: echoHeader(body), + errorCode: 'Unauthorized', + errorMessage: 'Invalid client credentials', + }); + } + const accessToken = randomBytes(24).toString('hex'); + store.tokens.clear(); // new token invalidates old ones (vendor behavior) + store.tokens.add(accessToken); + return json(res, 200, { accessToken, tokenType: 'Bearer' }); + } + + // All other PC endpoints require Bearer + if (!verifyBearer(req)) { + return json(res, 401, { + header: echoHeader(body), + errorCode: 'Unauthorized', + errorMessage: 'Invalid token', + }); + } + + // Tunnel ARI + const tunnelMatch = path.match(/^\/pcapigateway\/tunnel\/[^/]+\/(rate|inventory|availability|resStatus)$/); + if (req.method === 'POST' && tunnelMatch) { + const kind = tunnelMatch[1]; + if (kind === 'rate') store.rates.push(body); + else if (kind === 'inventory') store.inventories.push(body); + else if (kind === 'availability') store.availabilities.push(body); + else store.resStatuses.push(body); + return json(res, 200, { header: echoHeader(body) }); + } + + // Profile + const profileMatch = path.match(/^\/pcapigateway\/profile\/[^/]+\/(hotel|roomtype|rateplan|product)$/); + if (req.method === 'POST' && profileMatch) { + const kind = profileMatch[1]; + if (kind === 'hotel') store.hotels.push(body); + else if (kind === 'roomtype') store.roomTypes.push(body); + return json(res, 200, { header: echoHeader(body) }); + } + + return json(res, 404, { + header: echoHeader(body), + errorCode: 'NotFound', + errorMessage: `No mock handler for ${req.method} ${path}`, + }); +}); + +server.listen(PORT, () => { + console.log(`mock-derbysoft listening on :${PORT}`); +});