From a97dd21f323c6beedd7f48185c36653d4ef63343 Mon Sep 17 00:00:00 2001 From: Al Date: Sun, 5 Jul 2026 05:46:46 -0400 Subject: [PATCH] feat(#1466): non-blocking insurance prompt read-model + urgency badge (P3a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automation Gate P3a. Adds a persistent, non-blocking insurance-prompt card to the Trip Automation tab, resolved against trip_traveler_insurance, with a DISPLAY-ONLY urgency badge (purchase_window_hours never gates — Al's decision). - NEW insurance-prompt.resolver.ts: pure resolveInsurancePrompt() → per-traveler {status, resolved, urgency tier}. Booking anchor = trips.lastStatusChangeAt. Pending travelers share the SOONEST window across active catalog-linked packages (worst-case). Never throws; degenerate inputs → 'none'. Colocated spec. - trip-automations.controller.ts: GET automations/insurance-prompt (sibling of insurance/preview). Reuses InsuranceService.getTravelerInsurance/getPackages, small join on insurance_products for windows. Read-only, fails open to an empty read-model (never blocks the tab). Integration tests. - insurance-prompt-card.tsx + useInsurancePrompt hook: persistent card above the grouped jobs while anyPending; hidden once resolved; deep-links ?tab=insurance. Not gated by the automation-control flag (renders null when nothing pending). - shared-types: InsurancePromptDto / InsuranceUrgencyTier. refs #1466 Co-Authored-By: Claude Opus 4.8 --- .../_components/insurance-prompt-card.tsx | 78 ++++++ .../[id]/_components/trip-automations.tsx | 20 +- apps/admin/src/hooks/use-automations.ts | 18 ++ .../trips/insurance-prompt.resolver.spec.ts | 234 ++++++++++++++++++ .../src/trips/insurance-prompt.resolver.ts | 161 ++++++++++++ .../trips/trip-automations.controller.spec.ts | 158 ++++++++++++ .../src/trips/trip-automations.controller.ts | 111 ++++++++- .../shared-types/src/api/insurance.types.ts | 32 +++ 8 files changed, 806 insertions(+), 6 deletions(-) create mode 100644 apps/admin/src/app/trips/[id]/_components/insurance-prompt-card.tsx create mode 100644 apps/api/src/trips/insurance-prompt.resolver.spec.ts create mode 100644 apps/api/src/trips/insurance-prompt.resolver.ts diff --git a/apps/admin/src/app/trips/[id]/_components/insurance-prompt-card.tsx b/apps/admin/src/app/trips/[id]/_components/insurance-prompt-card.tsx new file mode 100644 index 000000000..24be511eb --- /dev/null +++ b/apps/admin/src/app/trips/[id]/_components/insurance-prompt-card.tsx @@ -0,0 +1,78 @@ +'use client' + +/** + * Insurance-prompt card — Automation Gate P3a (#1466). + * + * A PERSISTENT, NON-BLOCKING prompt rendered above the grouped automation jobs + * while any traveler's insurance decision is still pending. It deep-links to the + * Insurance tab (?tab=insurance) — it does NOT re-implement the proposal dialog. + * + * The urgency badge is DISPLAY-ONLY (Al: purchase_window_hours never gates). + * The underlying read-model fails open to "no badge" / no card on any error, so + * this can never block the Automation tab from rendering. + */ + +import * as React from 'react' +import { useRouter } from 'next/navigation' +import { Shield, ChevronRight } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { useInsurancePrompt } from '@/hooks/use-automations' +import type { InsuranceUrgencyTier } from '@tailfire/shared-types/api' + +interface InsurancePromptCardProps { + tripId: string +} + +const URGENCY_BADGE: Record< + Exclude, + { label: string; className: string } +> = { + urgent: { label: 'Purchase window closing', className: 'bg-red-100 text-red-700' }, + soon: { label: 'Purchase window soon', className: 'bg-amber-100 text-amber-700' }, +} + +export function InsurancePromptCard({ tripId }: InsurancePromptCardProps) { + const router = useRouter() + const { data } = useInsurancePrompt(tripId) + + // Fail-open: no data yet, or nothing pending → render nothing (card hidden + // once every traveler's insurance is resolved). + if (!data || !data.anyPending) return null + + const badge = data.urgency === 'none' ? null : URGENCY_BADGE[data.urgency] + const count = data.pendingCount + const travelerWord = count === 1 ? 'traveller' : 'travellers' + + return ( +
+
+ +
+
+

Insurance not yet confirmed

+ {badge && ( + + {badge.label} + + )} +
+

+ {count} {travelerWord} still {count === 1 ? 'needs' : 'need'} an insurance decision. + Confirm coverage, record their own policy, or log a declination on the Insurance tab. +

+
+
+ +
+ ) +} diff --git a/apps/admin/src/app/trips/[id]/_components/trip-automations.tsx b/apps/admin/src/app/trips/[id]/_components/trip-automations.tsx index eb9a950b1..bc32c75aa 100644 --- a/apps/admin/src/app/trips/[id]/_components/trip-automations.tsx +++ b/apps/admin/src/app/trips/[id]/_components/trip-automations.tsx @@ -29,6 +29,7 @@ import { import { AutomationScheduleDialog } from './automation-schedule-dialog' import { AutomationRecalibrateButton } from './automation-recalibrate-button' import { AutomationTasksSection } from './automation-tasks-section' +import { InsurancePromptCard } from './insurance-prompt-card' import { useTripAutomations, usePauseAutomation, @@ -364,13 +365,19 @@ export function TripAutomations({ trip }: TripAutomationsProps) { } // Flag OFF + no jobs → exact v1 empty state (no header), keeping v1 inert. + // The insurance prompt (#1466 P3a) is NOT gated by the automation-control flag — + // it renders above the empty state whenever a traveler's insurance is pending, + // and returns null otherwise (so the flag-off/no-pending case stays byte-identical). if (!controlsEnabled && sortedJobs.length === 0) { return ( - } - title="No automation jobs yet" - description="Jobs will appear here when you initiate insurance proposals or other automated workflows." - /> +
+ + } + title="No automation jobs yet" + description="Jobs will appear here when you initiate insurance proposals or other automated workflows." + /> +
) } @@ -504,6 +511,9 @@ export function TripAutomations({ trip }: TripAutomationsProps) { {renderHeader()} {renderFilterBar()} + {/* #1466 P3a — persistent, non-blocking insurance prompt above the jobs. */} + + {pendingReview.length > 0 && renderGroup( '__pending_review__', diff --git a/apps/admin/src/hooks/use-automations.ts b/apps/admin/src/hooks/use-automations.ts index d53372cc3..23a2ceee4 100644 --- a/apps/admin/src/hooks/use-automations.ts +++ b/apps/admin/src/hooks/use-automations.ts @@ -27,6 +27,7 @@ import type { CreateAdHocAutomationParams, EditAutomationContent, ExecuteNowResult, + InsurancePromptDto, JobIdentity, JobSelector, RescheduleMode, @@ -48,6 +49,7 @@ export type { ReconcileApplyItem, ReconcileApplyRequest, ReconcileApplyResponse, + InsurancePromptDto, JobIdentity, JobSelector, RescheduleMode, @@ -147,6 +149,8 @@ export const tripAutomationKeys = { list: (tripId: string) => [...tripAutomationKeys.all, tripId] as const, insurancePreview: (tripId: string) => [...tripAutomationKeys.all, 'insurance-preview', tripId] as const, + insurancePrompt: (tripId: string) => + [...tripAutomationKeys.all, 'insurance-prompt', tripId] as const, contentPreview: (tripId: string, queueName: string, jobId: string) => [...tripAutomationKeys.all, 'content-preview', tripId, queueName, jobId] as const, reconcilePreview: (tripId: string) => @@ -208,6 +212,20 @@ export function useInsurancePreview(tripId: string, enabled = true) { }) } +/** + * Fetch the non-blocking insurance-prompt read-model (#1466 P3a). Drives the + * persistent prompt card in the Automation tab: `anyPending` toggles the card, + * `urgency` is a DISPLAY badge only. Read-only; the endpoint fails open to an + * empty read-model, so this never blocks the tab render. + */ +export function useInsurancePrompt(tripId: string, enabled = true) { + return useQuery({ + queryKey: tripAutomationKeys.insurancePrompt(tripId), + queryFn: () => api.get(`/trips/${tripId}/automations/insurance-prompt`), + enabled: !!tripId && enabled, + }) +} + /** * Resolve the channel-aware rendered content of a queued/paused job for the * preview pane + the edit modal's seed (WS-2 content-preview). Read-only: diff --git a/apps/api/src/trips/insurance-prompt.resolver.spec.ts b/apps/api/src/trips/insurance-prompt.resolver.spec.ts new file mode 100644 index 000000000..a095e99a2 --- /dev/null +++ b/apps/api/src/trips/insurance-prompt.resolver.spec.ts @@ -0,0 +1,234 @@ +/** + * Tests for resolveInsurancePrompt — Automation Gate P3a (#1466). + * + * Pure read-model: per-traveler {status, resolved, urgency}. purchase_window_hours + * is a DISPLAY badge only (Al's locked decision) — nothing here gates anything. + * The function must NEVER throw; every degenerate input falls to a safe 'none'. + */ + +import { + resolveInsurancePrompt, + type InsurancePromptPackageInput, + type InsurancePromptTravelerInput, +} from './insurance-prompt.resolver' +import type { TravelerInsuranceStatus } from '@tailfire/shared-types' + +// Fixed clock/anchor so the tests are deterministic (no Date.now()). +const NOW = Date.UTC(2026, 6, 5, 12, 0, 0) // 2026-07-05T12:00:00Z +const HOUR = 60 * 60 * 1000 +const PRODUCT_ID = '11111111-1111-1111-1111-111111111111' +const WINDOW = 72 // hours + +const traveler = ( + status: TravelerInsuranceStatus, + tripTravelerId = `t-${status}`, +): InsurancePromptTravelerInput => ({ tripTravelerId, status }) + +const catalogPackage: InsurancePromptPackageInput = { + insuranceProductId: PRODUCT_ID, + isActive: true, +} + +const windowMap = new Map([[PRODUCT_ID, WINDOW]]) + +/** + * Pick an anchor so that `remainingHours` = target for a WINDOW-hour product. + * remaining = (anchor + WINDOW*H - now)/H ⇒ anchor = now + (remaining - WINDOW)*H + */ +const anchorForRemaining = (remainingHours: number) => + NOW + (remainingHours - WINDOW) * HOUR + +describe('resolveInsurancePrompt (#1466 P3a)', () => { + describe('resolved flag maps from status', () => { + it('marks only pending as unresolved; the other 3 statuses are resolved', () => { + const statuses: TravelerInsuranceStatus[] = [ + 'pending', + 'has_own_insurance', + 'declined', + 'selected_package', + ] + const result = resolveInsurancePrompt( + statuses.map((s) => traveler(s)), + [catalogPackage], + windowMap, + NOW, + anchorForRemaining(48), + ) + + const byStatus = new Map(result.travelers.map((t) => [t.status, t.resolved])) + expect(byStatus.get('pending')).toBe(false) + expect(byStatus.get('has_own_insurance')).toBe(true) + expect(byStatus.get('declined')).toBe(true) + expect(byStatus.get('selected_package')).toBe(true) + }) + + it('resolved travelers always get urgency none regardless of window', () => { + const result = resolveInsurancePrompt( + [traveler('declined'), traveler('selected_package'), traveler('has_own_insurance')], + [catalogPackage], + windowMap, + NOW, + anchorForRemaining(1), // would be "urgent" if they were pending + ) + for (const t of result.travelers) { + expect(t.urgency).toBe('none') + } + expect(result.anyPending).toBe(false) + expect(result.pendingCount).toBe(0) + expect(result.urgency).toBe('none') + }) + }) + + describe('urgency tier boundaries (window = 72h)', () => { + const tierAt = (remainingHours: number) => + resolveInsurancePrompt( + [traveler('pending')], + [catalogPackage], + windowMap, + NOW, + anchorForRemaining(remainingHours), + ).urgency + + it('remaining 23h59m → urgent (< 24h)', () => { + expect(tierAt(23 + 59 / 60)).toBe('urgent') + }) + + it('remaining exactly 24h → soon (not < 24h, still < window)', () => { + expect(tierAt(24)).toBe('soon') + }) + + it('remaining window-1 (71h) → soon (< window)', () => { + expect(tierAt(WINDOW - 1)).toBe('soon') + }) + + it('remaining exactly window (72h) → none (not < window; at the anchor)', () => { + expect(tierAt(WINDOW)).toBe('none') + }) + + it('past the deadline (negative remaining) → urgent', () => { + expect(tierAt(-5)).toBe('urgent') + }) + }) + + describe('window sourcing', () => { + it('no active catalog-linked packages → none tier', () => { + const result = resolveInsurancePrompt( + [traveler('pending')], + [], // no packages at all + windowMap, + NOW, + anchorForRemaining(1), + ) + expect(result.travelers[0]?.urgency).toBe('none') + }) + + it('custom / off-catalog package (no productId) → none tier', () => { + const custom: InsurancePromptPackageInput = { insuranceProductId: null, isActive: true } + const result = resolveInsurancePrompt( + [traveler('pending')], + [custom], + windowMap, + NOW, + anchorForRemaining(1), + ) + expect(result.travelers[0]?.urgency).toBe('none') + }) + + it('inactive catalog package is ignored → none tier', () => { + const inactive: InsurancePromptPackageInput = { + insuranceProductId: PRODUCT_ID, + isActive: false, + } + const result = resolveInsurancePrompt( + [traveler('pending')], + [inactive], + windowMap, + NOW, + anchorForRemaining(1), + ) + expect(result.travelers[0]?.urgency).toBe('none') + }) + + it('uses the SOONEST (smallest) window across active catalog packages', () => { + const shortId = '22222222-2222-2222-2222-222222222222' + const map = new Map([ + [PRODUCT_ID, WINDOW], // 72h + [shortId, 30], // 30h — the soonest + ]) + // Anchor so the SHORT (30h) window leaves 20h remaining → urgent (< 24h). + // The long (72h) window at the same anchor would leave 62h → soon. Getting + // 'urgent' proves the resolver picked the soonest (30h) window. + const anchor = NOW + (20 - 30) * HOUR + const result = resolveInsurancePrompt( + [traveler('pending')], + [ + { insuranceProductId: PRODUCT_ID, isActive: true }, + { insuranceProductId: shortId, isActive: true }, + ], + map, + NOW, + anchor, + ) + expect(result.travelers[0]?.urgency).toBe('urgent') + }) + }) + + describe('missing anchor / degenerate inputs never throw', () => { + it('missing booking anchor → none', () => { + const result = resolveInsurancePrompt( + [traveler('pending')], + [catalogPackage], + windowMap, + NOW, + null, + ) + expect(result.travelers[0]?.urgency).toBe('none') + }) + + it('empty travelers → anyPending false, urgency none, no throw', () => { + const result = resolveInsurancePrompt([], [catalogPackage], windowMap, NOW, anchorForRemaining(1)) + expect(result.anyPending).toBe(false) + expect(result.pendingCount).toBe(0) + expect(result.urgency).toBe('none') + expect(result.travelers).toEqual([]) + }) + + it('tolerates non-array / non-Map inputs without throwing', () => { + expect(() => + resolveInsurancePrompt( + undefined as unknown as InsurancePromptTravelerInput[], + undefined as unknown as InsurancePromptPackageInput[], + undefined as unknown as Map, + NOW, + null, + ), + ).not.toThrow() + }) + }) + + describe('aggregate: pending count + anyPending (declined excluded)', () => { + it('2 pending + 1 declined → anyPending true, pendingCount 2', () => { + const result = resolveInsurancePrompt( + [traveler('pending', 'a'), traveler('pending', 'b'), traveler('declined', 'c')], + [catalogPackage], + windowMap, + NOW, + anchorForRemaining(48), + ) + expect(result.anyPending).toBe(true) + expect(result.pendingCount).toBe(2) + }) + + it('overall urgency is the worst tier across pending travelers', () => { + // Both pending share the same window/anchor → both soon → overall soon. + const result = resolveInsurancePrompt( + [traveler('pending', 'a'), traveler('pending', 'b')], + [catalogPackage], + windowMap, + NOW, + anchorForRemaining(48), // 48h remaining → soon + ) + expect(result.urgency).toBe('soon') + }) + }) +}) diff --git a/apps/api/src/trips/insurance-prompt.resolver.ts b/apps/api/src/trips/insurance-prompt.resolver.ts new file mode 100644 index 000000000..a51af29c8 --- /dev/null +++ b/apps/api/src/trips/insurance-prompt.resolver.ts @@ -0,0 +1,161 @@ +/** + * Insurance-prompt resolver — Automation Gate P3a (epic #1466). + * + * PURE function that turns per-traveler insurance status + the trip's active + * insurance packages into a non-blocking prompt read-model for the Trip + * Automation tab. It produces, per traveler, whether their insurance is + * RESOLVED (status !== 'pending') and a DISPLAY-ONLY urgency tier derived from + * the catalog product's purchase_window_hours. + * + * Locked decisions (Al, supersedes #837's "enforce 72h" language): + * - purchase_window_hours is a DISPLAY BADGE ONLY — it NEVER gates anything. + * - Booking anchor = trips.lastStatusChangeAt (set at planning→active). + * - The badge is computed against CATALOG-LINKED packages only (packages whose + * insuranceProductId is non-null AND the product exposes a window). Custom / + * off-catalog packages contribute no window → the traveler falls to 'none'. + * - Pending travelers share the SOONEST window across active catalog-linked + * packages (worst-case display — the shortest window = earliest deadline). + * + * This function must NEVER throw: every missing/degenerate input resolves to a + * safe 'none' tier so the caller can fail-open to no badge and never block the + * tab render. + */ + +import type { TravelerInsuranceStatus } from '@tailfire/shared-types' + +const MS_PER_HOUR = 60 * 60 * 1000 +const URGENT_THRESHOLD_HOURS = 24 + +/** DISPLAY-ONLY urgency tiers. Never a gate. */ +export type InsuranceUrgencyTier = 'urgent' | 'soon' | 'none' + +/** One traveler's insurance status (subset of the traveler-insurance record). */ +export interface InsurancePromptTravelerInput { + tripTravelerId: string + status: TravelerInsuranceStatus +} + +/** + * One active trip insurance package (subset). Only catalog-linked packages + * (insuranceProductId non-null) with a product window contribute to the badge. + */ +export interface InsurancePromptPackageInput { + insuranceProductId: string | null + isActive?: boolean | null +} + +/** Per-traveler prompt read-model. */ +export interface InsurancePromptTravelerResult { + tripTravelerId: string + status: TravelerInsuranceStatus + resolved: boolean + urgency: InsuranceUrgencyTier +} + +/** Whole-trip prompt read-model returned by the resolver. */ +export interface InsurancePromptResult { + travelers: InsurancePromptTravelerResult[] + /** True while any traveler is still pending. Drives card visibility. */ + anyPending: boolean + /** Count of pending travelers (resolved statuses excluded). */ + pendingCount: number + /** Worst urgency tier across pending travelers (urgent > soon > none). */ + urgency: InsuranceUrgencyTier +} + +/** + * Soonest (smallest) purchase_window_hours across active catalog-linked + * packages — the worst-case display window. Returns null when no active + * catalog-linked package exposes a window. + */ +function computeSoonestWindowHours( + packages: InsurancePromptPackageInput[], + productWindowHours: Map, +): number | null { + let soonest: number | null = null + for (const pkg of packages) { + if (pkg.isActive === false) continue + if (!pkg.insuranceProductId) continue // custom / off-catalog → no window + const window = productWindowHours.get(pkg.insuranceProductId) + if (window == null || window <= 0) continue + if (soonest === null || window < soonest) soonest = window + } + return soonest +} + +/** + * Urgency tier for a single pending traveler. Boundaries (strict less-than): + * - remaining < 24h → 'urgent' + * - 24h <= remaining < windowHours → 'soon' + * - remaining >= windowHours → 'none' (i.e. exactly at / before the anchor) + * Any missing anchor or window → 'none'. + */ +function urgencyTier( + windowHours: number | null, + nowMs: number, + bookingAnchorMs: number | null, +): InsuranceUrgencyTier { + if (bookingAnchorMs == null || !Number.isFinite(bookingAnchorMs)) return 'none' + if (windowHours == null || !Number.isFinite(windowHours) || windowHours <= 0) return 'none' + if (!Number.isFinite(nowMs)) return 'none' + + const deadlineMs = bookingAnchorMs + windowHours * MS_PER_HOUR + const remainingHours = (deadlineMs - nowMs) / MS_PER_HOUR + + if (remainingHours < URGENT_THRESHOLD_HOURS) return 'urgent' + if (remainingHours < windowHours) return 'soon' + return 'none' +} + +/** urgent > soon > none */ +function worstTier(a: InsuranceUrgencyTier, b: InsuranceUrgencyTier): InsuranceUrgencyTier { + if (a === 'urgent' || b === 'urgent') return 'urgent' + if (a === 'soon' || b === 'soon') return 'soon' + return 'none' +} + +/** + * Resolve the non-blocking insurance prompt for a trip. + * + * @param travelers per-traveler insurance status records + * @param activePackages the trip's insurance packages (active ones counted) + * @param productWindowHours Map insuranceProductId → purchase_window_hours + * @param nowMs current time (epoch ms) + * @param bookingAnchorMs trips.lastStatusChangeAt (epoch ms) or null + */ +export function resolveInsurancePrompt( + travelers: InsurancePromptTravelerInput[], + activePackages: InsurancePromptPackageInput[], + productWindowHours: Map, + nowMs: number, + bookingAnchorMs: number | null, +): InsurancePromptResult { + const safeTravelers = Array.isArray(travelers) ? travelers : [] + const safePackages = Array.isArray(activePackages) ? activePackages : [] + const windowMap = productWindowHours instanceof Map ? productWindowHours : new Map() + + const windowHours = computeSoonestWindowHours(safePackages, windowMap) + + const results: InsurancePromptTravelerResult[] = safeTravelers.map((t) => { + const resolved = t.status !== 'pending' + return { + tripTravelerId: t.tripTravelerId, + status: t.status, + resolved, + urgency: resolved ? 'none' : urgencyTier(windowHours, nowMs, bookingAnchorMs), + } + }) + + const pending = results.filter((r) => !r.resolved) + const urgency = pending.reduce( + (acc, r) => worstTier(acc, r.urgency), + 'none', + ) + + return { + travelers: results, + anyPending: pending.length > 0, + pendingCount: pending.length, + urgency, + } +} diff --git a/apps/api/src/trips/trip-automations.controller.spec.ts b/apps/api/src/trips/trip-automations.controller.spec.ts index 593022a0b..40c64eb60 100644 --- a/apps/api/src/trips/trip-automations.controller.spec.ts +++ b/apps/api/src/trips/trip-automations.controller.spec.ts @@ -52,6 +52,7 @@ describe('TripAutomationsController (WS-1)', () => { return new TripAutomationsController( { verifyReadAccess, verifyWriteAccess: jest.fn() } as any, {} as any, // InsuranceAutomationService + {} as any, // InsuranceService (#1466 P3a) {} as any, // AutomationService control as any, // AutomationControlService {} as any, // TripAutomationReconcileService (#1066 P1) @@ -199,6 +200,7 @@ describe('TripAutomationsController (WS-1)', () => { const ctrl = new TripAutomationsController( { verifyReadAccess: jest.fn(), verifyWriteAccess: jest.fn() } as any, {} as any, + {} as any, // InsuranceService (#1466 P3a) { schedule: scheduleMock } as any, control as any, {} as any, @@ -239,3 +241,159 @@ describe('TripAutomationsController (WS-1)', () => { }) }) }) + +// ============================================================================ +// #1466 P3a — GET automations/insurance-prompt (non-blocking read-model) +// ============================================================================ + +const PROMPT_PRODUCT_ID = '11111111-1111-1111-1111-111111111111' + +describe('TripAutomationsController.getInsurancePrompt (#1466 P3a)', () => { + const buildController = (opts: { + verifyReadAccess?: jest.Mock + getPackages?: jest.Mock + /** + * DB results dequeued in call order: + * [0] trips anchor row(s) (via `.limit(1)`) + * [1] trip_travelers ⟕ insurance (via awaited `.where(...)`) + * [2] insurance_products window(s) (via awaited `.where(...)`) + */ + dbQueue?: any[][] + }) => { + const verifyReadAccess = opts.verifyReadAccess ?? jest.fn().mockResolvedValue(undefined) + const insuranceService = { + getPackages: + opts.getPackages ?? jest.fn().mockResolvedValue({ tripId: TRIP_ID, packages: [] }), + } + + // A drizzle-ish chain where the trips query uses `.limit(1)` and the traveler + // join / products queries await `.where(...)` directly; all dequeue from + // dbQueue in call order. + const queue = opts.dbQueue ?? [] + let i = 0 + const dequeue = () => Promise.resolve(queue[i++] ?? []) + const whereResult: any = { + limit: jest.fn(() => dequeue()), + then: (resolve: any, reject: any) => dequeue().then(resolve, reject), + } + const chain: any = { + select: jest.fn(() => chain), + from: jest.fn(() => chain), + leftJoin: jest.fn(() => chain), + where: jest.fn(() => whereResult), + limit: jest.fn(() => dequeue()), + } + const db = { + schema: { + trips: schema.trips, + tripTravelers: schema.tripTravelers, + tripTravelerInsurance: schema.tripTravelerInsurance, + insuranceProducts: schema.insuranceProducts, + }, + client: chain, + } as any + + const controller = new TripAutomationsController( + { verifyReadAccess, verifyWriteAccess: jest.fn() } as any, + {} as any, // InsuranceAutomationService + insuranceService as any, // InsuranceService (#1466 P3a) + {} as any, // AutomationService + {} as any, // AutomationControlService + {} as any, // TripAutomationReconcileService + {} as any, // TripAutomationApplyService + db, + ) + return { controller, verifyReadAccess, insuranceService } + } + + it('verifies read access before reading anything', async () => { + const verifyReadAccess = jest.fn().mockResolvedValue(undefined) + const { controller } = buildController({ verifyReadAccess }) + await controller.getInsurancePrompt(AUTH, TRIP_ID) + expect(verifyReadAccess).toHaveBeenCalledWith(TRIP_ID, AUTH) + }) + + it('2 pending + 1 declined → anyPending true, declined excluded from count', async () => { + // Anchor 46h ago with a 72h window ⇒ ~26h remaining ⇒ pending land on "soon" + // (comfortably clear of the 24h "urgent" boundary despite Date.now() drift). + const anchor = new Date(Date.now() - 46 * 60 * 60 * 1000) + const { controller } = buildController({ + getPackages: jest.fn().mockResolvedValue({ + tripId: TRIP_ID, + packages: [{ insuranceProductId: PROMPT_PRODUCT_ID, isActive: true }], + }), + dbQueue: [ + [{ lastStatusChangeAt: anchor }], + [ + { tripTravelerId: 'a', status: 'pending' }, + { tripTravelerId: 'b', status: 'pending' }, + { tripTravelerId: 'c', status: 'declined' }, + ], + [{ id: PROMPT_PRODUCT_ID, purchaseWindowHours: 72 }], + ], + }) + + const result = await controller.getInsurancePrompt(AUTH, TRIP_ID) + expect(result.anyPending).toBe(true) + expect(result.pendingCount).toBe(2) + expect(result.urgency).toBe('soon') + expect(result.travelers).toHaveLength(3) + }) + + it('traveler with NO insurance row counts as pending → anyPending true', async () => { + // LEFT JOIN yields status=null for a traveler with no trip_traveler_insurance + // row; the controller coalesces it to 'pending' (mirrors the Insurance tab). + const anchor = new Date(Date.now() - 46 * 60 * 60 * 1000) + const { controller } = buildController({ + getPackages: jest.fn().mockResolvedValue({ + tripId: TRIP_ID, + packages: [{ insuranceProductId: PROMPT_PRODUCT_ID, isActive: true }], + }), + dbQueue: [ + [{ lastStatusChangeAt: anchor }], + [{ tripTravelerId: 'x', status: null }], // no insurance row + [{ id: PROMPT_PRODUCT_ID, purchaseWindowHours: 72 }], + ], + }) + const result = await controller.getInsurancePrompt(AUTH, TRIP_ID) + expect(result.anyPending).toBe(true) + expect(result.pendingCount).toBe(1) + expect(result.travelers[0]?.status).toBe('pending') + expect(result.travelers[0]?.resolved).toBe(false) + }) + + it('all resolved → anyPending false, urgency none', async () => { + const { controller } = buildController({ + getPackages: jest.fn().mockResolvedValue({ + tripId: TRIP_ID, + packages: [{ insuranceProductId: PROMPT_PRODUCT_ID, isActive: true }], + }), + dbQueue: [ + [{ lastStatusChangeAt: new Date() }], + [ + { tripTravelerId: 'a', status: 'declined' }, + { tripTravelerId: 'b', status: 'selected_package' }, + ], + [{ id: PROMPT_PRODUCT_ID, purchaseWindowHours: 72 }], + ], + }) + const result = await controller.getInsurancePrompt(AUTH, TRIP_ID) + expect(result.anyPending).toBe(false) + expect(result.pendingCount).toBe(0) + expect(result.urgency).toBe('none') + }) + + it('fails open to an empty read-model when a downstream read throws (never blocks the tab)', async () => { + const { controller } = buildController({ + getPackages: jest.fn().mockRejectedValue(new Error('db down')), + }) + const result = await controller.getInsurancePrompt(AUTH, TRIP_ID) + expect(result).toEqual({ travelers: [], anyPending: false, pendingCount: 0, urgency: 'none' }) + }) + + it('propagates an access-denied error (does NOT fail open on authz)', async () => { + const verifyReadAccess = jest.fn().mockRejectedValue(new Error('forbidden')) + const { controller } = buildController({ verifyReadAccess }) + await expect(controller.getInsurancePrompt(AUTH, TRIP_ID)).rejects.toThrow(/forbidden/) + }) +}) diff --git a/apps/api/src/trips/trip-automations.controller.ts b/apps/api/src/trips/trip-automations.controller.ts index 2d247bad8..200e8bd30 100644 --- a/apps/api/src/trips/trip-automations.controller.ts +++ b/apps/api/src/trips/trip-automations.controller.ts @@ -28,11 +28,14 @@ import { ForbiddenException, } from '@nestjs/common' import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiBody, ApiQuery } from '@nestjs/swagger' -import { eq, and, or, sql, desc } from 'drizzle-orm' +import { eq, and, or, sql, desc, inArray } from 'drizzle-orm' import { GetAuthContext } from '../auth/decorators/auth-context.decorator' import type { AuthContext } from '../auth/auth.types' +import type { InsurancePromptDto } from '@tailfire/shared-types' import { TripAccessService } from './trip-access.service' import { InsuranceAutomationService } from './insurance-automation.service' +import { InsuranceService } from './insurance.service' +import { resolveInsurancePrompt } from './insurance-prompt.resolver' import { AutomationService } from '../automation/automation.service' import { isSystemAutomation } from '../automation/automation.types' import { AutomationControlService } from './automation-control.service' @@ -55,6 +58,7 @@ export class TripAutomationsController { constructor( private readonly tripAccessService: TripAccessService, private readonly insuranceAutomationService: InsuranceAutomationService, + private readonly insuranceService: InsuranceService, private readonly automationService: AutomationService, private readonly automationControlService: AutomationControlService, private readonly reconcileService: TripAutomationReconcileService, @@ -515,6 +519,111 @@ export class TripAutomationsController { return this.insuranceAutomationService.getProposalPreview(tripId) } + /** + * GET /trips/:tripId/automations/insurance-prompt + * + * Automation Gate P3a (#1466). Non-blocking insurance-prompt read-model for + * the Trip Automation tab: per-traveler resolved flag + a DISPLAY-ONLY urgency + * badge derived from the trip's booking anchor (trips.lastStatusChangeAt) and + * the catalog product's purchase_window_hours. purchase_window_hours NEVER + * gates anything (Al's locked decision). + * + * Read-only. Enumerates ALL trip travelers (trip_travelers LEFT JOIN + * trip_traveler_insurance, missing row → 'pending' — mirrors the Insurance + * tab's `insurance?.status || 'pending'`), reuses InsuranceService.getPackages, + * and does a small join on insurance_products for the windows. + * FAILS OPEN to "no badge" on any error so it can never block the tab render. + */ + @Get('automations/insurance-prompt') + @ApiOperation({ summary: 'Non-blocking insurance prompt read-model for a trip' }) + @ApiResponse({ status: 200, description: 'Per-traveler resolved flag + display urgency badge' }) + async getInsurancePrompt( + @GetAuthContext() auth: AuthContext, + @Param('tripId') tripId: string, + ): Promise { + await this.tripAccessService.verifyReadAccess(tripId, auth) + + const empty: InsurancePromptDto = { + travelers: [], + anyPending: false, + pendingCount: 0, + urgency: 'none', + } + + try { + // Booking anchor = when the trip last changed status (planning→active). + const [trip] = await this.db.client + .select({ lastStatusChangeAt: this.db.schema.trips.lastStatusChangeAt }) + .from(this.db.schema.trips) + .where(eq(this.db.schema.trips.id, tripId)) + .limit(1) + const bookingAnchorMs = trip?.lastStatusChangeAt + ? new Date(trip.lastStatusChangeAt).getTime() + : null + + // Enumerate EVERY trip traveler, not only those with an insurance row: a + // traveler with no trip_traveler_insurance row is pending (existing UX + // treats a missing row as 'pending'). Sourcing only existing rows would + // hide the prompt on a fresh trip — exactly when it is most needed. + const { tripTravelers, tripTravelerInsurance } = this.db.schema + const [travelerRows, { packages }] = await Promise.all([ + this.db.client + .select({ + tripTravelerId: tripTravelers.id, + status: tripTravelerInsurance.status, + }) + .from(tripTravelers) + .leftJoin( + tripTravelerInsurance, + and( + eq(tripTravelerInsurance.tripTravelerId, tripTravelers.id), + eq(tripTravelerInsurance.tripId, tripId), + ), + ) + .where(eq(tripTravelers.tripId, tripId)), + this.insuranceService.getPackages(tripId), + ]) + + const activePackages = packages.filter((p) => p.isActive !== false) + + // Map catalog product → purchase_window_hours (display badge only). + const productIds = Array.from( + new Set( + activePackages + .map((p) => p.insuranceProductId) + .filter((id): id is string => !!id), + ), + ) + const windowMap = new Map() + if (productIds.length > 0) { + const products = await this.db.client + .select({ + id: this.db.schema.insuranceProducts.id, + purchaseWindowHours: this.db.schema.insuranceProducts.purchaseWindowHours, + }) + .from(this.db.schema.insuranceProducts) + .where(inArray(this.db.schema.insuranceProducts.id, productIds)) + for (const p of products) windowMap.set(p.id, p.purchaseWindowHours) + } + + return resolveInsurancePrompt( + travelerRows.map((t) => ({ + tripTravelerId: t.tripTravelerId, + status: t.status ?? 'pending', // no insurance row → pending + })), + activePackages.map((p) => ({ + insuranceProductId: p.insuranceProductId, + isActive: p.isActive, + })), + windowMap, + Date.now(), + bookingAnchorMs, + ) + } catch { + return empty + } + } + /** * POST /trips/:tripId/insurance/initiate * Queue insurance proposal emails for selected travelers diff --git a/packages/shared-types/src/api/insurance.types.ts b/packages/shared-types/src/api/insurance.types.ts index 7a9750c80..5c8ef1bc3 100644 --- a/packages/shared-types/src/api/insurance.types.ts +++ b/packages/shared-types/src/api/insurance.types.ts @@ -226,6 +226,38 @@ export type TripTravelersInsuranceListDto = { } } +// ============================================================================= +// Automation Gate P3a (#1466) — non-blocking insurance-prompt read-model +// ============================================================================= + +/** + * DISPLAY-ONLY urgency tier for the insurance prompt (Al: purchase_window_hours + * is never a gate). Derived from the trip's booking anchor + the catalog + * product's purchase_window_hours: 'urgent' (< 24h to the window deadline), + * 'soon' (< the window), 'none' (at/before the anchor, resolved, or no window). + */ +export type InsuranceUrgencyTier = 'urgent' | 'soon' | 'none' + +/** Per-traveler entry in the insurance-prompt read-model. */ +export type InsurancePromptTravelerDto = { + tripTravelerId: string + status: TravelerInsuranceStatus + resolved: boolean + urgency: InsuranceUrgencyTier +} + +/** + * Whole-trip insurance-prompt read-model (GET automations/insurance-prompt). + * `anyPending` drives the non-blocking prompt card; `urgency` is the worst tier + * across pending travelers, surfaced as a display badge only. + */ +export type InsurancePromptDto = { + travelers: InsurancePromptTravelerDto[] + anyPending: boolean + pendingCount: number + urgency: InsuranceUrgencyTier +} + // ============================================================================= // #837 Phase 0 — Insurance Catalogue (Library → Trip Components) // =============================================================================