Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,16 @@ STRIPE_MODE=mock
# SMS_RATE_LIMIT_MAX=60
# SMS_RATE_LIMIT_WINDOW_MS=3600000

# Guest email — lifecycle comms (see docs/integrations/sendgrid-email.md)
# Provider order when multiple are set: SendGrid → Mailgun → SES → SMTP → console (log only).
# SENDGRID_API_KEY=SG....
# SENDGRID_FROM=frontdesk@yourhotel.com
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=
# SMTP_PASS=
# SMTP_FROM=noreply@yourhotel.com

# Media object storage
# STORAGE_DRIVER controls the media upload pipeline:
# local (default) — uploads disabled; the dashboard uses image-URL paste only.
Expand Down
3 changes: 3 additions & 0 deletions .env.production.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ STRIPE_SECRET_KEY=sk_test_REPLACE_ME
STRIPE_WEBHOOK_SECRET=whsec_REPLACE_ME

# ── Email — guest communications agent (optional) ─────────────────────────────
# SendGrid (preferred when set) or SMTP. Without either, emails are drafted only (console log).
# SENDGRID_API_KEY=SG....
# SENDGRID_FROM=noreply@example.com
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/NestJS-framework-E0234E?logo=nestjs&logoColor=white" alt="NestJS" />
<img src="https://img.shields.io/badge/PostgreSQL-database-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL" />
<img src="https://img.shields.io/badge/License-Apache%202.0-blue" alt="Apache 2.0 License" />
<img src="https://img.shields.io/badge/Tests-1437%20passing-brightgreen" alt="1437 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
<img src="https://img.shields.io/badge/Tests-1447%20passing-brightgreen" alt="1447 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
</p>

<p align="center">
Expand Down Expand Up @@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire
| OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) |
| XML Processing | fast-xml-parser | Booking.com OTA XML protocol |
| Package Manager | pnpm workspaces | Monorepo management |
| Testing | Vitest (1437 tests across 203 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Testing | Vitest (1447 tests across 204 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 |

Expand Down Expand Up @@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests

```bash
# All tests (1437 tests across 203 test files)
# All tests (1447 tests across 204 test files)

# API tests only
pnpm --filter @telivityhaip/api test
Expand Down Expand Up @@ -1188,7 +1188,7 @@ HAIP is built in public and contributions are welcome.
pnpm install # Install dependencies
pnpm build # Build all workspace packages
pnpm dev # Start API in dev mode (hot reload)
pnpm test # Run all tests (1437 tests, 203 files)
pnpm test # Run all tests (1447 tests, 204 files)
pnpm lint # ESLint
```

Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/modules/agent/agent-graph.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,8 @@ describe('agent-graph', () => {
expect(isValidAgentType('pricing')).toBe(true);
expect(isValidAgentType('not_an_agent')).toBe(false);
});

it('guest_comms has daily cron for pre-arrival and win-back', () => {
expect(DEFAULT_SCHEDULE_MATRIX.guest_comms.cron).toBe('0 7 * * *');
});
});
4 changes: 2 additions & 2 deletions apps/api/src/modules/agent/agent-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,8 @@ export const DEFAULT_SCHEDULE_MATRIX: Record<
notes: 'Before night audit close',
},
guest_comms: {
cron: '',
notes: 'Event-driven (reservation lifecycle) + manual',
cron: '0 7 * * *',
notes: 'Daily pre-arrival, day-of, delayed post-stay, win-back; lifecycle events for confirmation/welcome',
},
review_response: {
cron: '',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import type { WebhookPayload } from '../../webhook/webhook.service';
* Drafts guest-lifecycle emails when reservation events fire.
*
* Maps reservation.created → confirmation, checked_in → welcome,
* checked_out → post_stay (see getEmailTypeForEvent). Pre-arrival / day-of
* still come from scheduled `POST /agents/:propertyId/guest_comms/run`.
* checked_out → post_stay after `postStayDelayHours` (see getEmailTypeForEvent).
* Pre-arrival / day-of / delayed post-stay / win-back come from scheduled
* `POST /agents/:propertyId/guest_comms/run`.
*
* Never throws — a draft failure must not break reservation state changes.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GuestCommunicationAgent } from './guest-communication.agent';

function chainSelect(rows: any[]) {
const chain: any = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
then: (resolve: (v: any) => void) => resolve(rows),
};
return chain;
}

