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
5 changes: 5 additions & 0 deletions apps/admin/src/app/profile/_components/notifications-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ const CATEGORY_INFO: Record<NotificationCategory, { label: string; description:
label: 'Email Deliverability',
description: 'Bounces, spam complaints, and unsubscribes for emails you sent',
},
automation_gate: {
label: 'Automation Reminders',
description: 'Nudges when a trip automation is about to send automatically',
},
}

const CATEGORIES = Object.keys(CATEGORY_INFO) as NotificationCategory[]
Expand Down Expand Up @@ -122,6 +126,7 @@ export function NotificationsTab() {
collaboration: ['email', 'platform'],
payment_alert: ['email', 'push', 'platform'],
portal_message: ['email', 'platform'],
automation_gate: ['platform', 'email'], // refs #1466 P2 — match the server default so the UI shows it enabled
}), [])

const form = useForm<NotificationsFormData>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -411,14 +411,18 @@ export function AutomationJobCard({
</Button>
)}

{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') && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onPause(job)}
disabled={isPausing}
title="Pause job"
title="I've got this — pause autopilot and handle it yourself"
>
<Pause className="h-4 w-4" />
</Button>
Expand All @@ -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"
>
<Play className="h-4 w-4" />
</Button>
Expand Down
122 changes: 122 additions & 0 deletions apps/api/src/automation/automation-gate-nudge.spec.ts
Original file line number Diff line number Diff line change
@@ -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>): 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')
})
})
103 changes: 103 additions & 0 deletions apps/api/src/automation/automation-gate-nudge.ts
Original file line number Diff line number Diff line change
@@ -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<string, NudgeTarget>()

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'}.`,
}
}
13 changes: 13 additions & 0 deletions apps/api/src/automation/automation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/automation/automation.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading