Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -114,6 +115,7 @@ const imports: any[] = [
IntegrationsModule,
IcalModule,
FiscalModule,
MetricsModule,
];

// Serve the bundled dashboard as static files. Enabled in production, or
Expand Down
61 changes: 61 additions & 0 deletions apps/api/src/modules/channel/channel.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);

Expand All @@ -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',
);
});
});
});
38 changes: 37 additions & 1 deletion apps/api/src/modules/channel/channel.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
);
}
}

/**
Expand Down
107 changes: 101 additions & 6 deletions apps/api/src/modules/connect/connect-booking.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe('ConnectBookingService', () => {
let mockDb: any;
let mockAvailabilityService: any;
let mockWebhookService: any;
let mockReservationService: any;

const mockRatePlan = {
id: 'rp-1',
Expand All @@ -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([]),
Expand Down Expand Up @@ -54,7 +56,18 @@ describe('ConnectBookingService', () => {

mockWebhookService = { emit: vi.fn().mockResolvedValue(undefined) };
const mockRatePlanService = { assertSellable: vi.fn().mockResolvedValue(undefined) };
const mockReservationService = {
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',
Expand Down Expand Up @@ -119,6 +132,75 @@ 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 () => {
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 () => {
Expand Down Expand Up @@ -163,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);
});

Expand Down Expand Up @@ -209,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);
});

Expand Down Expand Up @@ -414,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({
Expand All @@ -438,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 () => {
Expand Down
Loading