describe('GuestCommunicationAgent', () => {
const runSelect = vi.fn();
const db = { select: runSelect };
const getOrCreateConfig = vi.fn();
const emailService = { isConfigured: vi.fn(), send: vi.fn() };
let agent: GuestCommunicationAgent;

beforeEach(() => {
vi.clearAllMocks();
getOrCreateConfig.mockResolvedValue({
config: {
enabledTypes: ['confirmation', 'pre_arrival', 'day_of', 'post_stay', 'win_back'],
preArrivalDaysBefore: 3,
postStayDelayHours: 24,
winBackDays: 90,
},
});
agent = new GuestCommunicationAgent(
db as any,
{ getOrCreateConfig } as any,
emailService as any,
);
});

it('recommend generates pre_arrival on scheduled run', async () => {
const arrival = new Date();
arrival.setUTCDate(arrival.getUTCDate() + 2);
const arrivalDate = arrival.toISOString().split('T')[0]!;

runSelect
.mockReturnValueOnce(chainSelect([{ id: 'prop-1', name: 'Hotel', checkInTime: '15:00', checkOutTime: '11:00' }]))
.mockReturnValueOnce(chainSelect([{ id: 'res-1', guestId: 'g-1', bookingId: 'b-1', roomTypeId: 'rt-1', ratePlanId: 'rp-1', arrivalDate, departureDate: '2026-12-01', nights: 1, status: 'confirmed' }]))
.mockReturnValueOnce(chainSelect([]))
.mockReturnValueOnce(chainSelect([{ id: 'g-1', firstName: 'Ann', lastName: 'Lee', email: 'ann@example.com', gdprConsentMarketing: true }]))
.mockReturnValueOnce(chainSelect([{ id: 'b-1', confirmationNumber: 'CONF-1' }]))
.mockReturnValueOnce(chainSelect([{ id: 'rt-1', name: 'King' }]))
.mockReturnValueOnce(chainSelect([{ id: 'rp-1', name: 'BAR' }]))
.mockReturnValueOnce(chainSelect([]))
.mockReturnValueOnce(chainSelect([]));

const analysis = await agent.analyze('prop-1');
const decisions = await agent.recommend(analysis);
expect(decisions.some((d) => (d.recommendation as any).emailType === 'pre_arrival')).toBe(true);
});

it('recommend skips post_stay on checkout event until delay elapsed', async () => {
const checkedOutAt = new Date();
runSelect
.mockReturnValueOnce(chainSelect([{ id: 'prop-1', name: 'Hotel', checkInTime: '15:00', checkOutTime: '11:00' }]))
.mockReturnValueOnce(chainSelect([{
id: 'res-1',
guestId: 'g-1',
bookingId: 'b-1',
roomTypeId: 'rt-1',
ratePlanId: 'rp-1',
arrivalDate: '2026-04-10',
departureDate: '2026-04-12',
nights: 2,
status: 'checked_out',
checkedOutAt,
}]))
.mockReturnValueOnce(chainSelect([{ id: 'g-1', firstName: 'Bob', lastName: 'Ray', email: 'bob@example.com', gdprConsentMarketing: true }]))
.mockReturnValueOnce(chainSelect([{ id: 'b-1', confirmationNumber: 'CONF-2' }]))
.mockReturnValueOnce(chainSelect([{ id: 'rt-1', name: 'Queen' }]))
.mockReturnValueOnce(chainSelect([{ id: 'rp-1', name: 'BAR' }]))
.mockReturnValueOnce(chainSelect([]))
.mockReturnValueOnce(chainSelect([{ guestId: 'g-1', status: 'checked_out' }]));

const analysis = await agent.analyze('prop-1', {
eventPayload: { event: 'reservation.checked_out', reservationId: 'res-1' },
});
const decisions = await agent.recommend(analysis);
expect(decisions).toHaveLength(0);
});

it('execute sends email when provider is configured', async () => {
emailService.isConfigured.mockReturnValue(true);
emailService.send.mockResolvedValue({ sent: true, provider: 'sendgrid' });

const result = await agent.execute({
recommendation: {
to: 'guest@example.com',
subject: 'Hi',
bodyHtml: '<p>Hi</p>',
bodyText: 'Hi',
emailType: 'confirmation',
},
} as any);

expect(emailService.send).toHaveBeenCalled();
expect(result.success).toBe(true);
expect(result.changes[0].action).toBe('sent');
});

it('execute drafts when no email provider configured', async () => {
emailService.isConfigured.mockReturnValue(false);
const result = await agent.execute({
recommendation: { to: 'guest@example.com', emailType: 'confirmation' },
} as any);
expect(emailService.send).not.toHaveBeenCalled();
expect(result.changes[0].action).toBe('drafted');
});
});
Loading
Loading