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
15 changes: 15 additions & 0 deletions apps/api/src/automation/automation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,21 @@ export class AutomationService implements OnModuleInit {
)
this.logger.log('Scheduled recurring automation gate nudge job')

// refs #1466 P3c — hourly insurance-prompt autopilot. Nudges the advisor
// first when a booked trip still has pending insurance past the grace
// window; on a later sweep (after the nudge grace) it auto-sends the
// client insurance proposal via the EXISTING queueProposalEmails path
// (zero new send code, inherits the #1048 cutover + MIGRATION_SILENT_MODE
// guards). Idempotent per traveler via the deterministic insurance jobId.
await this.scheduleRecurring(
QUEUES.CLIENT_CARE,
'recurring.insurance_prompt_autopilot',
{ type: 'recurring.insurance_prompt_autopilot' },
'0 * * * *', // Every hour
{ jobId: 'recurring:insurance_prompt_autopilot' },
)
this.logger.log('Scheduled recurring insurance prompt autopilot job')

// Contact lifecycle: returned → awaiting_next after 30 days
await this.scheduleRecurring(
QUEUES.CLIENT_CARE,
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/automation/automation.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ export const JOB_TYPES = {
// refs #1466 P2 — hourly sweep nudging advisors about gate automations
// (proposed/paused) nearing their scheduled send time.
RECURRING_AUTOMATION_GATE_NUDGE: 'recurring.automation_gate_nudge',
// refs #1466 P3c — hourly autopilot fallback: nudges the advisor first, then
// (on a later sweep) auto-sends the insurance proposal for still-pending
// travelers past the booking grace window.
RECURRING_INSURANCE_PROMPT_AUTOPILOT: 'recurring.insurance_prompt_autopilot',

// OCR processing jobs
OCR_EXTRACT: 'ocr.extract',
Expand Down Expand Up @@ -305,6 +309,7 @@ export interface RecurringJobData {
| 'recurring.task_assignment_digest'
| 'recurring.task_due_reminder'
| 'recurring.automation_gate_nudge'
| 'recurring.insurance_prompt_autopilot'
agencyId?: string // Optional - if not provided, runs for all agencies
}

Expand Down
115 changes: 115 additions & 0 deletions apps/api/src/automation/insurance-autopilot.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* refs #1466 P3c — pure selection/grouping/staging helpers for the insurance
* autopilot. No DB, no clock: proves the idempotency fence (existing job →
* excluded), the advisor-required filter, deterministic grouping, the two grace
* windows, and the stable advisor-scoped dedupe key.
*/
import {
getInsurancePromptGraceHours,
getInsuranceNudgeGraceHours,
buildAutopilotNudgeDedupeKey,
buildAutopilotNudgeCopy,
groupAutopilotCandidates,
isNudgeGraceElapsed,
type AutopilotCandidateRow,
} from './insurance-autopilot'
import { getInsuranceProposalJobId } from './automation.types'

const jobIdFor = getInsuranceProposalJobId

function row(
tripId: string,
tripTravelerId: string,
advisorId: string | null = 'advisor-1',
): AutopilotCandidateRow {
return { tripId, tripTravelerId, advisorId, agencyId: 'agency-1' }
}

describe('insurance-autopilot — grace resolution', () => {
it('uses the env override when positive', () => {
expect(getInsurancePromptGraceHours({ INSURANCE_PROMPT_GRACE_HOURS: '12' } as never)).toBe(12)
expect(getInsuranceNudgeGraceHours({ INSURANCE_PROMPT_NUDGE_GRACE_HOURS: '6' } as never)).toBe(6)
})

it('falls back to the default for missing / zero / non-numeric env', () => {
expect(getInsurancePromptGraceHours({} as never)).toBe(72)
expect(getInsurancePromptGraceHours({ INSURANCE_PROMPT_GRACE_HOURS: '0' } as never)).toBe(72)
expect(getInsurancePromptGraceHours({ INSURANCE_PROMPT_GRACE_HOURS: 'abc' } as never)).toBe(72)
expect(getInsuranceNudgeGraceHours({} as never)).toBe(24)
expect(getInsuranceNudgeGraceHours({ INSURANCE_PROMPT_NUDGE_GRACE_HOURS: '-4' } as never)).toBe(24)
})
})

describe('insurance-autopilot — dedupe key', () => {
it('is stable and embeds the advisor id (global dedupe index)', () => {
expect(buildAutopilotNudgeDedupeKey('advisor-1', 'trip-1')).toBe(
'insurance-autopilot:advisor-1:trip-1',
)
// Two advisors on the same trip never collide.
expect(buildAutopilotNudgeDedupeKey('advisor-2', 'trip-1')).not.toBe(
buildAutopilotNudgeDedupeKey('advisor-1', 'trip-1'),
)
})
})

describe('insurance-autopilot — groupAutopilotCandidates', () => {
it('groups pending travelers by trip (deterministic order)', () => {
const groups = groupAutopilotCandidates(
[row('trip-b', 't2'), row('trip-a', 't1'), row('trip-a', 't0')],
new Set(),
jobIdFor,
)
expect(groups.map((g) => g.tripId)).toEqual(['trip-a', 'trip-b'])
expect(groups[0]!.travelerIds).toEqual(['t0', 't1']) // sorted
})

it('excludes a traveler whose insurance proposal job already exists (idempotency)', () => {
const existing = new Set([jobIdFor('trip-a', 't1')])
const groups = groupAutopilotCandidates([row('trip-a', 't1'), row('trip-a', 't2')], existing, jobIdFor)
expect(groups).toHaveLength(1)
expect(groups[0]!.travelerIds).toEqual(['t2'])
})

it('drops a trip entirely when every traveler already has a job', () => {
const existing = new Set([jobIdFor('trip-a', 't1'), jobIdFor('trip-a', 't2')])
const groups = groupAutopilotCandidates([row('trip-a', 't1'), row('trip-a', 't2')], existing, jobIdFor)
expect(groups).toHaveLength(0)
})

it('skips a trip with no advisor/owner to nudge', () => {
const groups = groupAutopilotCandidates([row('trip-a', 't1', null)], new Set(), jobIdFor)
expect(groups).toHaveLength(0)
})
})

describe('insurance-autopilot — isNudgeGraceElapsed', () => {
const now = new Date('2026-07-05T12:00:00Z')

it('is false when no marker exists (advisor must be nudged first)', () => {
expect(isNudgeGraceElapsed(null, now, 24)).toBe(false)
expect(isNudgeGraceElapsed(undefined, now, 24)).toBe(false)
})

it('is false before the grace window elapses', () => {
const nudgedAt = new Date(now.getTime() - 23 * 60 * 60 * 1000) // 23h ago
expect(isNudgeGraceElapsed(nudgedAt, now, 24)).toBe(false)
})

it('is true once the grace window has elapsed (inclusive boundary)', () => {
const exactly = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const past = new Date(now.getTime() - 48 * 60 * 60 * 1000)
expect(isNudgeGraceElapsed(exactly, now, 24)).toBe(true)
expect(isNudgeGraceElapsed(past, now, 24)).toBe(true)
})
})

describe('insurance-autopilot — nudge copy', () => {
it('pluralizes on traveler count', () => {
const one = buildAutopilotNudgeCopy({ tripId: 't', advisorId: 'a', agencyId: 'g', travelerIds: ['x'] })
const two = buildAutopilotNudgeCopy({ tripId: 't', advisorId: 'a', agencyId: 'g', travelerIds: ['x', 'y'] })
expect(one.body).toContain('1 traveler ')
expect(one.body).toContain('has no insurance')
expect(two.body).toContain('2 travelers ')
expect(two.body).toContain('have no insurance')
})
})
159 changes: 159 additions & 0 deletions apps/api/src/automation/insurance-autopilot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* refs #1466 P3c — Autopilot fallback for the insurance prompt (the ONLY net-new
* client send in the Automation Gate epic).
*
* Pure selection / grouping / staging helpers for the hourly
* `recurring.insurance_prompt_autopilot` sweep. The processor owns the DB reads,
* the advisor nudge, and the (verbatim) call into
* `InsuranceAutomationService.queueProposalEmails`; these functions decide WHICH
* travelers qualify and — given the advisor-nudge marker — WHETHER a trip is
* ready to auto-send. Kept pure so the two-stage (advisor-first) logic and the
* grace math are unit-testable with no DB and no clock.
*
* Two-stage, advisor-first (safety):
* 1. First qualification → NUDGE the advisor (creates a durable marker).
* 2. Only after the marker exists AND a second grace window has elapsed does
* the sweep SEND. A nudge and a send NEVER happen in the same pass.
*/

/** Booking → nudge eligibility grace (hours since trips.lastStatusChangeAt). */
const DEFAULT_PROMPT_GRACE_HOURS = 72

/** Nudge → autopilot-send grace (hours the advisor has after being nudged). */
const DEFAULT_NUDGE_GRACE_HOURS = 24

const HOUR_MS = 60 * 60 * 1000

/**
* A pending-insurance candidate: one still-pending traveler on an active trip
* that is already past the booking grace window (the DB query applies the
* status + grace filter; these rows are the survivors).
*/
export interface AutopilotCandidateRow {
tripId: string
tripTravelerId: string
/** Trip owner = the advisor to nudge. Null when the trip has no owner. */
advisorId: string | null
agencyId: string
}

/** One trip ready for the autopilot pipeline, with its qualifying travelers. */
export interface AutopilotTripGroup {
tripId: string
advisorId: string
agencyId: string
/** Pending travelers with NO existing insurance proposal job (send targets). */
travelerIds: string[]
}

/**
* Resolve the booking-grace window (hours). Env override
* `INSURANCE_PROMPT_GRACE_HOURS`, else {@link DEFAULT_PROMPT_GRACE_HOURS}. A
* non-positive / unparseable value falls back to the default so a bad env can
* never make the window 0 (which would fire the instant a trip is booked).
*/
export function getInsurancePromptGraceHours(
env: NodeJS.ProcessEnv = process.env,
): number {
const parsed = Number(env.INSURANCE_PROMPT_GRACE_HOURS)
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PROMPT_GRACE_HOURS
}

/**
* Resolve the nudge-grace window (hours) — how long the advisor has to act after
* being nudged before the autopilot sends. Env override
* `INSURANCE_PROMPT_NUDGE_GRACE_HOURS`, else {@link DEFAULT_NUDGE_GRACE_HOURS}.
*/
export function getInsuranceNudgeGraceHours(
env: NodeJS.ProcessEnv = process.env,
): number {
const parsed = Number(env.INSURANCE_PROMPT_NUDGE_GRACE_HOURS)
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_NUDGE_GRACE_HOURS
}

/**
* Stable per-(advisor, trip) dedupe key for the advisor nudge. Unlike the P2
* gate nudge (day-bucketed so it re-fires daily), this key is STABLE — the
* notification is created exactly once and its row is the durable "nudged at T"
* marker that stage 2 reads. The advisor id MUST be embedded because the
* platform dedupe index is global (not per-user).
*/
export function buildAutopilotNudgeDedupeKey(advisorId: string, tripId: string): string {
return `insurance-autopilot:${advisorId}:${tripId}`
}

/**
* Group qualifying candidates by trip, excluding:
* - travelers whose insurance proposal job already exists (idempotency fence —
* `existingJobIds` holds the canonical getInsuranceProposalJobId ids found in
* automation_job_history), and
* - trips with no advisor/owner to nudge.
*
* `jobIdFor(tripId, travelerId)` produces the canonical job id so the exclusion
* compares like-for-like with the history rows.
*
* Deterministic ordering (trips + travelers sorted) so the sweep is stable.
*/
export function groupAutopilotCandidates(
rows: AutopilotCandidateRow[],
existingJobIds: ReadonlySet<string>,
jobIdFor: (tripId: string, travelerId: string) => string,
): AutopilotTripGroup[] {
const byTrip = new Map<string, AutopilotTripGroup>()

for (const row of rows) {
if (!row.advisorId) continue
if (existingJobIds.has(jobIdFor(row.tripId, row.tripTravelerId))) continue

const existing = byTrip.get(row.tripId)
if (!existing) {
byTrip.set(row.tripId, {
tripId: row.tripId,
advisorId: row.advisorId,
agencyId: row.agencyId,
travelerIds: [row.tripTravelerId],
})
} else if (!existing.travelerIds.includes(row.tripTravelerId)) {
existing.travelerIds.push(row.tripTravelerId)
}
}

const groups = [...byTrip.values()]
for (const g of groups) g.travelerIds.sort((a, b) => a.localeCompare(b))
return groups.sort((a, b) => a.tripId.localeCompare(b.tripId))
}

/**
* Stage-2 gate: given the marker's creation instant, is the nudge grace window
* elapsed (so the autopilot may send now)? A missing marker (`null`) is never
* ready — the advisor must be nudged first.
*/
export function isNudgeGraceElapsed(
nudgeCreatedAt: Date | null | undefined,
now: Date,
nudgeGraceHours: number,
): boolean {
if (!nudgeCreatedAt) return false
const elapsed = now.getTime() - nudgeCreatedAt.getTime()
return elapsed >= nudgeGraceHours * HOUR_MS
}

/**
* Advisor-facing copy for the autopilot nudge. Neutral, action-oriented: the
* advisor can resolve insurance manually, or let the autopilot send the client
* proposal once the grace window elapses.
*/
export function buildAutopilotNudgeCopy(group: AutopilotTripGroup): {
title: string
body: string
} {
const n = group.travelerIds.length
const plural = n === 1 ? '' : 's'
return {
title: 'Insurance still needs attention',
body:
`${n} traveler${plural} on this trip still ${n === 1 ? 'has' : 'have'} no insurance ` +
`decision on file. Resolve it in the Insurance tab, or the autopilot will email ` +
`the client${n === 1 ? '' : 's'} an insurance proposal shortly.`,
}
}
Loading
Loading