diff --git a/apps/admin/src/app/profile/_components/notifications-tab.tsx b/apps/admin/src/app/profile/_components/notifications-tab.tsx index 67f2974e4..87d24fb4a 100644 --- a/apps/admin/src/app/profile/_components/notifications-tab.tsx +++ b/apps/admin/src/app/profile/_components/notifications-tab.tsx @@ -81,6 +81,10 @@ const CATEGORY_INFO: Record({ diff --git a/apps/admin/src/app/trips/[id]/_components/automation-job-card.tsx b/apps/admin/src/app/trips/[id]/_components/automation-job-card.tsx index cb322b2ca..a8643df6b 100644 --- a/apps/admin/src/app/trips/[id]/_components/automation-job-card.tsx +++ b/apps/admin/src/app/trips/[id]/_components/automation-job-card.tsx @@ -411,14 +411,18 @@ export function AutomationJobCard({ )} - {job.status === 'queued' && ( + {/* refs #1466 P2 — "I've got this" toggle: UI sugar over the existing + pause/resume. 🟢 autopilot (queued/proposed) → Pause hands the + automation to the advisor; ✋ paused → Resume returns it to autopilot. + proposed had no pause affordance before P1 widened the backend. */} + {(job.status === 'queued' || job.status === 'proposed') && ( @@ -430,7 +434,7 @@ export function AutomationJobCard({ className="h-8 w-8" onClick={() => onResume(job)} disabled={isResuming} - title="Resume job" + title="Back to autopilot — let this automation send on schedule" > diff --git a/apps/api/src/automation/automation-gate-nudge.spec.ts b/apps/api/src/automation/automation-gate-nudge.spec.ts new file mode 100644 index 000000000..7d2d1b7a8 --- /dev/null +++ b/apps/api/src/automation/automation-gate-nudge.spec.ts @@ -0,0 +1,122 @@ +/** + * refs #1466 P2 — pure query/grouping + dedupe-key contract for the + * automation-gate courtesy nudge. No DB: given rows + now → which trips nudge. + */ +import { + selectTripsToNudge, + buildNudgeDedupeKey, + buildNudgeCopy, + type NudgeCandidateRow, +} from './automation-gate-nudge' + +const NOW = new Date('2026-07-05T12:00:00.000Z') +const inHours = (h: number) => new Date(NOW.getTime() + h * 60 * 60 * 1000) + +function row(overrides: Partial): NudgeCandidateRow { + return { tripId: 'trip-1', status: 'proposed', scheduledFor: inHours(2), ...overrides } +} + +describe('selectTripsToNudge', () => { + it('includes proposed AND paused rows firing within the next 24h', () => { + const targets = selectTripsToNudge( + [ + row({ tripId: 'trip-1', status: 'proposed', scheduledFor: inHours(2) }), + row({ tripId: 'trip-2', status: 'paused', scheduledFor: inHours(10) }), + ], + NOW, + ) + expect(targets.map((t) => t.tripId)).toEqual(['trip-1', 'trip-2']) + }) + + it('groups multiple rows per trip and reports count + earliest fire', () => { + const targets = selectTripsToNudge( + [ + row({ tripId: 'trip-1', scheduledFor: inHours(6) }), + row({ tripId: 'trip-1', scheduledFor: inHours(2) }), + row({ tripId: 'trip-1', status: 'paused', scheduledFor: inHours(20) }), + ], + NOW, + ) + expect(targets).toHaveLength(1) + expect(targets[0]!.count).toBe(3) + expect(targets[0]!.earliestScheduledFor).toEqual(inHours(2)) + }) + + it('excludes rows outside the (now, now+24h] window', () => { + const targets = selectTripsToNudge( + [ + row({ tripId: 'past', scheduledFor: inHours(-1) }), // already due + row({ tripId: 'now', scheduledFor: NOW }), // exactly now (exclusive) + row({ tripId: 'far', scheduledFor: inHours(25) }), // beyond horizon + row({ tripId: 'edge', scheduledFor: inHours(24) }), // horizon inclusive + ], + NOW, + ) + expect(targets.map((t) => t.tripId)).toEqual(['edge']) + }) + + it('excludes non-gate statuses (queued/completed/cancelled/failed/processing)', () => { + const targets = selectTripsToNudge( + [ + row({ tripId: 't', status: 'queued' }), + row({ tripId: 't', status: 'completed' }), + row({ tripId: 't', status: 'cancelled' }), + row({ tripId: 't', status: 'failed' }), + row({ tripId: 't', status: 'processing' }), + ], + NOW, + ) + expect(targets).toEqual([]) + }) + + it('skips rows with no tripId or no scheduledFor', () => { + const targets = selectTripsToNudge( + [ + row({ tripId: null }), + row({ tripId: 'trip-9', scheduledFor: null }), + ], + NOW, + ) + expect(targets).toEqual([]) + }) + + it('returns trips sorted by id for a deterministic sweep', () => { + const targets = selectTripsToNudge( + [row({ tripId: 'ccc' }), row({ tripId: 'aaa' }), row({ tripId: 'bbb' })], + NOW, + ) + expect(targets.map((t) => t.tripId)).toEqual(['aaa', 'bbb', 'ccc']) + }) +}) + +describe('buildNudgeDedupeKey', () => { + it('embeds the advisor id so the GLOBAL dedupe index never collides across advisors', () => { + const a = buildNudgeDedupeKey('advisor-A', 'trip-1', NOW) + const b = buildNudgeDedupeKey('advisor-B', 'trip-1', NOW) + expect(a).toMatch(/^automation-gate:advisor-A:trip-1:\d+$/) + expect(a).not.toBe(b) + expect(a).toContain('advisor-A') + }) + + it('is stable within a UTC day and rolls over the next day (one nudge/trip/day)', () => { + const morning = buildNudgeDedupeKey('advisor-A', 'trip-1', new Date('2026-07-05T01:00:00.000Z')) + const evening = buildNudgeDedupeKey('advisor-A', 'trip-1', new Date('2026-07-05T23:00:00.000Z')) + const nextDay = buildNudgeDedupeKey('advisor-A', 'trip-1', new Date('2026-07-06T00:30:00.000Z')) + expect(morning).toBe(evening) + expect(nextDay).not.toBe(morning) + }) +}) + +describe('buildNudgeCopy', () => { + it('uses singular copy for one automation', () => { + const { title, body } = buildNudgeCopy({ tripId: 't', count: 1, earliestScheduledFor: NOW }) + expect(title).toBe('Automation awaiting review') + expect(body).toContain('1 automation on this trip is approaching') + }) + + it('uses plural copy for multiple automations', () => { + const { title, body } = buildNudgeCopy({ tripId: 't', count: 3, earliestScheduledFor: NOW }) + expect(title).toBe('3 automations awaiting review') + expect(body).toContain('3 automations on this trip are approaching') + }) +}) diff --git a/apps/api/src/automation/automation-gate-nudge.ts b/apps/api/src/automation/automation-gate-nudge.ts new file mode 100644 index 000000000..c280329fe --- /dev/null +++ b/apps/api/src/automation/automation-gate-nudge.ts @@ -0,0 +1,103 @@ +/** + * refs #1466 P2 — Courtesy nudge for gate automations awaiting advisor action. + * + * Pure query/grouping helpers for the hourly `recurring.automation_gate_nudge` + * sweep. The processor does the DB read + the NotificationService send; these + * functions decide WHICH trips get nudged and build the per-(advisor, trip, day) + * dedupe key. Kept pure so the window/grouping logic is unit-testable with no DB. + * + * The nudge also breaks the "paused-forever silence": a PAUSED gate row nearing + * its original send time surfaces in the same sweep as a PROPOSED (autopilot) + * row, so an advisor who parked an automation still gets a reminder. + */ + +/** Rows read from automation_job_history for the nudge sweep. */ +export interface NudgeCandidateRow { + tripId: string | null + status: string + scheduledFor: Date | null +} + +/** One trip that should receive a nudge this sweep. */ +export interface NudgeTarget { + tripId: string + /** How many gate rows fall in the nudge window (drives the copy). */ + count: number + /** Earliest upcoming scheduled time among them (for ordering / future copy). */ + earliestScheduledFor: Date +} + +/** Statuses that keep an automation in the gate (awaiting advisor action). */ +export const NUDGE_STATUSES = ['proposed', 'paused'] as const + +const NUDGE_WINDOW_MS = 24 * 60 * 60 * 1000 +const ONE_DAY_MS = 24 * 60 * 60 * 1000 + +/** + * Given the candidate rows and the current instant, return the distinct trips + * that have at least one gate automation scheduled inside the next 24h. + * + * Window is (now, now+24h] — strictly after `now` (already-due rows are the + * queue's job to fire, not a courtesy nudge) and inclusive of the 24h horizon. + * A row counts only when it is still in the gate (proposed/paused), carries a + * trip, and has a scheduled_for. Results are sorted by tripId so the sweep is + * deterministic across runs. + */ +export function selectTripsToNudge(rows: NudgeCandidateRow[], now: Date): NudgeTarget[] { + const nowMs = now.getTime() + const horizonMs = nowMs + NUDGE_WINDOW_MS + const byTrip = new Map() + + for (const row of rows) { + if (!row.tripId || !row.scheduledFor) continue + if (!NUDGE_STATUSES.includes(row.status as (typeof NUDGE_STATUSES)[number])) continue + + const atMs = row.scheduledFor.getTime() + if (Number.isNaN(atMs) || atMs <= nowMs || atMs > horizonMs) continue + + const existing = byTrip.get(row.tripId) + if (!existing) { + byTrip.set(row.tripId, { + tripId: row.tripId, + count: 1, + earliestScheduledFor: row.scheduledFor, + }) + } else { + existing.count += 1 + if (atMs < existing.earliestScheduledFor.getTime()) { + existing.earliestScheduledFor = row.scheduledFor + } + } + } + + return [...byTrip.values()].sort((a, b) => a.tripId.localeCompare(b.tripId)) +} + +/** + * Per-(advisor, trip, UTC-day) dedupe key. The platform dedupe index is GLOBAL + * on dedupe_key (not per-user), so the advisor id MUST be embedded — otherwise + * two advisors nudged about the same trip would collide and one would be lost. + * The UTC-day bucket makes it exactly one nudge per trip per day: a 2nd sweep + * the same day claims the same key and is suppressed by the atomic insert. + */ +export function buildNudgeDedupeKey(advisorId: string, tripId: string, now: Date): string { + const bucket = Math.floor(now.getTime() / ONE_DAY_MS) + return `automation-gate:${advisorId}:${tripId}:${bucket}` +} + +/** + * Advisor-facing copy for a trip's nudge. Neutral across proposed (about to + * auto-send) and paused (parked, but nearing its original time) — both simply + * "need attention before they reach their scheduled time". + */ +export function buildNudgeCopy(target: NudgeTarget): { title: string; body: string } { + const n = target.count + const plural = n === 1 ? '' : 's' + return { + title: n === 1 ? 'Automation awaiting review' : `${n} automations awaiting review`, + body: + `${n} automation${plural} on this trip ${n === 1 ? 'is' : 'are'} approaching ` + + `${n === 1 ? 'its' : 'their'} scheduled time. Open the Automations tab to review, ` + + `hand off ("I've got this"), or reschedule ${n === 1 ? 'it' : 'them'}.`, + } +} diff --git a/apps/api/src/automation/automation.service.ts b/apps/api/src/automation/automation.service.ts index b9068aebe..cea8d3263 100644 --- a/apps/api/src/automation/automation.service.ts +++ b/apps/api/src/automation/automation.service.ts @@ -101,6 +101,19 @@ export class AutomationService implements OnModuleInit { ) this.logger.log('Scheduled recurring task due reminder job') + // refs #1466 P2 — hourly automation-gate courtesy nudge. Notifies the trip + // owner when a proposed/paused gate automation is within 24h of firing, and + // breaks the "paused-forever silence". One nudge/trip/day via the + // NotificationService dedupe claim (no state stored here). + await this.scheduleRecurring( + QUEUES.CLIENT_CARE, + 'recurring.automation_gate_nudge', + { type: 'recurring.automation_gate_nudge' }, + '0 * * * *', // Every hour + { jobId: 'recurring:automation_gate_nudge' }, + ) + this.logger.log('Scheduled recurring automation gate nudge job') + // Contact lifecycle: returned → awaiting_next after 30 days await this.scheduleRecurring( QUEUES.CLIENT_CARE, diff --git a/apps/api/src/automation/automation.types.ts b/apps/api/src/automation/automation.types.ts index 282a65805..98bd6ba09 100644 --- a/apps/api/src/automation/automation.types.ts +++ b/apps/api/src/automation/automation.types.ts @@ -77,6 +77,9 @@ export const JOB_TYPES = { RECURRING_OVERDUE_PAYMENT_SCAN: 'recurring.overdue_payment_scan', RECURRING_TASK_ASSIGNMENT_DIGEST: 'recurring.task_assignment_digest', RECURRING_TASK_DUE_REMINDER: 'recurring.task_due_reminder', + // 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', // OCR processing jobs OCR_EXTRACT: 'ocr.extract', @@ -296,7 +299,12 @@ export interface PostTripJobData { * Recurring job data for daily checks */ export interface RecurringJobData { - type: 'recurring.birthday_check' | 'recurring.overdue_payment_scan' | 'recurring.task_assignment_digest' | 'recurring.task_due_reminder' + type: + | 'recurring.birthday_check' + | 'recurring.overdue_payment_scan' + | 'recurring.task_assignment_digest' + | 'recurring.task_due_reminder' + | 'recurring.automation_gate_nudge' agencyId?: string // Optional - if not provided, runs for all agencies } diff --git a/apps/api/src/automation/processors/client-care.processor.automation-gate-nudge.spec.ts b/apps/api/src/automation/processors/client-care.processor.automation-gate-nudge.spec.ts new file mode 100644 index 000000000..5b96e4f31 --- /dev/null +++ b/apps/api/src/automation/processors/client-care.processor.automation-gate-nudge.spec.ts @@ -0,0 +1,110 @@ +/** + * refs #1466 P2 — ClientCareProcessor.handleAutomationGateNudge: + * resolves the trip owner, sends an internal 'automation_gate' nudge with an + * advisor-scoped dedupeKey, and proves a 2nd sweep the same UTC day does NOT + * re-notify (the NotificationService dedupe claim holds). + */ +import { ClientCareProcessor } from './client-care.processor' + +// Sentinel schema tables — identity-compared by the mocked .from(). +const SCHEMA = { + automationJobHistory: { __t: 'history' }, + trips: { __t: 'trips' }, +} as never + +type Row = { tripId: string | null; status: string; scheduledFor: Date | null } +type Owner = { id: string; ownerId: string | null } + +function makeDb(rows: Row[], owners: Owner[]) { + const client = { + select: () => ({ + from: (tbl: { __t: string }) => ({ + where: () => Promise.resolve(tbl.__t === 'history' ? rows : owners), + }), + }), + } + return { client, schema: SCHEMA } +} + +/** Mock send() that mirrors the real atomic dedupe claim (global on dedupe_key). */ +function makeNotificationService() { + const claimed = new Set() + const send = jest.fn(async (params: { dedupeKey?: string }) => { + if (params.dedupeKey && claimed.has(params.dedupeKey)) { + return { channels: [], results: {} } // deduped — suppressed + } + if (params.dedupeKey) claimed.add(params.dedupeKey) + return { channels: ['platform', 'email'], results: {} } + }) + return { send, claimed } +} + +function makeProcessor(db: unknown, notificationService: unknown): ClientCareProcessor { + return new ClientCareProcessor( + db as never, // db + {} as never, // eventEmitter + {} as never, // automationService + {} as never, // contentService + notificationService as never, // notificationService + {} as never, // emailService + {} as never, // documentTemplatesService + {} as never, // contactLifecycleService + ) +} + +// Fixed future time so both sweeps see it inside the (now, now+24h] window and +// on the same UTC day (identical dedupe bucket). +const soon = () => new Date(Date.now() + 3 * 60 * 60 * 1000) + +describe('ClientCareProcessor.handleAutomationGateNudge', () => { + it('nudges the trip owner with an internal automation_gate notification + deep link', async () => { + const db = makeDb( + [{ tripId: 'trip-1', status: 'proposed', scheduledFor: soon() }], + [{ id: 'trip-1', ownerId: 'advisor-1' }], + ) + const notifications = makeNotificationService() + const processor = makeProcessor(db, notifications) + + await (processor as unknown as { handleAutomationGateNudge: () => Promise }).handleAutomationGateNudge() + + expect(notifications.send).toHaveBeenCalledTimes(1) + const arg = notifications.send.mock.calls[0]![0] as Record + expect(arg.userId).toBe('advisor-1') + expect(arg.category).toBe('automation_gate') + expect(arg.actionUrl).toBe('/trips/trip-1?tab=automations') + expect(arg.dedupeKey).toMatch(/^automation-gate:advisor-1:trip-1:\d+$/) + }) + + it('does NOT re-notify on a 2nd sweep the same UTC day (dedupe holds)', async () => { + const rows: Row[] = [{ tripId: 'trip-1', status: 'proposed', scheduledFor: soon() }] + const owners: Owner[] = [{ id: 'trip-1', ownerId: 'advisor-1' }] + const notifications = makeNotificationService() + const processor = makeProcessor(makeDb(rows, owners), notifications) + const run = () => + (processor as unknown as { handleAutomationGateNudge: () => Promise }).handleAutomationGateNudge() + + await run() + await run() + + // Both sweeps attempt a send, but they produce the SAME advisor-scoped key, + // so the claim suppresses the second → exactly one real notification. + expect(notifications.send).toHaveBeenCalledTimes(2) + const key1 = (notifications.send.mock.calls[0]![0] as { dedupeKey: string }).dedupeKey + const key2 = (notifications.send.mock.calls[1]![0] as { dedupeKey: string }).dedupeKey + expect(key1).toBe(key2) + expect(notifications.claimed.size).toBe(1) + }) + + it('skips a trip whose owner is null (no advisor to nudge)', async () => { + const db = makeDb( + [{ tripId: 'trip-1', status: 'paused', scheduledFor: soon() }], + [{ id: 'trip-1', ownerId: null }], + ) + const notifications = makeNotificationService() + const processor = makeProcessor(db, notifications) + + await (processor as unknown as { handleAutomationGateNudge: () => Promise }).handleAutomationGateNudge() + + expect(notifications.send).not.toHaveBeenCalled() + }) +}) diff --git a/apps/api/src/automation/processors/client-care.processor.ts b/apps/api/src/automation/processors/client-care.processor.ts index a4dddccad..247af64ce 100644 --- a/apps/api/src/automation/processors/client-care.processor.ts +++ b/apps/api/src/automation/processors/client-care.processor.ts @@ -14,7 +14,7 @@ import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq' import { Logger, Injectable } from '@nestjs/common' import { Job } from 'bullmq' import { EventEmitter2 } from '@nestjs/event-emitter' -import { eq, and, or, lte, gte, sql, inArray, isNotNull, ne } from 'drizzle-orm' +import { eq, and, or, lte, gte, gt, sql, inArray, isNotNull, ne } from 'drizzle-orm' import type { EditAutomationContent } from '@tailfire/shared-types' import { DatabaseService } from '../../db/database.service' import { AutomationService } from '../automation.service' @@ -36,6 +36,11 @@ import { type InsuranceProposalEmailData, type AdHocTripMessageJobData, } from '../automation.types' +import { + selectTripsToNudge, + buildNudgeDedupeKey, + buildNudgeCopy, +} from '../automation-gate-nudge' /** Escape HTML special characters to prevent markup breakage in email templates */ function escapeHtml(str: string): string { @@ -169,6 +174,11 @@ export class ClientCareProcessor extends WorkerHost { break } + case 'recurring.automation_gate_nudge': { + await this.handleAutomationGateNudge() + break + } + // Insurance proposal emails case 'insurance.proposal.email': { const data = job.data as InsuranceProposalEmailData @@ -1051,6 +1061,91 @@ ${removedTasks.map((t) => `