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:
step.sleepUntil(slotTime)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:
| Phase | Counts as engagement |
|---|---|
headsup | heads_up, escalation_headsup |
t2 | t2_reminder, escalation_t2 |
tc | time_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_atset) → loop exits - End-bound reached (next phase’s fire time)
- Slot list exhausted (rare —
MAX_WATCH_SLOTS=6)
Source
src/workflows/booking-flag.ts—runEscalationWatch,computeWatchTimessrc/lib/email-engagement.ts—lastEmailOpenedAt,openedWithin- Tests:
test/lib/email-engagement.test.ts(9 cases)