From 2e268ce3dc4f146a0eb8534144ed4e9331ae217e Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 9 Aug 2026 17:30:49 +0000
Subject: [PATCH 1/2] feat(guest-comms): wire email provider, schedule
pre-arrival, win-back
- Document SendGrid/SMTP env vars in .env.example and production template
- Add daily cron (07:00) for guest_comms: pre-arrival, day-of, delayed
post-stay, and win-back generation
- Honor postStayDelayHours on checkout events; scheduled run picks up
post-stay after delay elapses
- Generate win_back drafts on scheduled runs using winBackDays config
- Pass actual days_since into win-back templates
- Add scheduling helpers and unit tests for agent/models
Co-authored-by: telivity-otaip
---
.env.example | 10 +
.env.production.example | 3 +
.../api/src/modules/agent/agent-graph.spec.ts | 4 +
apps/api/src/modules/agent/agent-graph.ts | 4 +-
.../agent/guest-comms/guest-comms.listener.ts | 5 +-
.../guest-communication.agent.spec.ts | 115 ++++++++
.../guest-comms/guest-communication.agent.ts | 273 ++++++++++++------
.../guest-communication.models.spec.ts | 45 +++
.../guest-comms/guest-communication.models.ts | 42 ++-
docs/operations/cron.md | 3 +-
scripts/cron/agent-runs.sh | 2 +-
11 files changed, 404 insertions(+), 102 deletions(-)
create mode 100644 apps/api/src/modules/agent/guest-comms/guest-communication.agent.spec.ts
diff --git a/.env.example b/.env.example
index 94366603..7385cf41 100644
--- a/.env.example
+++ b/.env.example
@@ -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.
diff --git a/.env.production.example b/.env.production.example
index 8df296e1..61386326 100644
--- a/.env.production.example
+++ b/.env.production.example
@@ -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=
diff --git a/apps/api/src/modules/agent/agent-graph.spec.ts b/apps/api/src/modules/agent/agent-graph.spec.ts
index 1bbc691b..632b4278 100644
--- a/apps/api/src/modules/agent/agent-graph.spec.ts
+++ b/apps/api/src/modules/agent/agent-graph.spec.ts
@@ -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 * * *');
+ });
});
diff --git a/apps/api/src/modules/agent/agent-graph.ts b/apps/api/src/modules/agent/agent-graph.ts
index d2015f0f..b29f4e85 100644
--- a/apps/api/src/modules/agent/agent-graph.ts
+++ b/apps/api/src/modules/agent/agent-graph.ts
@@ -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: '',
diff --git a/apps/api/src/modules/agent/guest-comms/guest-comms.listener.ts b/apps/api/src/modules/agent/guest-comms/guest-comms.listener.ts
index 81b78d06..fb126362 100644
--- a/apps/api/src/modules/agent/guest-comms/guest-comms.listener.ts
+++ b/apps/api/src/modules/agent/guest-comms/guest-comms.listener.ts
@@ -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.
*/
diff --git a/apps/api/src/modules/agent/guest-comms/guest-communication.agent.spec.ts b/apps/api/src/modules/agent/guest-comms/guest-communication.agent.spec.ts
new file mode 100644
index 00000000..3787d715
--- /dev/null
+++ b/apps/api/src/modules/agent/guest-comms/guest-communication.agent.spec.ts
@@ -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: 'Hi
',
+ 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');
+ });
+});
diff --git a/apps/api/src/modules/agent/guest-comms/guest-communication.agent.ts b/apps/api/src/modules/agent/guest-comms/guest-communication.agent.ts
index 61bd3e04..cce98954 100644
--- a/apps/api/src/modules/agent/guest-comms/guest-communication.agent.ts
+++ b/apps/api/src/modules/agent/guest-comms/guest-communication.agent.ts
@@ -26,6 +26,8 @@ import {
generateEmailDraft,
getEmailTypeForEvent,
getDefaultCommunicationConfig,
+ isPostStayReady,
+ daysSinceDate,
type EmailType,
type GuestContext,
type ReservationContext,
@@ -68,6 +70,9 @@ export class GuestCommunicationAgent implements HaipAgent, OnModuleInit {
// Get target reservations
let targetReservations: any[];
+ let postStayReservations: any[] = [];
+ let winBackReservations: any[] = [];
+
if (reservationId) {
// Single reservation from event trigger
targetReservations = await this.db
@@ -80,8 +85,13 @@ export class GuestCommunicationAgent implements HaipAgent, OnModuleInit {
),
);
} else {
- // Manual/scheduled run: get active reservations needing communication
+ // Scheduled/manual run: upcoming arrivals + delayed post-stay + win-back
const today = new Date().toISOString().split('T')[0]!;
+ const winBackDays = commConfig.winBackDays ?? 90;
+ const winBackCutoff = new Date();
+ winBackCutoff.setUTCDate(winBackCutoff.getUTCDate() - winBackDays);
+ const winBackDate = winBackCutoff.toISOString().split('T')[0]!;
+
targetReservations = await this.db
.select()
.from(reservations)
@@ -92,31 +102,54 @@ export class GuestCommunicationAgent implements HaipAgent, OnModuleInit {
not(inArray(reservations.status, ['cancelled', 'no_show'] as any)),
),
);
+
+ const checkedOut = await this.db
+ .select()
+ .from(reservations)
+ .where(
+ and(
+ eq(reservations.propertyId, propertyId),
+ eq(reservations.status, 'checked_out' as any),
+ ),
+ );
+
+ postStayReservations = checkedOut.filter((r: any) =>
+ isPostStayReady(
+ r.checkedOutAt,
+ r.departureDate,
+ commConfig.postStayDelayHours ?? 24,
+ ),
+ );
+
+ winBackReservations = checkedOut.filter((r: any) =>
+ r.departureDate === winBackDate,
+ );
}
// Get guest data
- const guestIds = [...new Set(targetReservations.map((r: any) => r.guestId).filter(Boolean))];
+ const allReservations = [...targetReservations, ...postStayReservations, ...winBackReservations];
+ const guestIds = [...new Set(allReservations.map((r: any) => r.guestId).filter(Boolean))];
const guestData: any[] = guestIds.length > 0
? await this.db.select().from(guests).where(inArray(guests.id, guestIds as any))
: [];
const guestMap = new Map(guestData.map((g: any) => [g.id, g]));
// Get booking data for confirmation numbers
- const bookingIds = [...new Set(targetReservations.map((r: any) => r.bookingId).filter(Boolean))];
+ const bookingIds = [...new Set(allReservations.map((r: any) => r.bookingId).filter(Boolean))];
const bookingData: any[] = bookingIds.length > 0
? await this.db.select().from(bookings).where(inArray(bookings.id, bookingIds as any))
: [];
const bookingMap = new Map(bookingData.map((b: any) => [b.id, b]));
// Get room type names
- const rtIds = [...new Set(targetReservations.map((r: any) => r.roomTypeId).filter(Boolean))];
+ const rtIds = [...new Set(allReservations.map((r: any) => r.roomTypeId).filter(Boolean))];
const rtData: any[] = rtIds.length > 0
? await this.db.select().from(roomTypes).where(inArray(roomTypes.id, rtIds as any))
: [];
const rtMap = new Map(rtData.map((rt: any) => [rt.id, rt]));
// Get rate plan names
- const rpIds = [...new Set(targetReservations.map((r: any) => r.ratePlanId).filter(Boolean))];
+ const rpIds = [...new Set(allReservations.map((r: any) => r.ratePlanId).filter(Boolean))];
const rpData: any[] = rpIds.length > 0
? await this.db.select().from(ratePlans).where(inArray(ratePlans.id, rpIds as any))
: [];
@@ -166,6 +199,8 @@ export class GuestCommunicationAgent implements HaipAgent, OnModuleInit {
property,
commConfig,
targetReservations,
+ postStayReservations,
+ winBackReservations,
guestMap: Object.fromEntries(guestMap),
bookingMap: Object.fromEntries(bookingMap),
rtMap: Object.fromEntries(rtMap),
@@ -182,6 +217,8 @@ export class GuestCommunicationAgent implements HaipAgent, OnModuleInit {
property,
commConfig,
targetReservations,
+ postStayReservations = [],
+ winBackReservations = [],
guestMap,
bookingMap,
rtMap,
@@ -190,101 +227,147 @@ export class GuestCommunicationAgent implements HaipAgent, OnModuleInit {
pastStayCounts,
} = analysis.signals as any;
- if (!property || targetReservations.length === 0) return [];
+ if (!property) return [];
- const decisions: AgentDecisionInput[] = [];
const enabledTypes: EmailType[] = commConfig.enabledTypes ?? ['confirmation', 'pre_arrival', 'post_stay'];
+ const decisions: AgentDecisionInput[] = [];
- for (const res of targetReservations) {
- const guest = guestMap[res.guestId];
- if (!guest || !guest.email) continue;
-
- const booking = bookingMap[res.bookingId];
- const roomType = rtMap[res.roomTypeId];
- const ratePlan = rpMap[res.ratePlanId];
- const previousTypes: EmailType[] = sentMap[res.id] ?? [];
- const pastStays: number = pastStayCounts[res.guestId] ?? 0;
-
- // Determine which email types to generate
- const typesToGenerate: EmailType[] = [];
- if (triggeredType) {
- // Event-driven: single type
- if (enabledTypes.includes(triggeredType)) {
- typesToGenerate.push(triggeredType);
- }
- } else {
- // Scheduled/manual: check all enabled types for pre_arrival
- const daysUntil = Math.ceil(
- (new Date(res.arrivalDate).getTime() - Date.now()) / 86400000,
- );
- if (enabledTypes.includes('pre_arrival') && daysUntil <= (commConfig.preArrivalDaysBefore ?? 3) && daysUntil > 0) {
- typesToGenerate.push('pre_arrival');
- }
- if (enabledTypes.includes('day_of') && daysUntil === 0) {
- typesToGenerate.push('day_of');
- }
- }
-
- const guestCtx: GuestContext = {
- firstName: guest.firstName,
- lastName: guest.lastName,
- email: guest.email,
- vipLevel: guest.vipLevel ?? 'none',
- isRepeatGuest: pastStays > 0,
- pastStayCount: pastStays,
- gdprConsentMarketing: guest.gdprConsentMarketing ?? false,
- preferences: guest.preferences,
- };
+ const batches: Array<{ reservations: any[]; scheduledTypes: EmailType[] }> = [];
- const resCtx: ReservationContext = {
- id: res.id,
- arrivalDate: res.arrivalDate,
- departureDate: res.departureDate,
- nights: res.nights,
- roomTypeName: roomType?.name ?? 'Standard Room',
- ratePlanName: ratePlan?.name ?? 'Standard Rate',
- totalAmount: res.totalAmount ?? '0.00',
- currencyCode: res.currencyCode ?? 'USD',
- specialRequests: res.specialRequests,
- confirmationNumber: booking?.confirmationNumber ?? res.id.slice(0, 8),
- };
+ if (triggeredType) {
+ batches.push({ reservations: targetReservations, scheduledTypes: [] });
+ } else {
+ batches.push({ reservations: targetReservations, scheduledTypes: ['pre_arrival', 'day_of'] });
+ if (enabledTypes.includes('post_stay')) {
+ batches.push({ reservations: postStayReservations, scheduledTypes: ['post_stay'] });
+ }
+ if (enabledTypes.includes('win_back')) {
+ batches.push({ reservations: winBackReservations, scheduledTypes: ['win_back'] });
+ }
+ }
- const propCtx: PropertyContext = {
- name: property.name,
- checkInTime: property.checkInTime ?? '15:00',
- checkOutTime: property.checkOutTime ?? '11:00',
- phone: property.phone,
- email: property.email,
- website: property.website,
- addressLine1: property.addressLine1,
- city: property.city,
- };
+ for (const { reservations, scheduledTypes } of batches) {
+ for (const res of reservations) {
+ const guest = guestMap[res.guestId];
+ if (!guest || !guest.email) continue;
+
+ const booking = bookingMap[res.bookingId];
+ const roomType = rtMap[res.roomTypeId];
+ const ratePlan = rpMap[res.ratePlanId];
+ const previousTypes: EmailType[] = sentMap[res.id] ?? [];
+ const pastStays: number = pastStayCounts[res.guestId] ?? 0;
+
+ const typesToGenerate: EmailType[] = [];
+ if (triggeredType) {
+ if (!enabledTypes.includes(triggeredType)) continue;
+ if (
+ triggeredType === 'post_stay' &&
+ !isPostStayReady(
+ res.checkedOutAt,
+ res.departureDate,
+ commConfig.postStayDelayHours ?? 24,
+ )
+ ) {
+ continue;
+ }
+ typesToGenerate.push(triggeredType);
+ } else {
+ if (scheduledTypes.includes('pre_arrival')) {
+ const daysUntil = Math.ceil(
+ (new Date(res.arrivalDate).getTime() - Date.now()) / 86_400_000,
+ );
+ if (
+ enabledTypes.includes('pre_arrival') &&
+ daysUntil <= (commConfig.preArrivalDaysBefore ?? 3) &&
+ daysUntil > 0
+ ) {
+ typesToGenerate.push('pre_arrival');
+ }
+ if (enabledTypes.includes('day_of') && daysUntil === 0) {
+ typesToGenerate.push('day_of');
+ }
+ }
+ if (scheduledTypes.includes('post_stay') && enabledTypes.includes('post_stay')) {
+ typesToGenerate.push('post_stay');
+ }
+ if (scheduledTypes.includes('win_back') && enabledTypes.includes('win_back')) {
+ typesToGenerate.push('win_back');
+ }
+ }
- for (const type of typesToGenerate) {
- const draft = generateEmailDraft(type, guestCtx, resCtx, propCtx, commConfig, previousTypes);
- if (!draft) continue;
-
- decisions.push({
- decisionType: 'guest_communication',
- recommendation: {
- reservationId: res.id,
- guestId: res.guestId,
- emailType: draft.emailType,
- to: draft.to,
- subject: draft.subject,
- bodyHtml: draft.bodyHtml,
- bodyText: draft.bodyText,
- personalizationTokens: draft.personalizationTokens,
- },
- confidence: 0.90, // template-based = high confidence
- inputSnapshot: {
- reservationId: res.id,
- guestName: `${guest.firstName} ${guest.lastName}`,
- emailType: draft.emailType,
- isRepeatGuest: guestCtx.isRepeatGuest,
- analyzedAt: analysis.timestamp.toISOString(),
- },
- });
+ if (typesToGenerate.length === 0) continue;
+
+ const guestCtx: GuestContext = {
+ firstName: guest.firstName,
+ lastName: guest.lastName,
+ email: guest.email,
+ vipLevel: guest.vipLevel ?? 'none',
+ isRepeatGuest: pastStays > 0,
+ pastStayCount: pastStays,
+ gdprConsentMarketing: guest.gdprConsentMarketing ?? false,
+ preferences: guest.preferences,
+ };
+
+ const resCtx: ReservationContext = {
+ id: res.id,
+ arrivalDate: res.arrivalDate,
+ departureDate: res.departureDate,
+ nights: res.nights,
+ roomTypeName: roomType?.name ?? 'Standard Room',
+ ratePlanName: ratePlan?.name ?? 'Standard Rate',
+ totalAmount: res.totalAmount ?? '0.00',
+ currencyCode: res.currencyCode ?? 'USD',
+ specialRequests: res.specialRequests,
+ confirmationNumber: booking?.confirmationNumber ?? res.id.slice(0, 8),
+ };
+
+ const propCtx: PropertyContext = {
+ name: property.name,
+ checkInTime: property.checkInTime ?? '15:00',
+ checkOutTime: property.checkOutTime ?? '11:00',
+ phone: property.phone,
+ email: property.email,
+ website: property.website,
+ addressLine1: property.addressLine1,
+ city: property.city,
+ };
+
+ const daysSinceDeparture = daysSinceDate(res.departureDate);
+
+ for (const type of typesToGenerate) {
+ const draft = generateEmailDraft(
+ type,
+ guestCtx,
+ resCtx,
+ propCtx,
+ commConfig,
+ previousTypes,
+ type === 'win_back' ? { daysSinceDeparture } : undefined,
+ );
+ if (!draft) continue;
+
+ decisions.push({
+ decisionType: 'guest_communication',
+ recommendation: {
+ reservationId: res.id,
+ guestId: res.guestId,
+ emailType: draft.emailType,
+ to: draft.to,
+ subject: draft.subject,
+ bodyHtml: draft.bodyHtml,
+ bodyText: draft.bodyText,
+ personalizationTokens: draft.personalizationTokens,
+ },
+ confidence: 0.90,
+ inputSnapshot: {
+ reservationId: res.id,
+ guestName: `${guest.firstName} ${guest.lastName}`,
+ emailType: draft.emailType,
+ isRepeatGuest: guestCtx.isRepeatGuest,
+ analyzedAt: analysis.timestamp.toISOString(),
+ },
+ });
+ }
}
}
@@ -297,7 +380,7 @@ export class GuestCommunicationAgent implements HaipAgent, OnModuleInit {
if (!this.emailService.isConfigured()) {
return {
success: true,
- changes: [{ entity: 'email', action: 'drafted', detail: `Email drafted for ${rec.to} (SMTP not configured)` }],
+ changes: [{ entity: 'email', action: 'drafted', detail: `Email drafted for ${rec.to} (email provider not configured)` }],
};
}
diff --git a/apps/api/src/modules/agent/guest-comms/guest-communication.models.spec.ts b/apps/api/src/modules/agent/guest-comms/guest-communication.models.spec.ts
index 5ebcfb81..689d5f0e 100644
--- a/apps/api/src/modules/agent/guest-comms/guest-communication.models.spec.ts
+++ b/apps/api/src/modules/agent/guest-comms/guest-communication.models.spec.ts
@@ -4,6 +4,9 @@ import {
generateEmailDraft,
getEmailTypeForEvent,
getDefaultCommunicationConfig,
+ daysSinceDate,
+ isPostStayReady,
+ isWinBackDue,
type GuestContext,
type ReservationContext,
type PropertyContext,
@@ -264,6 +267,48 @@ describe('generateEmailDraft', () => {
expect(draft!.emailType).toBe('win_back');
expect(draft!.bodyText).toContain('Telivity Grand Hotel');
});
+
+ it('uses actual days since departure in win_back repeat template', () => {
+ const draft = generateEmailDraft(
+ 'win_back',
+ makeGuest({ isRepeatGuest: true, pastStayCount: 2 }),
+ makeReservation(),
+ makeProperty(),
+ makeConfig(),
+ [],
+ { daysSinceDeparture: 120 },
+ );
+ expect(draft!.bodyText).toContain('120 days');
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Scheduling helpers
+// ---------------------------------------------------------------------------
+
+describe('scheduling helpers', () => {
+ it('daysSinceDate counts calendar days', () => {
+ const now = new Date('2026-04-15T12:00:00.000Z');
+ expect(daysSinceDate('2026-04-10', now)).toBe(5);
+ });
+
+ it('isPostStayReady waits for postStayDelayHours after checkout', () => {
+ const now = new Date('2026-04-15T20:00:00.000Z');
+ const checkedOutAt = new Date('2026-04-15T10:00:00.000Z');
+ expect(isPostStayReady(checkedOutAt, '2026-04-15', 24, now)).toBe(false);
+ expect(isPostStayReady(checkedOutAt, '2026-04-15', 8, now)).toBe(true);
+ });
+
+ it('isPostStayReady falls back to departure date when checkedOutAt missing', () => {
+ const now = new Date('2026-04-17T12:00:00.000Z');
+ expect(isPostStayReady(null, '2026-04-15', 24, now)).toBe(true);
+ });
+
+ it('isWinBackDue matches winBackDays after departure', () => {
+ const now = new Date('2026-07-14T08:00:00.000Z');
+ expect(isWinBackDue('2026-04-15', 90, now)).toBe(true);
+ expect(isWinBackDue('2026-04-16', 90, now)).toBe(false);
+ });
});
// ---------------------------------------------------------------------------
diff --git a/apps/api/src/modules/agent/guest-comms/guest-communication.models.ts b/apps/api/src/modules/agent/guest-comms/guest-communication.models.ts
index 5fa91532..78e56c6c 100644
--- a/apps/api/src/modules/agent/guest-comms/guest-communication.models.ts
+++ b/apps/api/src/modules/agent/guest-comms/guest-communication.models.ts
@@ -286,6 +286,7 @@ export function generateEmailDraft(
property: PropertyContext,
config: CommunicationConfig,
previousEmailTypes: EmailType[] = [],
+ options?: { daysSinceDeparture?: number },
): EmailDraft | null {
// GDPR: check opt-out
if (!guest.gdprConsentMarketing && emailType !== 'confirmation') {
@@ -324,7 +325,9 @@ export function generateEmailDraft(
property_address: [property.addressLine1, property.city].filter(Boolean).join(', '),
property_website: property.website ?? '',
days_until: String(daysUntilArrival),
- days_since: '90', // default for win_back
+ days_since: String(
+ options?.daysSinceDeparture ?? config.winBackDays ?? 90,
+ ),
special_requests_line: reservation.specialRequests
? `Special requests: ${reservation.specialRequests}`
: '',
@@ -407,3 +410,40 @@ export function getDefaultCommunicationConfig(): CommunicationConfig {
localTips: [],
};
}
+
+// ---------------------------------------------------------------------------
+// Scheduling helpers (pre-arrival cron, post-stay delay, win-back)
+// ---------------------------------------------------------------------------
+
+const MS_PER_DAY = 86_400_000;
+const MS_PER_HOUR = 3_600_000;
+
+/** Whole calendar days between a YYYY-MM-DD date and now (UTC date boundary). */
+export function daysSinceDate(dateStr: string, now: Date = new Date()): number {
+ const then = new Date(`${dateStr}T00:00:00.000Z`);
+ const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
+ return Math.floor((today.getTime() - then.getTime()) / MS_PER_DAY);
+}
+
+/** True when post-stay email may be drafted (delay elapsed since checkout). */
+export function isPostStayReady(
+ checkedOutAt: Date | string | null | undefined,
+ departureDate: string,
+ delayHours: number,
+ now: Date = new Date(),
+): boolean {
+ const checkoutAt = checkedOutAt
+ ? new Date(checkedOutAt)
+ : new Date(`${departureDate}T23:59:59.000Z`);
+ const elapsedMs = now.getTime() - checkoutAt.getTime();
+ return elapsedMs >= delayHours * MS_PER_HOUR;
+}
+
+/** True when win-back is due (daily cron matches winBackDays after departure). */
+export function isWinBackDue(
+ departureDate: string,
+ winBackDays: number,
+ now: Date = new Date(),
+): boolean {
+ return daysSinceDate(departureDate, now) === winBackDays;
+}
diff --git a/docs/operations/cron.md b/docs/operations/cron.md
index 33858638..b4567b6a 100644
--- a/docs/operations/cron.md
+++ b/docs/operations/cron.md
@@ -51,7 +51,8 @@ Role: `admin`
| `0 8 * * *` | `housekeeping` | Daily ops window |
| `0 */6 * * *` | `cancellation` | Every 6 hours |
| `0 23 * * *` | `night_audit` | Before night-audit close |
-| Events | `guest_comms`, `review_response` | Reservation lifecycle / review ingest |
+| `0 7 * * *` | `guest_comms` | Pre-arrival, day-of, delayed post-stay, win-back |
+| Events | `guest_comms`, `review_response` | Reservation lifecycle (confirmation, welcome) / review ingest |
| Manual | Any specialist + RManager | Dashboard **Run Now** or `triggeredBy=manual` |
**Do not** independently cron `demand_forecast`, `pricing`, `overbooking`, `channel_mix`, or `group_pickup` — they run via RManager and would double-fire. See [`docs/agents-orchestration.md`](../agents-orchestration.md).
diff --git a/scripts/cron/agent-runs.sh b/scripts/cron/agent-runs.sh
index 325fe66f..5a4e9bca 100755
--- a/scripts/cron/agent-runs.sh
+++ b/scripts/cron/agent-runs.sh
@@ -13,7 +13,7 @@ source "${SCRIPT_DIR}/_common.sh"
AGENT_TYPE="${1:-${HAIP_AGENT_TYPE:-}}"
if [ -z "${AGENT_TYPE}" ]; then
echo "Usage: $0 (or set HAIP_AGENT_TYPE)" >&2
- echo "Typical: revenue_manager | housekeeping | cancellation | ar_collections | night_audit" >&2
+ echo "Typical: revenue_manager | housekeeping | cancellation | ar_collections | night_audit | guest_comms" >&2
exit 1
fi
From c4578d8735034c15a4d0be3785c41bb3522833cc Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 9 Aug 2026 18:01:46 +0000
Subject: [PATCH 2/2] chore: sync README test counts after guest-comms specs
Co-authored-by: telivity-otaip
---
README.md | 8 ++++----
docs/test-stats.json | 6 +++---
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 19fbf2f6..d24f1dfe 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -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 (1426 tests across 200 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
+| Testing | Vitest (1436 tests across 201 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Containers | Docker + docker-compose | Local dev and production deployment |
| CI/CD | GitHub Actions | Automated testing, builds, and releases |
@@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests
```bash
-# All tests (1426 tests across 200 test files)
+# All tests (1436 tests across 201 test files)
# API tests only
pnpm --filter @telivityhaip/api test
@@ -1188,7 +1188,7 @@ HAIP is built in public and contributions are welcome.
pnpm install # Install dependencies
pnpm build # Build all workspace packages
pnpm dev # Start API in dev mode (hot reload)
-pnpm test # Run all tests (1426 tests, 200 files)
+pnpm test # Run all tests (1436 tests, 201 files)
pnpm lint # ESLint
```
diff --git a/docs/test-stats.json b/docs/test-stats.json
index 6256a705..44e0978d 100644
--- a/docs/test-stats.json
+++ b/docs/test-stats.json
@@ -1,5 +1,5 @@
{
- "tests": 1426,
- "files": 200,
- "updatedAt": "2026-08-09T03:28:16.472Z"
+ "tests": 1436,
+ "files": 201,
+ "updatedAt": "2026-08-09T18:00:58.602Z"
}