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 @@ NestJS PostgreSQL Apache 2.0 License -1426 Tests Passing 12 AI Agents +1436 Tests Passing 12 AI Agents

@@ -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" }