WhatsApp Lifecycle Automation + Egress — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Each task is self-contained; tasks within a phase are mostly independent.Goal: Ship inline lifecycle automation for the 8 ssh & Associates Meta templates, wire the Appointment Status card buttons with state-machine + time gates and auto-fired WhatsApp companions, cut DB egress so the new dispatch + survey cron doesn’t pile cost. Architecture: Extend
automation_trigger_kind enum with 6 inline kinds + 1 cron kind. Add dispatchInlineAutomations() that reuses the existing runOne path. Wire from appointments.ts status PATCH + create POST + reschedule PUT. Add last_visit_completed cron query. Daily reconcile against Meta auto-disables orphaned automations. Egress cuts: narrow patients projections, drop messages page size, replace read-only getTxDb() with getReadDb(), 5-min cache on rarely-changing tables, single SQL per trigger kind in dispatcher.
Tech Stack: Drizzle ORM, Hono on Cloudflare Workers, Neon Postgres, TanStack Query, React 19.
Spec: docs/superpowers/specs/2026-05-18-whatsapp-lifecycle-automation-egress.md
Phase 0 — Egress reductions (preconditions)
Task 1: Narrow patient SELECTs on hot paths
Files:-
Modify:
server/src/routes/appointments.ts(the PATCH/:id/statusquery at line 1146) -
Modify: any other route that does
SELECT *on patients while only using a few fields (grep audit first) -
Replace
.leftJoin(patients, eq(...))with a.select({ ... })that only projectsfirstName, lastName, email, phone, whatsappPhone, hasWhatsapp, phoneHash, whatsappPhoneHash, id. Same shape passed todecryptPatientPHI. -
Run the same audit on
routes/messages.ts,routes/conversations.ts,routes/whatsapp-webhook.ts. Anywhere a patient row is selected for a status/notification path, narrow it. - Commit per file. Diff stat target: −30 lines (the wide column selects), +30 lines (the explicit projection).
- Typecheck.
Task 2: Reduce messages page size on the appointment detail right rail
Files:ui/src/components/appointments/AppointmentDetailPage.tsx
- Find the
getMessages({ patientId, type: 'whatsapp' })call. - Switch to
getMessages({ patientId, type: 'whatsapp', limit: 20 }). - Smoke build.
Task 3: Switch read-only getTxDb() callers to getReadDb()
Files: see grep audit results — routes/admin.ts:3487, routes/leads.ts:344, routes/payroll.ts:252, routes/public-documents.ts:375 (verify each one isn’t actually running a transaction first).
- For each, confirm the handler’s body has no
db.transaction(async tx => ...). If it does, leave it alone. - Switch the
const { db, end } = getTxDb()+c.executionCtx.waitUntil(end())lines toconst db = getReadDb(). - Typecheck.
- Commit.
Task 4: 5-minute cache for subscription_plans, clinic_modules, permission_templates
Files: server/src/lib/permissions.ts, server/src/lib/modules.ts, wherever subscriptionPlans is fetched in middleware.
- Add a tiny in-memory cache helper at
server/src/lib/short-lived-cache.ts:
- In
lib/permissions.ts, wrap the plan + permission-template fetches:await cached('plan:' + planId, () => db.select()...). Similarly inlib/modules.ts. - On the superadmin endpoints that mutate plans / templates / module config, call
invalidateCache('plan:')/invalidateCache('modules:')/invalidateCache('perms:')after the write. - Typecheck.
- Commit.
Phase 0.5 — Per-clinic per-tab toggle layer
Task 4b: Tab toggle helpers + custom error
Files:-
Modify:
server/src/lib/whatsapp.ts -
Create:
server/src/middleware/require-whatsapp-tab.ts -
Modify:
server/src/lib/errors.ts -
Append to
server/src/lib/whatsapp.ts:
- Wrap
isWhatsappTabEnabledin the 5-min cache helper from Task 4 with keywa-tab:${clinicId}:${tab}. - Create middleware factory:
- In
server/src/lib/errors.ts, extendhandleError:
- Typecheck. Commit.
Task 4c: GET /whatsapp-module/tabs endpoint
Files: Createserver/src/routes/whatsapp-module/tabs.ts (or wherever the module routes live), mount in api.ts.
- Endpoint returns
{ inbox: bool, templates: bool, ... }for the requesting user’s clinic. - No tab gate on this endpoint — it’s the discovery endpoint UIs need to know which tabs to render.
- Commit.
Task 4d: Apply requireTab middleware to existing tab-scoped routes
Files:
-
server/src/routes/whatsapp-templates.ts— wrap withrequireTab('templates') -
server/src/routes/whatsapp-automations.ts—requireTab('automations') -
server/src/routes/messages.ts(chat-v2 + composer paths) —requireTab('inbox') -
server/src/routes/whatsapp-events.ts(if exists) —requireTab('events') -
server/src/routes/whatsapp-analytics.ts(if exists) —requireTab('analytics') -
server/src/routes/whatsapp-quick-replies.ts(if exists) —requireTab('quickReplies') -
For each, add
.use('*', requireTab('<tab>'))near the top of the route module. Don’t break existing middleware order. - Typecheck. Commit per file.
Task 4e: Superadmin endpoint to flip a tab
Files:server/src/routes/admin.ts.
- Add:
- Commit.
Task 4f: Inline + cron dispatcher checks the tab
Files:server/src/lib/automation-dispatcher.ts.
- In
dispatchInlineAutomations, before the SELECT, callisWhatsappTabEnabled(clinicId, 'automations'). If false, return{ sent:0, skipped:0, failed:0, reason:'automations_tab_disabled' }. - In
runDueAutomations, when iterating clinics, skip clinics where the tab is off. Easiest: addAND EXISTS (...)clause to the candidate queries, but a simpler approach is to fetch the tab map upfront per clinic and skip the clinic. - In
handleAutomationReconcile(Phase 4, Task 15) — same gate. - Typecheck. Commit.
Task 4g: UI tab-map hook + render guards
Files:-
Create:
ui/src/hooks/useWhatsappTabs.ts -
Modify:
ui/src/components/whatsapp/WhatsAppModule.tsx,ui/src/components/whatsapp-v2/WhatsAppModuleV2.tsx - Hook:
- In each WhatsApp module page, call
useWhatsappTabs()and conditionally render each<TabsTrigger>/<TabsContent>. - If the URL has
?tab=automationsbuttabs.automations === false, fall back to the first enabled tab (or show a “This tab is not available for your clinic — contact support” empty state if none are enabled). - Commit.
Task 4h: Superadmin Tenants → Modules & Addons tab — WhatsApp tab toggles
Files:ui/src/components/superadmin/Tenants/tabs/ModulesAddonsTab.tsx.
- Add a WhatsApp module card with the existing module enable/disable toggle, and when enabled, 6 child toggles for the tabs.
- Each tab toggle calls
PATCH /admin/tenants/:clinicId/whatsapp-tabs { tab, enabled }. - On success, invalidate
tenantKeys.modules(clinicId)and the global['wa-tabs']if the user is impersonating this clinic. - Build. Commit.
Phase 1 — Schema migration
Task 5: Migration 0044 — enum + reconcile columns
Files: Createserver/drizzle/0044_automation_lifecycle_triggers.sql.
- Write SQL:
- Commit.
- Pause for user confirmation before applying to prod. After confirm:
psql "$DBURL" -f server/drizzle/0044_automation_lifecycle_triggers.sql. - Verify:
psql -c "\d app.whatsapp_automations"shows the two new columns and the index.
Task 6: Update Drizzle schema
Files:server/src/schema/whatsapp_automations.ts.
- Add 7 values to the enum:
- Add the two new columns to the table definition:
- Add the new index in the table options.
- Typecheck.
- Commit.
Phase 2 — Dispatcher extension
Task 7: Add dispatchInlineAutomations to the dispatcher
Files: server/src/lib/automation-dispatcher.ts.
- Add the inline kind union and dispatch function:
- Add
'last_visit_completed'case to the existingfindCandidatesswitch:
- Typecheck.
- Commit.
Task 8: Extend AutomationCtx and var resolver
Files: server/src/lib/automation-vars.ts, server/src/lib/automation-dispatcher.ts (buildContext).
- Add
recentCompletedAppointmentandgoogleReviewUrlSuffixtoAutomationCtx:
- In
resolveAutomationVar, add cases:
- In
buildContext(dispatcher), additionally fetch the most-recent completed appointment for the patient and parseclinic.notificationPreferences.googleReviewUrlto its suffix (last path segment). - Typecheck.
- Commit.
Task 9: Filter hello_world from templates endpoint
Files: server/src/routes/whatsapp-templates.ts.
- At the top of the file:
- In the response builder, filter:
- Typecheck.
- Commit.
Task 10: UI defence-in-depth filter for hello_world
Files:
-
ui/src/components/whatsapp/composer/Composer.tsx -
ui/src/components/whatsapp/TemplatesTab.tsx -
ui/src/components/whatsapp-v2/tabs/TemplatesTab.tsx -
ui/src/components/whatsapp-v2/tabs/AutomationsTab.tsx -
ui/src/components/chat-v2/composer/TemplatesPicker.tsx -
In each, find where the templates list is rendered. Add
.filter(t => t.name !== 'hello_world')right after the data is loaded. - Build.
- Commit.
Phase 3 — Lifecycle wiring on the appointment endpoints
Task 11: inlineEventFor helper
Files: Create server/src/lib/lifecycle-events.ts.
- Write the helper:
- Test inline (no need for a test file — covered by integration test in Task 22).
- Commit.
Task 11b: Inline dispatcher returns reason when tab off
Files:server/src/lib/automation-dispatcher.ts.
- Before the SELECT in
dispatchInlineAutomations:
- Type the result union so the route handler can read
.reason. - Commit.
Task 12: Wire status PATCH to fire inline events
Files:server/src/routes/appointments.ts.
- After the transaction commits and
queueAppointmentStatusNotificationsis queued, add:
- Include
whatsapp: whatsappOutcomein the response JSON. - Typecheck.
- Commit.
Task 13: Wire appointment create POST to fire appointment_requested / appointment_scheduled / appointment_confirmed
Files: server/src/routes/appointments.ts (the POST / route).
- After the appointment row is inserted, compute
event = inlineEventFor(null, newRow.status)and dispatch the same way. - Include
whatsappblock in the response. - Commit.
Task 14: Wire reschedule (PUT /:id) to fire appointment_rescheduled
Files: server/src/routes/appointments.ts (the PUT /:id route).
- Capture pre-update
appointmentDate+appointmentTime. After the update commits, if either changed, fireappointment_rescheduledviadispatchInlineAutomations. - Include
whatsappin the response. - Commit.
Phase 4 — Reconcile + auto-mapping
Task 15: Daily reconcile handler
Files:-
Create:
server/src/scheduled/automation-reconcile.ts -
Modify:
server/src/scheduled.ts -
Write the handler that, for each clinic with WhatsApp configured + at least one enabled automation:
- Fetches approved templates via
fetchApprovedTemplates(wabaId, config). - For each enabled automation, if its
templateName + templateLanguageisn’t in the list, disable it and setreconcile_notes.
- Fetches approved templates via
-
Wire in
scheduled.tsto run at 02:00 UTC only:
- Commit.
Task 16: suggest-mapping endpoint
Files: server/src/routes/whatsapp-automations.ts, server/src/lib/automation-templates.ts.
- Add the defaults registry (per the spec’s per-template registry block).
- Add the endpoint
POST /admin/automations/suggest-mapping:
- Commit.
Task 17: AutomationsTab UI — trigger picker + Suggest mapping button + reconcile badge
Files:ui/src/components/whatsapp-v2/tabs/AutomationsTab.tsx.
- Extend the
TRIGGERSarray with 7 new entries — labels and descriptions for the inline kinds +last_visit_completed. - Group triggers in the picker into two sections: “Event-driven (inline)” vs “Time-based (cron)”.
- Add a
Suggest mappingbutton next to the param-mapping editor. Wires toPOST /admin/automations/suggest-mapping. Shows a Sparkle icon and “Auto-mapped from template body — confirm or adjust” hint. - Show a “needs attention” badge for automations whose
reconcile_notesis non-null. Click → filter list. - Build.
- Commit.
Phase 5 — Appointment Overview Status card
Task 18: Status card buttons with state-machine + time gates
Files:ui/src/components/appointments/AppointmentDetailPage.tsx.
- Replace the current Status card buttons with the table from the spec (Confirm / Check In / Complete & Invoice / Reschedule / No Show / Cancel / Send Survey).
- Each button is rendered with:
visiblefrom acanTransitionToFromStatusMachine(currentStatus, nextStatus)helper (uses the samevalidStatusTransitionsmap as the server).enabledfrom a time-gate check (now ≥ apptTime − 30 minetc.) computed in PKT via the existingpkt.tshelpers.tooltipfrom the table when disabled.
- Each click hits
PATCH /:id/statusand surfaces thewhatsappblock from the response in a toast. - Reschedule button opens the existing SlotPicker dialog. On save, calls
PUT /:id, surfaces thewhatsappblock. - Send Survey button fires
POST /admin/automations/:id/fire-once(new endpoint) — see Task 19. - Build.
- Commit.
Task 19: Manual-fire endpoint for Send Survey
Files:server/src/routes/whatsapp-automations.ts.
- Add
POST /admin/automations/:id/fire-oncebody{ appointmentId, patientId }that:- Loads the automation, asserts it’s the calling clinic’s.
- Calls
runOne(auto, cand)directly. - Returns the outcome.
- Permission gate:
whatsapp.manage_automations. - Commit.
Phase 6 — Tests, docs, release
Task 20: Unit tests
Files: Createserver/src/lib/lifecycle-events.test.ts, server/src/lib/automation-vars.test.ts.
-
lifecycle-events.test.ts: assert each of the 7 mapped transitions returns the right inline event; null mapping for unsupported transitions (e.g. completed → in_progress backtrack). -
automation-vars.test.ts: assertrecentAppointmentDate,googleReviewUrlresolve correctly from a stubAutomationCtx. - Run:
cd server && npx vitest run src/lib/lifecycle-events.test.ts src/lib/automation-vars.test.ts. - Commit.
Task 21: Integration test for inline dispatch
Files: Createserver/src/routes/appointments.lifecycle.test.ts.
- Test scenario: confirm a scheduled appointment via the route, assert a
whatsapp_automation_runsrow was written withstatus='sent'for theappointment_confirmedautomation. - Stub Meta API to avoid live sends.
- Commit.
Task 22: Egress regression snapshot
Files: none — this is a verification step.- Reset
pg_stat_statements:SELECT pg_stat_statements_reset();. - Run the appointment status flow against a staging or test clinic (confirm, cancel, complete + invoice) for ~10 cycles.
- Re-snapshot. Assert
SELECT * FROM patientsno longer appears in the top 10 by total_rows for the status path. - Document the numbers in
docs/qa/2026-05-18-egress-snapshot.md.
Task 23: API docs
Files:docs/api-reference.md.
- Document new endpoints:
POST /admin/automations/suggest-mappingPOST /admin/automations/:id/fire-once
- Document the new
whatsappblock on the appointment status PATCH response. - Document new trigger kinds (7) under
whatsapp_automations. - Commit.
Task 24: Release notes + version bump
Files:RELEASES.md, ui/src/components/ui/sign-in.tsx.
- Write entry at top of
RELEASES.md. What’s new, Fixed, Internal sections. - Bump
APP_VERSION. - Commit.
Task 25: Deploy
Files: none.- User confirmation for prod migration 0044.
- Apply 0044 against prod.
- Optionally apply 0045 (seed ssh & Associates’ default automations) — user confirmation again.
- Deploy worker:
cd server && npx wrangler deploy --env production. - Build + deploy UI; force-promote canonical.
- Verify chunk hash same on all 3 domains.
- Smoke: log in to ssh & Associates, open an appointment, click Confirm — observe WhatsApp toast + the actual template arriving on patient phone.
Self-Review
Spec coverage:- All 7 goals → tasks 1–25 cover them.
- Edge cases table → handled by
runOne(existing) + new reconcile (task 15) + heuristic mapping (task 16) + UI badge (task 17). - Affected files list → matches task file paths.
InlineEvent defined once in Task 7, imported by tasks 11, 12. TEMPLATE_DEFAULTS defined once in Task 16, consumed by Task 17.
Scope: One project, three braided streams; tasks are sequenced so each phase unblocks the next. Could be split into Phase 0 (egress) + Phase 1+ (automation) and merged separately if desired.
