Skip to content

Escalation watches

If a cleaner doesn’t acknowledge a touchpoint (heads-up, T-2, time-commit), the workflow falls into an escalation loop that pings them AM/PM until they ack or the next phase fires.

How it works

Implemented inline in each touchpoint’s apply step (runEscalationWatch in src/workflows/booking-flag.ts).

After the initial touchpoint sends, computeWatchTimes builds a list of escalation slots:

  • Up to 6 AM/PM slots (cap = MAX_WATCH_SLOTS)
  • Each slot is 9am MT or 5pm MT (15:00Z / 23:00Z)
  • Start: 12 hours after the initial send OR 2 days before arrival (whichever is later)
  • End: end of departure day (so escalation doesn’t run past the next scheduled phase)

For each slot:

  1. step.sleepUntil(slotTime)
  2. step.do("watch-{phase}-{i}-check", ...):
    • Read current cleaning row
    • If ack’d → return (no-op)
    • Open-rate gate: if Mailgun shows the cleaner opened any related email within the last 4 hours → return (they’re aware)
    • Otherwise: re-send the touchpoint via email + SMS

Open-rate-aware behavior

Defined in ENGAGEMENT_PURPOSES:

PhaseCounts as engagement
headsupheads_up, escalation_headsup
t2t2_reminder, escalation_t2
tctime_commit, escalation_tc

If lastEmailOpenedAt(env, bookingId, [...]) < 4h ago, skip this slot. Next slot still fires.

Net effect: a cleaner who opens but doesn’t click gets 4-hour-windowed silence. A cleaner who never opens keeps getting pinged.

Watch ends when

  • Cleaner acks (any *_ack_at set) → loop exits
  • End-bound reached (next phase’s fire time)
  • Slot list exhausted (rare — MAX_WATCH_SLOTS=6)

Source

  • src/workflows/booking-flag.tsrunEscalationWatch, computeWatchTimes
  • src/lib/email-engagement.tslastEmailOpenedAt, openedWithin
  • Tests: test/lib/email-engagement.test.ts (9 cases)