Skip to main content

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/status query 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 projects firstName, lastName, email, phone, whatsappPhone, hasWhatsapp, phoneHash, whatsappPhoneHash, id. Same shape passed to decryptPatientPHI.
  • 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 to const 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:
// 5-minute key-value cache. Per worker isolate — Cloudflare routes
// requests to the same isolate when possible so we get reasonable
// hit rates even without KV. No need for cross-isolate consistency
// because the data being cached (plans / modules / permission templates)
// is only mutated by superadmin actions which are infrequent.
const TTL_MS = 5 * 60 * 1000;
const store = new Map<string, { v: any; exp: number }>();

export async function cached<T>(key: string, loader: () => Promise<T>): Promise<T> {
  const hit = store.get(key);
  if (hit && hit.exp > Date.now()) return hit.v as T;
  const v = await loader();
  store.set(key, { v, exp: Date.now() + TTL_MS });
  return v;
}

export function invalidateCache(prefix?: string) {
  if (!prefix) { store.clear(); return; }
  for (const k of store.keys()) if (k.startsWith(prefix)) store.delete(k);
}
  • In lib/permissions.ts, wrap the plan + permission-template fetches: await cached('plan:' + planId, () => db.select()...). Similarly in lib/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:
export type WhatsappTabKey =
  | 'inbox' | 'templates' | 'automations'
  | 'quickReplies' | 'analytics' | 'events';

export const WHATSAPP_TAB_KEYS: WhatsappTabKey[] = [
  'inbox', 'templates', 'automations', 'quickReplies', 'analytics', 'events',
];

export class WhatsappTabDisabledError extends Error {
  constructor(public readonly tab: WhatsappTabKey, public readonly clinicId: string) {
    super(`WhatsApp ${tab} tab is disabled for clinic ${clinicId}`);
    this.name = 'WhatsappTabDisabledError';
  }
}

/**
 * Resolves to `true` when:
 *   - the WhatsApp module is enabled for the clinic, AND
 *   - the requested tab is on in config.tabs (defaults to ON when the key
 *     is absent — preserves behavior for clinics configured before this
 *     feature shipped).
 */
export async function isWhatsappTabEnabled(
  clinicId: string,
  tab: WhatsappTabKey,
): Promise<boolean> {
  try {
    const db = getReadDb(getDatabaseUrl());
    const [row] = await db
      .select({ config: clinicModules.config })
      .from(clinicModules)
      .where(and(
        eq(clinicModules.clinicId, clinicId),
        eq(clinicModules.moduleKey, 'whatsapp'),
        eq(clinicModules.isEnabled, true),
      )).limit(1);
    if (!row?.config) return false;
    const parsed = JSON.parse(row.config) as { tabs?: Record<string, boolean> };
    if (!parsed.tabs) return true; // legacy config — default on
    return parsed.tabs[tab] !== false;
  } catch {
    return false;
  }
}

export async function requireWhatsappTab(
  clinicId: string,
  tab: WhatsappTabKey,
): Promise<void> {
  const ok = await isWhatsappTabEnabled(clinicId, tab);
  if (!ok) throw new WhatsappTabDisabledError(tab, clinicId);
}

export async function getEnabledWhatsappTabs(
  clinicId: string,
): Promise<Record<WhatsappTabKey, boolean>> {
  const result = {} as Record<WhatsappTabKey, boolean>;
  for (const tab of WHATSAPP_TAB_KEYS) {
    result[tab] = await isWhatsappTabEnabled(clinicId, tab);
  }
  return result;
}
  • Wrap isWhatsappTabEnabled in the 5-min cache helper from Task 4 with key wa-tab:${clinicId}:${tab}.
  • Create middleware factory:
// server/src/middleware/require-whatsapp-tab.ts
import { Hono } from 'hono';
import type { MiddlewareHandler } from 'hono';
import { requireWhatsappTab, type WhatsappTabKey } from '../lib/whatsapp';

export function requireTab(tab: WhatsappTabKey): MiddlewareHandler {
  return async (c, next) => {
    const user = c.get('user') as any;
    const clinicId = user?.clinicId ?? c.get('clinicContext')?.currentClinicId;
    if (!clinicId) return c.json({ error: 'no_clinic_context' }, 400);
    await requireWhatsappTab(clinicId, tab); // throws on disabled — caught by handleError
    return next();
  };
}
  • In server/src/lib/errors.ts, extend handleError:
if (error instanceof WhatsappTabDisabledError) {
  const payload = createErrorResponse(
    'WhatsappTabDisabledError',
    403,
    `The ${error.tab} tab is disabled for this clinic. Contact your account manager to enable it.`,
    { feature: `whatsapp.${error.tab}` },
    'FEATURE_DISABLED',
    getRequestId(c),
  );
  return c.json(payload, 403);
}
  • Typecheck. Commit.

Task 4c: GET /whatsapp-module/tabs endpoint

Files: Create server/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 with requireTab('templates')
  • server/src/routes/whatsapp-automations.tsrequireTab('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:
adminRoute.patch('/tenants/:clinicId/whatsapp-tabs', async (c) => {
  try {
    const user = c.get('user') as any;
    if (user.role !== 'superadmin') throw new AppError('Superadmin only', 403);
    const clinicId = c.req.param('clinicId');
    const body = await c.req.json() as { tab: WhatsappTabKey; enabled: boolean };
    if (!WHATSAPP_TAB_KEYS.includes(body.tab)) throw new AppError('invalid tab', 400);

    const db = getReadDb();
    const [row] = await db.select({ config: clinicModules.config })
      .from(clinicModules)
      .where(and(eq(clinicModules.clinicId, clinicId), eq(clinicModules.moduleKey, 'whatsapp')))
      .limit(1);
    if (!row) throw new AppError('WhatsApp module not configured for this clinic', 404);

    const parsed = JSON.parse(row.config) as Record<string, any>;
    parsed.tabs = parsed.tabs ?? Object.fromEntries(WHATSAPP_TAB_KEYS.map(k => [k, true]));
    parsed.tabs[body.tab] = body.enabled;

    await db.update(clinicModules)
      .set({ config: JSON.stringify(parsed) })
      .where(and(eq(clinicModules.clinicId, clinicId), eq(clinicModules.moduleKey, 'whatsapp')));

    invalidateCache(`wa-tab:${clinicId}:`);

    await recordAuditLog({
      clinicId,
      actorUserId: c.get('actorUserId') ?? user.id,
      onBehalfOfUserId: user.id,
      action: 'whatsapp_tab.toggle',
      entityType: 'clinic_modules',
      entityId: clinicId,
      changes: { tab: body.tab, enabled: body.enabled },
    });

    return c.json({ ok: true, tabs: parsed.tabs });
  } catch (err) { return handleError(err, c); }
});
  • Commit.

Task 4f: Inline + cron dispatcher checks the tab

Files: server/src/lib/automation-dispatcher.ts.
  • In dispatchInlineAutomations, before the SELECT, call isWhatsappTabEnabled(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: add AND 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:
// ui/src/hooks/useWhatsappTabs.ts
import { useQuery } from '@tanstack/react-query';
import { fetchWithAuth } from '@/lib/serverComm';

export type WhatsappTabKey = 'inbox' | 'templates' | 'automations' | 'quickReplies' | 'analytics' | 'events';

export function useWhatsappTabs() {
  return useQuery({
    queryKey: ['wa-tabs'],
    queryFn: async (): Promise<Record<WhatsappTabKey, boolean>> => {
      const res = await fetchWithAuth('/api/v1/protected/whatsapp-module/tabs');
      if (!res.ok) {
        // Module disabled or feature off — surface as "all off"
        return { inbox: false, templates: false, automations: false, quickReplies: false, analytics: false, events: false };
      }
      return res.json();
    },
    staleTime: 5 * 60_000,
  });
}
  • In each WhatsApp module page, call useWhatsappTabs() and conditionally render each <TabsTrigger> / <TabsContent>.
  • If the URL has ?tab=automations but tabs.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: Create server/drizzle/0044_automation_lifecycle_triggers.sql.
  • Write SQL:
-- 0044_automation_lifecycle_triggers.sql
-- Postgres requires ALTER TYPE … ADD VALUE outside a multi-statement transaction.
-- Apply with: psql ... -v ON_ERROR_STOP=1 -f 0044_automation_lifecycle_triggers.sql
-- AND ensure no \begin in this file.

ALTER TYPE app.automation_trigger_kind ADD VALUE IF NOT EXISTS 'appointment_requested';
ALTER TYPE app.automation_trigger_kind ADD VALUE IF NOT EXISTS 'appointment_scheduled';
ALTER TYPE app.automation_trigger_kind ADD VALUE IF NOT EXISTS 'appointment_confirmed';
ALTER TYPE app.automation_trigger_kind ADD VALUE IF NOT EXISTS 'appointment_cancelled';
ALTER TYPE app.automation_trigger_kind ADD VALUE IF NOT EXISTS 'appointment_rescheduled';
ALTER TYPE app.automation_trigger_kind ADD VALUE IF NOT EXISTS 'appointment_no_show';
ALTER TYPE app.automation_trigger_kind ADD VALUE IF NOT EXISTS 'last_visit_completed';

ALTER TABLE app.whatsapp_automations
  ADD COLUMN IF NOT EXISTS reconcile_notes TEXT,
  ADD COLUMN IF NOT EXISTS reconciled_at TIMESTAMP;

CREATE INDEX IF NOT EXISTS whatsapp_automations_clinic_trigger_enabled_idx
  ON app.whatsapp_automations (clinic_id, trigger_kind, is_enabled);
  • 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:
export const automationTriggerKindEnum = pgEnum('automation_trigger_kind', [
  'appointment_upcoming',
  'appointment_missed',
  'last_visit_recall',
  'invoice_overdue',
  'appointment_requested',
  'appointment_scheduled',
  'appointment_confirmed',
  'appointment_cancelled',
  'appointment_rescheduled',
  'appointment_no_show',
  'last_visit_completed',
]);
  • Add the two new columns to the table definition:
reconcileNotes: text('reconcile_notes'),
reconciledAt: timestamp('reconciled_at'),
  • 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:
export type InlineEvent =
  | 'appointment_requested'
  | 'appointment_scheduled'
  | 'appointment_confirmed'
  | 'appointment_cancelled'
  | 'appointment_rescheduled'
  | 'appointment_no_show';

export interface InlineDispatchResult { sent: number; skipped: number; failed: number }

export async function dispatchInlineAutomations(opts: {
  clinicId: string;
  event: InlineEvent;
  appointmentId: string;
  patientId: string;
}): Promise<InlineDispatchResult> {
  const db = getReadDb();
  const autos = await db
    .select()
    .from(whatsappAutomations)
    .where(and(
      eq(whatsappAutomations.clinicId, opts.clinicId),
      eq(whatsappAutomations.triggerKind, opts.event as any),
      eq(whatsappAutomations.isEnabled, true),
    ));

  const result: InlineDispatchResult = { sent: 0, skipped: 0, failed: 0 };
  for (const auto of autos) {
    const cand: Candidate = {
      subjectKind: 'appointment',
      subjectId: opts.appointmentId,
      patientId: opts.patientId,
      appointmentId: opts.appointmentId,
    };
    const outcome = await runOne(auto, cand);
    if (outcome === 'sent') result.sent += 1;
    else if (outcome === 'skipped') result.skipped += 1;
    else result.failed += 1;
  }
  return result;
}
  • Add 'last_visit_completed' case to the existing findCandidates switch:
case 'last_visit_completed': {
  const rows = await db.execute(sql`
    WITH last_completed AS (
      SELECT a.id AS appointment_id, a.patient_id,
             a.appointment_date::timestamp + a.appointment_time::time AS completed_at
      FROM app.appointments a
      WHERE a.clinic_id = ${auto.clinicId}
        AND a.status = 'completed'
    )
    SELECT appointment_id, patient_id
    FROM last_completed
    WHERE completed_at BETWEEN now() - (${offsetMin + TRIGGER_WING_MIN} || ' minutes')::interval
                           AND now() - (${offsetMin - TRIGGER_WING_MIN} || ' minutes')::interval
  `);
  const list = ((rows as any).rows ?? rows) as Array<{ appointment_id: string; patient_id: string }>;
  return list.map((r) => ({
    subjectKind: 'appointment' as const,
    subjectId: r.appointment_id,
    patientId: r.patient_id,
    appointmentId: r.appointment_id,
  }));
}
  • Typecheck.
  • Commit.

Task 8: Extend AutomationCtx and var resolver

Files: server/src/lib/automation-vars.ts, server/src/lib/automation-dispatcher.ts (buildContext).
  • Add recentCompletedAppointment and googleReviewUrlSuffix to AutomationCtx:
recentCompletedAppointment: {
  appointmentDate?: string | null;
  appointmentTime?: string | null;
  appointmentType?: string | null;
  doctorName?: string | null;
} | null;
googleReviewUrlSuffix: string | null;
  • In resolveAutomationVar, add cases:
case 'recentAppointmentDate':
  return fmtDate(ctx.recentCompletedAppointment?.appointmentDate);
case 'recentAppointmentTime':
  return fmtTime(ctx.recentCompletedAppointment?.appointmentTime);
case 'googleReviewUrl':
  return ctx.googleReviewUrlSuffix;  // suffix only — Meta button URL placeholder takes the trailing path
  • In buildContext (dispatcher), additionally fetch the most-recent completed appointment for the patient and parse clinic.notificationPreferences.googleReviewUrl to 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:
const HIDDEN_TEMPLATES = new Set<string>(['hello_world']);
  • In the response builder, filter:
const visible = (templates ?? []).filter((t: any) => !HIDDEN_TEMPLATES.has(t.name));
cache.set(clinicId, { ts: Date.now(), data: visible });
return c.json({ templates: visible });
  • 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:
import type { InlineEvent } from './automation-dispatcher';

export function inlineEventFor(
  prevStatus: string | null,
  newStatus: string,
): InlineEvent | null {
  if (prevStatus == null) {
    if (newStatus === 'requested') return 'appointment_requested';
    if (newStatus === 'scheduled') return 'appointment_scheduled';
    if (newStatus === 'confirmed') return 'appointment_confirmed';
    return null;
  }
  if (newStatus === prevStatus) return null;
  if (newStatus === 'confirmed') return 'appointment_confirmed';
  if (newStatus === 'cancelled') return 'appointment_cancelled';
  if (newStatus === 'no_show') return 'appointment_no_show';
  return null;
}
  • 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:
const tabOn = await isWhatsappTabEnabled(opts.clinicId, 'automations');
if (!tabOn) return { sent: 0, skipped: 0, failed: 0, reason: 'automations_tab_disabled' } as any;
  • 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 queueAppointmentStatusNotifications is queued, add:
import { inlineEventFor } from '../lib/lifecycle-events';
import { dispatchInlineAutomations } from '../lib/automation-dispatcher';

// ... inside handler, after `if (!updated) throw ...`
const event = inlineEventFor(currentStatus, newStatus);
let whatsappOutcome: { queued: boolean; templateName?: string; reason?: string } | null = null;
if (event) {
  // Run in waitUntil so the response isn't blocked, but capture the count
  // synchronously by doing the find-automations query inline; the actual
  // Meta send happens in the background.
  const db2 = getReadDb();
  const [auto] = await db2
    .select({ name: whatsappAutomations.name, templateName: whatsappAutomations.templateName })
    .from(whatsappAutomations)
    .where(and(
      eq(whatsappAutomations.clinicId, currentClinicId),
      eq(whatsappAutomations.triggerKind, event as any),
      eq(whatsappAutomations.isEnabled, true),
    ))
    .limit(1);
  if (auto) {
    whatsappOutcome = { queued: true, templateName: auto.templateName };
    c.executionCtx?.waitUntil(
      dispatchInlineAutomations({
        clinicId: currentClinicId,
        event,
        appointmentId: updated.id,
        patientId: updated.patientId!,
      }).catch(err => console.error('[lifecycle] inline dispatch failed:', err)),
    );
  } else {
    whatsappOutcome = { queued: false, reason: 'no_automation_bound' };
  }
}
  • Include whatsapp: whatsappOutcome in 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 whatsapp block 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, fire appointment_rescheduled via dispatchInlineAutomations.
  • Include whatsapp in 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 + templateLanguage isn’t in the list, disable it and set reconcile_notes.
  • Wire in scheduled.ts to run at 02:00 UTC only:
if (utcHour === 2) {
  await handleAutomationReconcile(env, ctx);
}
  • 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:
adminRoute.post('/automations/suggest-mapping', async (c) => {
  const { templateName, templateLanguage } = await c.req.json();
  const known = TEMPLATE_DEFAULTS[templateName];
  if (known) return c.json({ paramMapping: known.paramMapping, confidence: 'known' });
  // Heuristic — fetch template body, regex placeholders, score by surrounding text.
  // ... (heuristic implementation)
});
  • Commit.

Task 17: AutomationsTab UI — trigger picker + Suggest mapping button + reconcile badge

Files: ui/src/components/whatsapp-v2/tabs/AutomationsTab.tsx.
  • Extend the TRIGGERS array 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 mapping button next to the param-mapping editor. Wires to POST /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_notes is 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:
    • visible from a canTransitionToFromStatusMachine(currentStatus, nextStatus) helper (uses the same validStatusTransitions map as the server).
    • enabled from a time-gate check (now ≥ apptTime − 30 min etc.) computed in PKT via the existing pkt.ts helpers.
    • tooltip from the table when disabled.
  • Each click hits PATCH /:id/status and surfaces the whatsapp block from the response in a toast.
  • Reschedule button opens the existing SlotPicker dialog. On save, calls PUT /:id, surfaces the whatsapp block.
  • 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-once body { 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: Create server/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: assert recentAppointmentDate, googleReviewUrl resolve correctly from a stub AutomationCtx.
  • 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: Create server/src/routes/appointments.lifecycle.test.ts.
  • Test scenario: confirm a scheduled appointment via the route, assert a whatsapp_automation_runs row was written with status='sent' for the appointment_confirmed automation.
  • 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 patients no 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-mapping
    • POST /admin/automations/:id/fire-once
  • Document the new whatsapp block 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.
Placeholder scan: None — every code-bearing step has the actual code inline or a precise pointer. Type consistency: 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.