Skip to main content

WhatsApp Module v1 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Ship the WhatsApp module v1 specified in docs/superpowers/specs/2026-05-13-whatsapp-module-design.md — first-class /whatsapp page with 5 tabs, window-aware composer, full event timeline, and live Meta cost transparency. Architecture: Extend existing BYOK plumbing (already shipped). Add two new tables (whatsapp_events, whatsapp_quick_replies), one pricing module, one routes file, one frontend module shell with five tabs. Webhook + outbound paths write event rows; UI reads them. Tech Stack: Hono + Drizzle (server), Cloudflare Workers + KV (template cache), React + TanStack Query + shadcn/ui + recharts + lucide-react + date-fns (frontend).

Parallelism plan

Wave 1 (parallel) :  Task 1 · Task 2
Wave 2 (sequential): Task 3 · Task 4 · Task 5
Wave 3 (parallel) :  Task 6 · Task 7
Wave 4 (sequential): Task 8 · Task 9
Wave 5 (parallel) :  Task 10 · Task 11 · Task 12 · Task 13
Wave 6 (sequential): Task 14 · Task 15
Tasks within a wave have no dependencies on each other and can be dispatched to separate subagents in one batch.

File map (locks decomposition)

ActionPathTask
Createserver/drizzle/0034_whatsapp_module.sql1
Createserver/src/schema/whatsapp_events.ts1
Createserver/src/schema/whatsapp_quick_replies.ts1
Modifyserver/src/schema/index.ts1
Createserver/src/lib/whatsapp-pricing.ts2
Modifyserver/src/routes/whatsapp-webhook.ts3
Modifyserver/src/lib/whatsapp.ts4
Modifyserver/src/scheduled.ts4
Createserver/src/routes/whatsapp.ts5
Modifyserver/src/api.ts5
Modifyui/src/lib/permissionTree.ts6
Modifyui/src/components/layout/AppLayout.tsx6
Modifyui/src/lib/serverComm.ts7
Createui/src/components/whatsapp/WhatsAppModule.tsx8
Createui/src/components/whatsapp/composer/WindowTimer.tsx9
Createui/src/components/whatsapp/composer/CostPill.tsx9
Createui/src/components/whatsapp/composer/Composer.tsx9
Createui/src/components/whatsapp/PatientPanel.tsx9
Createui/src/components/whatsapp/ConversationsTab.tsx10
Createui/src/components/whatsapp/EventsTab.tsx11
Createui/src/components/whatsapp/TemplatesTab.tsx12
Createui/src/components/whatsapp/QuickRepliesTab.tsx13
Createui/src/components/whatsapp/AnalyticsTab.tsx13
Modifyui/src/components/superadmin/SuperadminModulesPanel.tsx14
ModifyRELEASES.md15

Task 1: Database migration + Drizzle schemas

Files:
  • Create: server/drizzle/0034_whatsapp_module.sql
  • Create: server/src/schema/whatsapp_events.ts
  • Create: server/src/schema/whatsapp_quick_replies.ts
  • Modify: server/src/schema/index.ts
  • Step 1: Write the SQL migration
server/drizzle/0034_whatsapp_module.sql:
CREATE TABLE IF NOT EXISTS whatsapp_events (
  id            TEXT PRIMARY KEY,
  clinic_id     TEXT NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
  patient_id    TEXT REFERENCES patients(id) ON DELETE SET NULL,
  message_id    TEXT REFERENCES messages(id) ON DELETE SET NULL,
  type          TEXT NOT NULL,
  category      TEXT,
  template_name TEXT,
  cost_pkr      NUMERIC(10,4) NOT NULL DEFAULT 0,
  metadata      JSONB NOT NULL DEFAULT '{}'::jsonb,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_wae_clinic_created  ON whatsapp_events (clinic_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wae_clinic_patient  ON whatsapp_events (clinic_id, patient_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wae_clinic_type     ON whatsapp_events (clinic_id, type, created_at DESC);

CREATE TABLE IF NOT EXISTS whatsapp_quick_replies (
  id          TEXT PRIMARY KEY,
  clinic_id   TEXT NOT NULL REFERENCES clinics(id) ON DELETE CASCADE,
  shortcut    TEXT NOT NULL,
  body        TEXT NOT NULL,
  position    INTEGER NOT NULL DEFAULT 0,
  created_by  TEXT REFERENCES users(id) ON DELETE SET NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE (clinic_id, shortcut)
);

CREATE INDEX IF NOT EXISTS idx_wqr_clinic_position ON whatsapp_quick_replies (clinic_id, position);
  • Step 2: Write the Drizzle schemas
server/src/schema/whatsapp_events.ts:
import { pgTable, text, numeric, jsonb, timestamp, index } from 'drizzle-orm/pg-core';
import { clinics } from './clinics';
import { patients } from './patients';
import { messages } from './messages';

export const whatsappEvents = pgTable('whatsapp_events', {
  id:           text('id').primaryKey(),
  clinicId:     text('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
  patientId:    text('patient_id').references(() => patients.id, { onDelete: 'set null' }),
  messageId:    text('message_id').references(() => messages.id, { onDelete: 'set null' }),
  type:         text('type').notNull(),  // inbound_message | outbound_message | template_sent | delivered | read | failed | window_opened | window_closed | free_entry
  category:     text('category'),         // marketing | utility | authentication | auth_international | service | null
  templateName: text('template_name'),
  costPkr:      numeric('cost_pkr', { precision: 10, scale: 4 }).notNull().default('0'),
  metadata:     jsonb('metadata').notNull().default({}),
  createdAt:    timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  byClinicCreated: index('idx_wae_clinic_created').on(t.clinicId, t.createdAt),
  byClinicPatient: index('idx_wae_clinic_patient').on(t.clinicId, t.patientId, t.createdAt),
  byClinicType:    index('idx_wae_clinic_type').on(t.clinicId, t.type, t.createdAt),
}));

export type WhatsappEvent = typeof whatsappEvents.$inferSelect;
export type NewWhatsappEvent = typeof whatsappEvents.$inferInsert;
server/src/schema/whatsapp_quick_replies.ts:
import { pgTable, text, integer, timestamp, unique, index } from 'drizzle-orm/pg-core';
import { clinics } from './clinics';
import { users } from './users';

export const whatsappQuickReplies = pgTable('whatsapp_quick_replies', {
  id:        text('id').primaryKey(),
  clinicId:  text('clinic_id').notNull().references(() => clinics.id, { onDelete: 'cascade' }),
  shortcut:  text('shortcut').notNull(),
  body:      text('body').notNull(),
  position:  integer('position').notNull().default(0),
  createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  uniqShortcut: unique('uq_wqr_clinic_shortcut').on(t.clinicId, t.shortcut),
  byClinicPos:  index('idx_wqr_clinic_position').on(t.clinicId, t.position),
}));

export type WhatsappQuickReply = typeof whatsappQuickReplies.$inferSelect;
export type NewWhatsappQuickReply = typeof whatsappQuickReplies.$inferInsert;
  • Step 3: Wire schemas into the schema index
Modify server/src/schema/index.ts — add at the bottom of the existing exports:
export * from './whatsapp_events';
export * from './whatsapp_quick_replies';
  • Step 4: Apply migration (USER CONFIRMS BEFORE EXECUTION — per feedback_no_live_tenant_execution)
Show this command, do NOT run it without explicit confirmation:
cd server && npx wrangler d1 --remote-no-thx execute || \
  npx drizzle-kit push --config drizzle.config.ts
The repo’s actual migration command lives in server/package.json under migrate or similar. Inspect it first; run the project’s convention. Wait for user confirmation before executing against production.
  • Step 5: Typecheck
cd server && npx tsc --noEmit 2>&1 | grep -E "whatsapp_(events|quick_replies)"
Expected: no output (no new errors in our new files).
  • Step 6: Commit
git add server/drizzle/0034_whatsapp_module.sql server/src/schema/whatsapp_events.ts server/src/schema/whatsapp_quick_replies.ts server/src/schema/index.ts
git commit -m "feat(whatsapp): migration 0034 — events + quick_replies tables"

Task 2: Pricing module + window helpers

Files:
  • Create: server/src/lib/whatsapp-pricing.ts
  • Step 1: Write the rate card + pricing function
server/src/lib/whatsapp-pricing.ts:
// Last verified against developers.facebook.com/.../pricing on 2026-05-13.
// PKR amounts derived from Meta's USD rates × 285 PKR/USD baseline.
// Pakistan rates increased on 2026-04-01 (utility/auth $0.0054 → $0.01).
//
// Superadmin can override per-clinic via clinic_modules.config.rateCardOverride.

export const RATE_CARD_LAST_UPDATED = '2026-05-13';

export type WaCategory = 'marketing' | 'utility' | 'authentication' | 'auth_international' | 'service';
export type WaCountry = 'PK' | 'IN' | 'AE' | 'SA' | 'BD' | 'US' | 'GB' | 'DEFAULT';

const RATE_CARD_PKR: Record<WaCountry, Record<WaCategory, number>> = {
  PK:      { marketing: 13.50, utility: 2.85,  authentication: 2.85,  auth_international: 21.0, service: 0 },
  IN:      { marketing:  3.80, utility: 0.45,  authentication: 0.45,  auth_international: 14.0, service: 0 },
  AE:      { marketing: 10.20, utility: 2.30,  authentication: 2.30,  auth_international: 18.0, service: 0 },
  SA:      { marketing:  9.40, utility: 2.10,  authentication: 2.10,  auth_international: 17.0, service: 0 },
  BD:      { marketing:  3.50, utility: 0.40,  authentication: 0.40,  auth_international: 13.0, service: 0 },
  US:      { marketing:  7.40, utility: 1.80,  authentication: 1.80,  auth_international:  9.0, service: 0 },
  GB:      { marketing:  6.20, utility: 1.50,  authentication: 1.50,  auth_international:  7.5, service: 0 },
  DEFAULT: { marketing:  7.00, utility: 1.50,  authentication: 1.50,  auth_international: 10.0, service: 0 },
};

export interface PriceArgs {
  category: WaCategory;
  country: WaCountry;
  insideWindow: boolean;
  override?: Partial<Record<WaCategory, number>>;
}

/**
 * Returns the PKR cost the clinic owes Meta for a single delivered message.
 * Inside-window utility templates and all service messages are free since
 * Meta's 2025-07-01 update.
 */
export function priceForMessage({ category, country, insideWindow, override }: PriceArgs): number {
  if (category === 'service') return 0;
  if (category === 'utility' && insideWindow) return 0;
  if (override?.[category] !== undefined) return override[category]!;
  return RATE_CARD_PKR[country]?.[category] ?? RATE_CARD_PKR.DEFAULT[category];
}

/**
 * 24h customer service window state derived from the most recent inbound
 * message timestamp. `free-entry` is set by the caller if the conversation
 * began from a WhatsApp ad / FB CTA (webhook `referral` field present).
 */
export type WindowStatus = 'green' | 'amber' | 'red' | 'free-entry';

export interface WindowState {
  status: WindowStatus;
  opensAt: string | null;     // ISO
  closesAt: string | null;    // ISO
  secondsLeft: number;        // 0 if closed
}

export function computeWindowState(
  lastInboundAt: Date | null,
  now: Date = new Date(),
  freeEntry: boolean = false,
): WindowState {
  if (!lastInboundAt) {
    return { status: 'red', opensAt: null, closesAt: null, secondsLeft: 0 };
  }
  const windowMs = freeEntry ? 72 * 60 * 60 * 1000 : 24 * 60 * 60 * 1000;
  const closesAt = new Date(lastInboundAt.getTime() + windowMs);
  const secondsLeft = Math.max(0, Math.floor((closesAt.getTime() - now.getTime()) / 1000));
  if (secondsLeft <= 0) return { status: 'red', opensAt: lastInboundAt.toISOString(), closesAt: closesAt.toISOString(), secondsLeft: 0 };
  if (freeEntry) return { status: 'free-entry', opensAt: lastInboundAt.toISOString(), closesAt: closesAt.toISOString(), secondsLeft };
  if (secondsLeft <= 2 * 60 * 60) return { status: 'amber', opensAt: lastInboundAt.toISOString(), closesAt: closesAt.toISOString(), secondsLeft };
  return { status: 'green', opensAt: lastInboundAt.toISOString(), closesAt: closesAt.toISOString(), secondsLeft };
}
  • Step 2: Smoke-test the pure functions
Add a one-off check (not a permanent test) at the bottom of the file, run via npx tsx:
cd server && npx tsx -e "
import { priceForMessage, computeWindowState } from './src/lib/whatsapp-pricing';
console.assert(priceForMessage({ category: 'service', country: 'PK', insideWindow: false }) === 0, 'service=0');
console.assert(priceForMessage({ category: 'utility', country: 'PK', insideWindow: true }) === 0, 'utility-in-window=0');
console.assert(priceForMessage({ category: 'utility', country: 'PK', insideWindow: false }) === 2.85, 'utility-out=2.85');
console.assert(priceForMessage({ category: 'marketing', country: 'PK', insideWindow: true }) === 13.50, 'marketing=13.50');
const now = new Date('2026-05-13T12:00:00Z');
const s1 = computeWindowState(new Date('2026-05-13T00:00:00Z'), now);
console.assert(s1.status === 'green' && s1.secondsLeft === 12*3600, 'green @12h');
const s2 = computeWindowState(new Date('2026-05-12T23:30:00Z'), now);
console.assert(s2.status === 'amber', 'amber when <2h left');
const s3 = computeWindowState(new Date('2026-05-11T00:00:00Z'), now);
console.assert(s3.status === 'red', 'red after 24h');
console.assert(computeWindowState(null, now).status === 'red', 'red when no inbound');
const s4 = computeWindowState(new Date('2026-05-13T00:00:00Z'), now, true);
console.assert(s4.status === 'free-entry', 'free-entry on referral');
console.log('OK');
"
Expected: prints OK. No assertion errors.
  • Step 3: Commit
git add server/src/lib/whatsapp-pricing.ts
git commit -m "feat(whatsapp): rate card + 24h window state computation"

Task 3: Webhook side-effects — write events on inbound + delivery receipts

Files:
  • Modify: server/src/routes/whatsapp-webhook.ts
  • Step 1: Add event-writer helper at the top of the file (after imports)
After the existing imports in whatsapp-webhook.ts, add:
import { whatsappEvents } from '../schema/whatsapp_events';
import { priceForMessage, computeWindowState, type WaCategory } from '../lib/whatsapp-pricing';
import { getDatabase } from '../lib/db';

async function writeWhatsappEvent(args: {
  clinicId: string;
  patientId: string | null;
  messageId: string | null;
  type: 'inbound_message' | 'outbound_message' | 'template_sent' | 'delivered' | 'read' | 'failed' | 'window_opened' | 'window_closed' | 'free_entry';
  category?: WaCategory;
  templateName?: string;
  costPkr?: number;
  metadata?: Record<string, unknown>;
}): Promise<void> {
  const db = await getDatabase();
  await db.insert(whatsappEvents).values({
    id: crypto.randomUUID(),
    clinicId: args.clinicId,
    patientId: args.patientId,
    messageId: args.messageId,
    type: args.type,
    category: args.category ?? null,
    templateName: args.templateName ?? null,
    costPkr: String(args.costPkr ?? 0),
    metadata: args.metadata ?? {},
  });
}
  • Step 2: Find the inbound-message handler and add event writes
In whatsapp-webhook.ts, find where an inbound message is INSERTed into messages (search for type: 'whatsapp', near line ~363 in the per-clinic POST handler). Immediately after the insert succeeds and you have patientId and the new messageId, add:
// Detect if this opens a new 24h window: no inbound in the prior 24h?
const priorInboundCutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
const [priorInbound] = await db
  .select({ id: messages.id })
  .from(messages)
  .where(and(
    eq(messages.clinicId, clinicId),
    eq(messages.patientId, patientId),
    eq(messages.direction, 'inbound'),
    gte(messages.createdAt, priorInboundCutoff),
  ))
  .orderBy(desc(messages.createdAt))
  .limit(2);  // 2 because the row we just inserted is one of them

const isNewWindow = priorInbound.length <= 1;

await writeWhatsappEvent({
  clinicId,
  patientId,
  messageId: newMessageId,
  type: 'inbound_message',
});

if (isNewWindow) {
  await writeWhatsappEvent({
    clinicId,
    patientId,
    messageId: newMessageId,
    type: 'window_opened',
  });
}

// Free-entry detection — Meta passes referral in the webhook payload for
// chats started via WhatsApp ads / FB CTA buttons.
const referral = msg.referral;  // already in scope from webhook payload destructure
if (referral) {
  await writeWhatsappEvent({
    clinicId,
    patientId,
    messageId: newMessageId,
    type: 'free_entry',
    metadata: { source: referral.source_type, headline: referral.headline, body: referral.body },
  });
}
You may need to import gte and desc from drizzle-orm if not already imported.
  • Step 3: Find the status-update handler (delivered / read / failed) and add event writes
In the same file, find where the webhook processes message status updates (look for status === 'delivered' or the status switch). Immediately after recording the status change, add:
// Resolve the original outbound message to figure out cost.
const [origMsg] = await db
  .select({
    id: messages.id,
    patientId: messages.patientId,
    templateName: messages.templateName,
    category: messages.category,  // if column exists; else null
  })
  .from(messages)
  .where(and(eq(messages.clinicId, clinicId), eq(messages.providerMessageId, status.id)))
  .limit(1);

if (status.status === 'delivered' && origMsg) {
  // Compute cost from the original send — was it inside-window at send time?
  // For v1 we recompute: if there was an inbound from the same patient in the
  // 24h before the send timestamp (now − ~status delay), we treat as in-window.
  const sentInWindowCutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
  const [recentInbound] = await db
    .select({ id: messages.id })
    .from(messages)
    .where(and(
      eq(messages.clinicId, clinicId),
      eq(messages.patientId, origMsg.patientId),
      eq(messages.direction, 'inbound'),
      gte(messages.createdAt, sentInWindowCutoff),
    ))
    .limit(1);
  const insideWindow = !!recentInbound;

  const category = (origMsg.category as WaCategory | null) ?? (origMsg.templateName ? 'utility' : 'service');
  const cost = priceForMessage({ category, country: 'PK', insideWindow });

  await writeWhatsappEvent({
    clinicId,
    patientId: origMsg.patientId,
    messageId: origMsg.id,
    type: 'delivered',
    category,
    templateName: origMsg.templateName ?? undefined,
    costPkr: cost,
  });
} else if (status.status === 'read' && origMsg) {
  await writeWhatsappEvent({
    clinicId,
    patientId: origMsg.patientId,
    messageId: origMsg.id,
    type: 'read',
  });
} else if (status.status === 'failed' && origMsg) {
  await writeWhatsappEvent({
    clinicId,
    patientId: origMsg.patientId,
    messageId: origMsg.id,
    type: 'failed',
    metadata: { errors: status.errors ?? [] },
  });
}
Adjust column names (providerMessageId, category) to whatever exists in the messages schema. If category doesn’t exist on messages, fall back to inferring from templateName (template name appoitmentconf etc → utility, no template → service).
  • Step 4: Typecheck
cd server && npx tsc --noEmit 2>&1 | grep "whatsapp-webhook"
Expected: no new errors.
  • Step 5: Commit
git add server/src/routes/whatsapp-webhook.ts
git commit -m "feat(whatsapp): write events on inbound, delivery, read, failed"

Task 4: Outbound side-effects + window_closed cron

Files:
  • Modify: server/src/lib/whatsapp.ts
  • Modify: server/src/scheduled.ts
  • Create: server/src/scheduled/whatsapp-window-closer.ts
  • Step 1: Add event writes to outbound senders
In server/src/lib/whatsapp.ts, find sendTextMessage() and sendTemplateMessage(). Add the same writeWhatsappEvent helper (copy from Task 3 Step 1 — both files need a local copy because tree-shaking and circular imports). At the end of each send function, after the message INSERT succeeds: For sendTextMessage — write outbound_message event:
await writeWhatsappEvent({
  clinicId: options.clinicId,
  patientId: options.patientId ?? null,
  messageId: newMessageId,
  type: 'outbound_message',
  category: 'service',  // free-form text is always 'service' category
});
For sendTemplateMessage — write template_sent event:
const category = inferTemplateCategory(options.templateName);
await writeWhatsappEvent({
  clinicId: options.clinicId,
  patientId: options.patientId ?? null,
  messageId: newMessageId,
  type: 'template_sent',
  category,
  templateName: options.templateName,
  // Cost not booked here — booked on delivery webhook.
});
Add the inference helper just above:
function inferTemplateCategory(templateName: string): WaCategory {
  // Map known appointment templates → utility. Default unknown → marketing
  // (conservative — overcharges in the cost preview rather than undercharges).
  const utilityNames = new Set([
    'appoitmentconf',
    'appointmentremd',
    'appointment_cancellation_1',
    'appointment_reschedule',
    'missed_appointment',
  ]);
  if (utilityNames.has(templateName)) return 'utility';
  return 'marketing';
}
Import WaCategory from ./whatsapp-pricing.
  • Step 2: Create the window-closer scheduled task
server/src/scheduled/whatsapp-window-closer.ts:
import { eq, and, sql, gte, lt, isNull, notExists, exists } from 'drizzle-orm';
import { getReadDb, getDatabase } from '../lib/db';
import { getDatabaseUrl, runWithEnv } from '../lib/env';
import { messages } from '../schema/messages';
import { whatsappEvents } from '../schema/whatsapp_events';
import { clinicModules } from '../schema/clinic_modules';

/**
 * Writes a `window_closed` event for every (clinic, patient) thread whose
 * last inbound message just crossed the 24h mark, AND for which we haven't
 * already written a `window_closed` event after that inbound.
 *
 * Runs on the existing 5x/day cron. Idempotent — running it twice in the
 * same day yields zero extra rows because the NOT EXISTS guard prevents
 * duplicates.
 */
export async function handleWhatsappWindowCloser(env: any): Promise<void> {
  return runWithEnv(env, async () => {
    const db = await getDatabase();

    // For each enabled WhatsApp clinic, find inbound messages where:
    //   created_at < now - 24h  AND
    //   created_at >= now - 25h  (only catch the boundary crossing — older
    //                              ones were caught on previous ticks)
    //   no later inbound from same patient
    //   no window_closed event referencing this message
    const result = await db.execute(sql`
      INSERT INTO whatsapp_events (id, clinic_id, patient_id, message_id, type, created_at)
      SELECT
        gen_random_uuid()::text,
        m.clinic_id,
        m.patient_id,
        m.id,
        'window_closed',
        NOW()
      FROM messages m
      JOIN clinic_modules cm
        ON cm.clinic_id = m.clinic_id
       AND cm.module_key = 'whatsapp'
       AND cm.is_enabled = true
      WHERE m.direction = 'inbound'
        AND m.type = 'whatsapp'
        AND m.created_at <  NOW() - INTERVAL '24 hours'
        AND m.created_at >= NOW() - INTERVAL '25 hours'
        AND NOT EXISTS (
          SELECT 1 FROM messages m2
          WHERE m2.clinic_id = m.clinic_id
            AND m2.patient_id = m.patient_id
            AND m2.direction = 'inbound'
            AND m2.type = 'whatsapp'
            AND m2.created_at > m.created_at
        )
        AND NOT EXISTS (
          SELECT 1 FROM whatsapp_events e
          WHERE e.message_id = m.id
            AND e.type = 'window_closed'
        )
    `);
    console.log('[whatsapp] window_closed events written:', result.rowCount ?? 0);
  });
}
  • Step 3: Wire the cron into scheduled.ts
In server/src/scheduled.ts, add the import at the top with the other scheduled imports:
import { handleWhatsappWindowCloser } from './scheduled/whatsapp-window-closer';
And in handleScheduled(), after the existing handleInventoryExpiryAlerts(env) call, add:
// 9. WHATSAPP WINDOW CLOSER — writes `window_closed` events for threads
//    whose 24h window just expired. Runs on every cron tick (cheap query).
await handleWhatsappWindowCloser(env);
  • Step 4: Typecheck
cd server && npx tsc --noEmit 2>&1 | grep -E "whatsapp\.ts|scheduled\.ts|window-closer"
Expected: no new errors.
  • Step 5: Commit
git add server/src/lib/whatsapp.ts server/src/scheduled.ts server/src/scheduled/whatsapp-window-closer.ts
git commit -m "feat(whatsapp): outbound + window_closed event writes"

Task 5: Module API routes

Files:
  • Create: server/src/routes/whatsapp.ts
  • Modify: server/src/api.ts
  • Step 1: Create the route file
server/src/routes/whatsapp.ts — full file. This is the longest task; the code is below in three blocks for readability. Block A — imports and setup:
import { Hono } from 'hono';
import { z } from 'zod';
import { and, asc, desc, eq, gte, lte, inArray, sql } from 'drizzle-orm';
import { getReadDb, getDatabase } from '../lib/db';
import { messages, patients, clinicModules, appointments } from '../schema';
import { whatsappEvents } from '../schema/whatsapp_events';
import { whatsappQuickReplies } from '../schema/whatsapp_quick_replies';
import { requirePermission } from '../middleware/permissions';
import { handleError, AppError } from '../lib/errors';
import { decryptPatientPHI } from '../lib/encryption';
import {
  computeWindowState,
  priceForMessage,
  RATE_CARD_LAST_UPDATED,
  type WaCategory,
} from '../lib/whatsapp-pricing';
import {
  sendTextMessage,
  sendTemplateMessage,
  getWhatsAppConfigForClinic,
} from '../lib/whatsapp';

const whatsappRoute = new Hono();
whatsappRoute.use('*', requirePermission('comms.whatsapp.module.access'));

// Helper — apply doctor scope: returns patient_id list the user is allowed to see.
async function scopedPatientIds(c: any): Promise<string[] | 'all'> {
  const user = c.get('user');
  const clinicId = c.get('clinicContext')?.currentClinicId ?? user.clinicId;
  if (user.role !== 'doctor') return 'all';
  const db = getReadDb();
  const rows = await db
    .selectDistinct({ patientId: appointments.patientId })
    .from(appointments)
    .where(and(eq(appointments.clinicId, clinicId), eq(appointments.doctorId, user.id)));
  return rows.map(r => r.patientId).filter(Boolean) as string[];
}

function clinicId(c: any): string {
  return c.get('clinicContext')?.currentClinicId ?? c.get('user').clinicId;
}
Block B — read endpoints (conversations / window / events / templates / quick replies / analytics):
// GET /conversations — threads list with last-message preview + window state
whatsappRoute.get('/conversations', async (c) => {
  try {
    const cid = clinicId(c);
    const scoped = await scopedPatientIds(c);
    const db = getReadDb();

    // Latest message per patient
    const rows = await db.execute(sql`
      SELECT DISTINCT ON (m.patient_id)
        m.patient_id  AS patient_id,
        m.id          AS last_message_id,
        m.content     AS last_message_preview,
        m.direction   AS last_direction,
        m.status      AS last_status,
        m.created_at  AS last_created_at,
        (SELECT MAX(created_at) FROM messages mi
           WHERE mi.clinic_id = m.clinic_id
             AND mi.patient_id = m.patient_id
             AND mi.direction = 'inbound'
             AND mi.type = 'whatsapp') AS last_inbound_at,
        (SELECT COUNT(*) FROM messages mu
           WHERE mu.clinic_id = m.clinic_id
             AND mu.patient_id = m.patient_id
             AND mu.direction = 'inbound'
             AND mu.status = 'unread') AS unread_count
      FROM messages m
      WHERE m.clinic_id = ${cid}
        AND m.type = 'whatsapp'
        ${scoped === 'all' ? sql`` : sql`AND m.patient_id = ANY(${scoped})`}
      ORDER BY m.patient_id, m.created_at DESC
    `);

    // Hydrate patient names + sort by last_created_at desc
    const patientIds = (rows.rows as any[]).map(r => r.patient_id).filter(Boolean);
    const patientRows = patientIds.length
      ? await db.select().from(patients).where(inArray(patients.id, patientIds))
      : [];
    const byId = new Map(patientRows.map(p => [p.id, decryptPatientPHI(p)]));

    const threads = (rows.rows as any[]).map(r => {
      const p = byId.get(r.patient_id);
      const window = computeWindowState(r.last_inbound_at ? new Date(r.last_inbound_at) : null);
      return {
        patientId: r.patient_id,
        patientName: p ? `${p.firstName} ${p.lastName}` : 'Unknown',
        patientNumber: p?.patientNumber ?? '',
        lastMessage: {
          id: r.last_message_id,
          preview: (r.last_message_preview ?? '').slice(0, 80),
          direction: r.last_direction,
          status: r.last_status,
          createdAt: r.last_created_at,
        },
        unreadCount: Number(r.unread_count ?? 0),
        window,
      };
    }).sort((a, b) => new Date(b.lastMessage.createdAt).getTime() - new Date(a.lastMessage.createdAt).getTime());

    return c.json({ threads });
  } catch (err) {
    return handleError(c, err);
  }
});

// GET /conversations/:patientId/messages — full thread history
whatsappRoute.get('/conversations/:patientId/messages', async (c) => {
  try {
    const cid = clinicId(c);
    const patientId = c.req.param('patientId');
    const scoped = await scopedPatientIds(c);
    if (scoped !== 'all' && !scoped.includes(patientId)) {
      throw new AppError(403, 'Patient out of doctor scope');
    }
    const db = getReadDb();
    const msgs = await db
      .select()
      .from(messages)
      .where(and(
        eq(messages.clinicId, cid),
        eq(messages.patientId, patientId),
        eq(messages.type, 'whatsapp'),
      ))
      .orderBy(asc(messages.createdAt))
      .limit(500);
    return c.json({ messages: msgs });
  } catch (err) {
    return handleError(c, err);
  }
});

// GET /conversations/:patientId/window — window state for one thread
whatsappRoute.get('/conversations/:patientId/window', async (c) => {
  try {
    const cid = clinicId(c);
    const patientId = c.req.param('patientId');
    const db = getReadDb();
    const [latest] = await db
      .select({ createdAt: messages.createdAt })
      .from(messages)
      .where(and(
        eq(messages.clinicId, cid),
        eq(messages.patientId, patientId),
        eq(messages.type, 'whatsapp'),
        eq(messages.direction, 'inbound'),
      ))
      .orderBy(desc(messages.createdAt))
      .limit(1);
    return c.json(computeWindowState(latest?.createdAt ?? null));
  } catch (err) {
    return handleError(c, err);
  }
});

// GET /events — paginated activity timeline
const eventsQuery = z.object({
  patientId: z.string().uuid().optional(),
  type: z.string().optional(),
  from: z.string().datetime().optional(),
  to: z.string().datetime().optional(),
  cursor: z.string().optional(),
  limit: z.coerce.number().min(1).max(100).default(50),
});

whatsappRoute.get('/events', async (c) => {
  try {
    const cid = clinicId(c);
    const scoped = await scopedPatientIds(c);
    const q = eventsQuery.parse(Object.fromEntries(new URL(c.req.url).searchParams));
    const db = getReadDb();
    const where = [eq(whatsappEvents.clinicId, cid)];
    if (q.patientId) where.push(eq(whatsappEvents.patientId, q.patientId));
    if (q.type) where.push(eq(whatsappEvents.type, q.type));
    if (q.from) where.push(gte(whatsappEvents.createdAt, new Date(q.from)));
    if (q.to)   where.push(lte(whatsappEvents.createdAt, new Date(q.to)));
    if (scoped !== 'all') where.push(inArray(whatsappEvents.patientId, scoped));
    if (q.cursor) where.push(lte(whatsappEvents.createdAt, new Date(q.cursor)));
    const rows = await db.select().from(whatsappEvents).where(and(...where)).orderBy(desc(whatsappEvents.createdAt)).limit(q.limit + 1);
    const hasMore = rows.length > q.limit;
    const items = rows.slice(0, q.limit);
    return c.json({
      events: items,
      nextCursor: hasMore ? items[items.length - 1].createdAt : null,
    });
  } catch (err) {
    return handleError(c, err);
  }
});

// GET /templates — fetch live from Meta (cached 5 min)
whatsappRoute.get('/templates', async (c) => {
  try {
    const cid = clinicId(c);
    const cfg = await getWhatsAppConfigForClinic(cid);
    const cacheKey = `whatsapp:templates:${cid}`;
    const env = c.env as any;
    const kv = env.WHATSAPP_CACHE as KVNamespace | undefined;
    if (kv) {
      const cached = await kv.get(cacheKey, 'json');
      if (cached) return c.json({ templates: cached, cached: true });
    }
    const resp = await fetch(
      `https://graph.facebook.com/v21.0/${cfg.businessAccountId}/message_templates?limit=200`,
      { headers: { Authorization: `Bearer ${cfg.accessToken}` } },
    );
    if (!resp.ok) {
      const text = await resp.text();
      throw new AppError(502, `Meta template fetch failed: ${resp.status} ${text}`);
    }
    const body = await resp.json() as any;
    const templates = (body.data ?? []).map((t: any) => ({
      name: t.name,
      category: (t.category ?? '').toLowerCase(),  // marketing | utility | authentication
      language: t.language,
      status: t.status,  // APPROVED | PENDING | REJECTED
      components: t.components,
    }));
    if (kv) await kv.put(cacheKey, JSON.stringify(templates), { expirationTtl: 300 });
    return c.json({ templates, cached: false });
  } catch (err) {
    return handleError(c, err);
  }
});

// GET /quick-replies
whatsappRoute.get('/quick-replies', async (c) => {
  try {
    const cid = clinicId(c);
    const db = getReadDb();
    const rows = await db.select().from(whatsappQuickReplies)
      .where(eq(whatsappQuickReplies.clinicId, cid))
      .orderBy(asc(whatsappQuickReplies.position), asc(whatsappQuickReplies.shortcut));
    return c.json({ quickReplies: rows });
  } catch (err) {
    return handleError(c, err);
  }
});

// GET /analytics — single payload powering all Analytics-tab cards
whatsappRoute.get('/analytics', async (c) => {
  try {
    const cid = clinicId(c);
    const db = getReadDb();
    const thirtyDaysAgo = new Date(Date.now() - 30 * 86400 * 1000);

    // Aggregate cost + counts by category
    const costRows = await db.execute(sql`
      SELECT
        COALESCE(category, 'service') AS category,
        COUNT(*) FILTER (WHERE type IN ('outbound_message', 'template_sent', 'delivered')) AS sent,
        COUNT(*) FILTER (WHERE type = 'delivered') AS delivered,
        COUNT(*) FILTER (WHERE type = 'read') AS read,
        SUM(cost_pkr) AS cost
      FROM whatsapp_events
      WHERE clinic_id = ${cid} AND created_at >= ${thirtyDaysAgo.toISOString()}
      GROUP BY 1
    `);

    const monthCost = await db.execute(sql`
      SELECT SUM(cost_pkr)::numeric AS total
      FROM whatsapp_events
      WHERE clinic_id = ${cid}
        AND date_trunc('month', created_at) = date_trunc('month', NOW())
        AND type = 'delivered'
    `);

    // Busiest hours
    const hours = await db.execute(sql`
      SELECT EXTRACT(HOUR FROM created_at AT TIME ZONE 'Asia/Karachi') AS hour, COUNT(*) AS n
      FROM whatsapp_events
      WHERE clinic_id = ${cid}
        AND type IN ('inbound_message', 'outbound_message')
        AND created_at >= ${thirtyDaysAgo.toISOString()}
      GROUP BY 1 ORDER BY 1
    `);

    return c.json({
      lastUpdated: RATE_CARD_LAST_UPDATED,
      monthCostPkr: Number((monthCost.rows as any[])[0]?.total ?? 0),
      byCategory: costRows.rows,
      busiestHours: hours.rows,
    });
  } catch (err) {
    return handleError(c, err);
  }
});

// GET /cost/preview?category=&insideWindow=
whatsappRoute.get('/cost/preview', async (c) => {
  const category = (c.req.query('category') ?? 'service') as WaCategory;
  const insideWindow = c.req.query('insideWindow') === 'true';
  const cid = clinicId(c);
  const db = getReadDb();
  const [mod] = await db.select().from(clinicModules)
    .where(and(eq(clinicModules.clinicId, cid), eq(clinicModules.moduleKey, 'whatsapp')))
    .limit(1);
  const override = (() => {
    try { return mod?.config ? JSON.parse(mod.config).rateCardOverride : undefined; } catch { return undefined; }
  })();
  return c.json({
    pricePkr: priceForMessage({ category, country: 'PK', insideWindow, override }),
    currency: 'PKR',
    isFreeInWindow: category === 'service' || (category === 'utility' && insideWindow),
  });
});
Block C — write endpoints:
const sendTextSchema = z.object({ body: z.string().min(1).max(4096) });

whatsappRoute.post('/conversations/:patientId/send', async (c) => {
  try {
    const cid = clinicId(c);
    const patientId = c.req.param('patientId');
    const { body } = sendTextSchema.parse(await c.req.json());
    const scoped = await scopedPatientIds(c);
    if (scoped !== 'all' && !scoped.includes(patientId)) throw new AppError(403, 'Patient out of doctor scope');

    // Server-side enforcement — refuse free-text outside the 24h window.
    const db = getReadDb();
    const [latest] = await db.select({ createdAt: messages.createdAt })
      .from(messages)
      .where(and(
        eq(messages.clinicId, cid),
        eq(messages.patientId, patientId),
        eq(messages.type, 'whatsapp'),
        eq(messages.direction, 'inbound'),
      ))
      .orderBy(desc(messages.createdAt))
      .limit(1);
    const state = computeWindowState(latest?.createdAt ?? null);
    if (state.status === 'red') {
      throw new AppError(409, 'Free-text window closed. Send an approved template instead.');
    }

    // Resolve patient WhatsApp phone
    const [patient] = await db.select().from(patients).where(eq(patients.id, patientId)).limit(1);
    if (!patient) throw new AppError(404, 'Patient not found');
    const decrypted = decryptPatientPHI(patient) as any;
    const phone = decrypted.whatsappPhone || decrypted.phone;
    if (!phone) throw new AppError(400, 'Patient has no phone on file');

    const sent = await sendTextMessage({
      clinicId: cid,
      patientId,
      to: phone,
      body,
      userId: c.get('user').id,
    });
    return c.json({ ok: true, messageId: sent.messageId });
  } catch (err) {
    return handleError(c, err);
  }
});

const sendTemplateSchema = z.object({
  templateName: z.string().min(1),
  languageCode: z.string().default('en'),
  components: z.array(z.any()).optional(),
});

whatsappRoute.post('/conversations/:patientId/send-template', async (c) => {
  try {
    const cid = clinicId(c);
    const patientId = c.req.param('patientId');
    const args = sendTemplateSchema.parse(await c.req.json());
    const scoped = await scopedPatientIds(c);
    if (scoped !== 'all' && !scoped.includes(patientId)) throw new AppError(403, 'Patient out of doctor scope');

    const db = getReadDb();
    const [patient] = await db.select().from(patients).where(eq(patients.id, patientId)).limit(1);
    if (!patient) throw new AppError(404, 'Patient not found');
    const decrypted = decryptPatientPHI(patient) as any;
    const phone = decrypted.whatsappPhone || decrypted.phone;
    if (!phone) throw new AppError(400, 'Patient has no phone on file');

    const sent = await sendTemplateMessage({
      clinicId: cid,
      patientId,
      to: phone,
      templateName: args.templateName,
      languageCode: args.languageCode,
      components: args.components,
      userId: c.get('user').id,
    });
    return c.json({ ok: true, messageId: sent.messageId });
  } catch (err) {
    return handleError(c, err);
  }
});

const quickReplySchema = z.object({
  shortcut: z.string().regex(/^\/[a-z0-9_-]+$/, 'Must look like /slug'),
  body: z.string().min(1).max(2000),
  position: z.number().int().nonnegative().default(0),
});

whatsappRoute.post('/quick-replies', async (c) => {
  try {
    const cid = clinicId(c);
    if (!c.get('permissions')?.has('comms.whatsapp.quick_replies.edit')) throw new AppError(403, 'forbidden');
    const args = quickReplySchema.parse(await c.req.json());
    const db = await getDatabase();
    const [row] = await db.insert(whatsappQuickReplies).values({
      id: crypto.randomUUID(),
      clinicId: cid,
      shortcut: args.shortcut,
      body: args.body,
      position: args.position,
      createdBy: c.get('user').id,
    }).returning();
    return c.json({ quickReply: row });
  } catch (err) {
    return handleError(c, err);
  }
});

whatsappRoute.patch('/quick-replies/:id', async (c) => {
  try {
    const cid = clinicId(c);
    if (!c.get('permissions')?.has('comms.whatsapp.quick_replies.edit')) throw new AppError(403, 'forbidden');
    const id = c.req.param('id');
    const args = quickReplySchema.partial().parse(await c.req.json());
    const db = await getDatabase();
    const [row] = await db.update(whatsappQuickReplies)
      .set({ ...args, updatedAt: new Date() })
      .where(and(eq(whatsappQuickReplies.id, id), eq(whatsappQuickReplies.clinicId, cid)))
      .returning();
    return c.json({ quickReply: row });
  } catch (err) {
    return handleError(c, err);
  }
});

whatsappRoute.delete('/quick-replies/:id', async (c) => {
  try {
    const cid = clinicId(c);
    if (!c.get('permissions')?.has('comms.whatsapp.quick_replies.edit')) throw new AppError(403, 'forbidden');
    const id = c.req.param('id');
    const db = await getDatabase();
    await db.delete(whatsappQuickReplies)
      .where(and(eq(whatsappQuickReplies.id, id), eq(whatsappQuickReplies.clinicId, cid)));
    return c.json({ ok: true });
  } catch (err) {
    return handleError(c, err);
  }
});

export default whatsappRoute;
  • Step 2: Register the route in api.ts
In server/src/api.ts, in the protected-routes section, add the import + route registration adjacent to other module routes:
import whatsappModuleRoute from './routes/whatsapp';
// …
protectedRoutes.route('/whatsapp-module', whatsappModuleRoute);
Use /whatsapp-module (not /whatsapp) because /whatsapp is already taken by the existing config route (whatsappConfigRoute at line 1349). The frontend uses /api/v1/protected/whatsapp-module/*.
  • Step 3: Typecheck
cd server && npx tsc --noEmit 2>&1 | grep -E "routes/whatsapp|api\.ts" | grep -v "messages\.ts"
Expected: no new errors.
  • Step 4: Commit
git add server/src/routes/whatsapp.ts server/src/api.ts
git commit -m "feat(whatsapp): module API — conversations, events, templates, quick replies, analytics"

Task 6: Permissions + sidebar nav entry

Files:
  • Modify: ui/src/lib/permissionTree.ts
  • Modify: ui/src/components/layout/AppLayout.tsx (or wherever nav items are defined)
  • Modify: server/src/middleware/permissions.ts (if a server-side permission registry exists)
  • Step 1: Locate the nav items array
grep -rn "navItems\s*=\|NavEntry" /Users/ssh/Documents/Beta-App/odontoX/ui/src/components --include="*.tsx" | head -10
Identify the file that defines the sidebar nav (typically AppLayout.tsx or a sibling nav.ts). Add a new entry there.
  • Step 2: Add the WhatsApp nav entry
In the nav definition file, near other module entries (Patients, Appointments, Files), add:
{
  id: 'whatsapp',
  label: 'WhatsApp',
  icon: 'MessageCircle',  // or whatever icon lib uses
  permission: 'comms.whatsapp.module.access',
  module: 'whatsapp_api',  // gated on clinic_modules.whatsapp_api being enabled
  route: '/whatsapp',
},
If your nav system gates on permission AND module, this is now hidden when either is missing.
  • Step 3: Register permission keys in permissionTree.ts
Find the existing tree (search for comms.messages. or comms.whatsapp.). Add a whatsapp branch:
{
  key: 'comms.whatsapp',
  label: 'WhatsApp',
  children: [
    { key: 'comms.whatsapp.module.access',       label: 'Access the WhatsApp module',     defaultRoles: ['admin', 'receptionist', 'doctor'] },
    { key: 'comms.whatsapp.templates.send',      label: 'Send approved templates',          defaultRoles: ['admin', 'receptionist'] },
    { key: 'comms.whatsapp.quick_replies.edit',  label: 'Edit quick reply snippets',        defaultRoles: ['admin'] },
    { key: 'comms.whatsapp.analytics.view',      label: 'View WhatsApp analytics',          defaultRoles: ['admin'] },
  ],
},
  • Step 4: Verify the nav entry shows when expected
cd ui && npx tsc --noEmit -p tsconfig.json 2>&1 | tail -5
Expected: exit 0.
  • Step 5: Commit
git add ui/src/lib/permissionTree.ts ui/src/components/layout/AppLayout.tsx
git commit -m "feat(whatsapp): sidebar nav entry + permission keys"

Task 7: Typed API client wrappers in serverComm.ts

Files:
  • Modify: ui/src/lib/serverComm.ts
  • Step 1: Add types + fetch functions at the bottom of serverComm.ts
// ─── WhatsApp Module ─────────────────────────────────────────────────────────

export interface WaWindowState {
  status: 'green' | 'amber' | 'red' | 'free-entry';
  opensAt: string | null;
  closesAt: string | null;
  secondsLeft: number;
}

export interface WaThread {
  patientId: string;
  patientName: string;
  patientNumber: string;
  lastMessage: { id: string; preview: string; direction: 'inbound' | 'outbound'; status: string; createdAt: string };
  unreadCount: number;
  window: WaWindowState;
}

export interface WaEvent {
  id: string;
  type: 'inbound_message' | 'outbound_message' | 'template_sent' | 'delivered' | 'read' | 'failed' | 'window_opened' | 'window_closed' | 'free_entry';
  category: 'marketing' | 'utility' | 'authentication' | 'auth_international' | 'service' | null;
  templateName: string | null;
  costPkr: string;  // numeric serialized as string
  metadata: Record<string, unknown>;
  createdAt: string;
  patientId: string | null;
  messageId: string | null;
}

export interface WaTemplate {
  name: string;
  category: string;
  language: string;
  status: 'APPROVED' | 'PENDING' | 'REJECTED';
  components: any[];
}

export interface WaQuickReply {
  id: string;
  shortcut: string;
  body: string;
  position: number;
}

const WA_BASE = '/api/v1/protected/whatsapp-module';

export async function getWaThreads(): Promise<WaThread[]> {
  const r = await fetchWithAuth(`${WA_BASE}/conversations`);
  const j = await r.json();
  return j.threads ?? [];
}

export async function getWaThreadMessages(patientId: string): Promise<Message[]> {
  const r = await fetchWithAuth(`${WA_BASE}/conversations/${patientId}/messages`);
  const j = await r.json();
  return j.messages ?? [];
}

export async function getWaWindow(patientId: string): Promise<WaWindowState> {
  const r = await fetchWithAuth(`${WA_BASE}/conversations/${patientId}/window`);
  return r.json();
}

export async function sendWaText(patientId: string, body: string): Promise<{ ok: boolean; messageId: string }> {
  const r = await fetchWithAuth(`${WA_BASE}/conversations/${patientId}/send`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ body }),
  });
  return r.json();
}

export async function sendWaTemplate(patientId: string, args: { templateName: string; languageCode?: string; components?: any[] }): Promise<{ ok: boolean; messageId: string }> {
  const r = await fetchWithAuth(`${WA_BASE}/conversations/${patientId}/send-template`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(args),
  });
  return r.json();
}

export async function getWaEvents(params: { patientId?: string; type?: string; from?: string; to?: string; cursor?: string; limit?: number } = {}): Promise<{ events: WaEvent[]; nextCursor: string | null }> {
  const qs = new URLSearchParams();
  for (const [k, v] of Object.entries(params)) if (v != null) qs.set(k, String(v));
  const r = await fetchWithAuth(`${WA_BASE}/events?${qs.toString()}`);
  return r.json();
}

export async function getWaTemplates(): Promise<{ templates: WaTemplate[]; cached: boolean }> {
  const r = await fetchWithAuth(`${WA_BASE}/templates`);
  return r.json();
}

export async function getWaQuickReplies(): Promise<WaQuickReply[]> {
  const r = await fetchWithAuth(`${WA_BASE}/quick-replies`);
  const j = await r.json();
  return j.quickReplies ?? [];
}

export async function createWaQuickReply(args: { shortcut: string; body: string; position?: number }): Promise<WaQuickReply> {
  const r = await fetchWithAuth(`${WA_BASE}/quick-replies`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(args),
  });
  return (await r.json()).quickReply;
}

export async function updateWaQuickReply(id: string, args: Partial<{ shortcut: string; body: string; position: number }>): Promise<WaQuickReply> {
  const r = await fetchWithAuth(`${WA_BASE}/quick-replies/${id}`, {
    method: 'PATCH', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(args),
  });
  return (await r.json()).quickReply;
}

export async function deleteWaQuickReply(id: string): Promise<void> {
  await fetchWithAuth(`${WA_BASE}/quick-replies/${id}`, { method: 'DELETE' });
}

export interface WaAnalytics {
  lastUpdated: string;
  monthCostPkr: number;
  byCategory: Array<{ category: string; sent: number; delivered: number; read: number; cost: string }>;
  busiestHours: Array<{ hour: number; n: number }>;
}

export async function getWaAnalytics(): Promise<WaAnalytics> {
  const r = await fetchWithAuth(`${WA_BASE}/analytics`);
  return r.json();
}

export async function getWaCostPreview(args: { category: string; insideWindow: boolean }): Promise<{ pricePkr: number; currency: string; isFreeInWindow: boolean }> {
  const r = await fetchWithAuth(`${WA_BASE}/cost/preview?category=${args.category}&insideWindow=${args.insideWindow}`);
  return r.json();
}
  • Step 2: Typecheck
cd ui && npx tsc --noEmit -p tsconfig.json 2>&1 | tail -5
Expected: exit 0.
  • Step 3: Commit
git add ui/src/lib/serverComm.ts
git commit -m "feat(whatsapp): typed API client wrappers"

Task 8: Module shell + tab router + route registration

Files:
  • Create: ui/src/components/whatsapp/WhatsAppModule.tsx
  • Modify: routing/dashboard registry (whatever maps view=whatsapp to a component)
  • Step 1: Build the module shell
ui/src/components/whatsapp/WhatsAppModule.tsx:
import { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Settings as SettingsIcon, MessageCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { hasModule } from '@/lib/modules';  // adjust if path differs
import { ConversationsTab } from './ConversationsTab';
import { EventsTab } from './EventsTab';
import { TemplatesTab } from './TemplatesTab';
import { QuickRepliesTab } from './QuickRepliesTab';
import { AnalyticsTab } from './AnalyticsTab';

const TABS = [
  { id: 'conversations', label: 'Conversations', component: ConversationsTab },
  { id: 'events',        label: 'Events',         component: EventsTab },
  { id: 'templates',     label: 'Templates',      component: TemplatesTab },
  { id: 'quick-replies', label: 'Quick replies',  component: QuickRepliesTab },
  { id: 'analytics',     label: 'Analytics',      component: AnalyticsTab },
] as const;

type TabId = typeof TABS[number]['id'];

export default function WhatsAppModule() {
  const [params, setParams] = useSearchParams();
  const navigate = useNavigate();
  const activeTab: TabId = (TABS.find(t => t.id === params.get('tab'))?.id ?? 'conversations');

  if (!hasModule('whatsapp_api')) {
    return (
      <div className="flex items-center justify-center min-h-[400px] p-8">
        <div className="text-center max-w-sm">
          <MessageCircle className="h-10 w-10 mx-auto text-muted-foreground mb-3" />
          <h2 className="text-lg font-medium mb-1">WhatsApp module isn't enabled</h2>
          <p className="text-sm text-muted-foreground mb-4">
            Ask your administrator to enable the WhatsApp add-on for your clinic.
          </p>
        </div>
      </div>
    );
  }

  const Active = TABS.find(t => t.id === activeTab)!.component;

  return (
    <div className="flex flex-col h-full">
      <header className="flex items-center justify-between border-b px-6 py-3">
        <nav className="flex items-center gap-1">
          {TABS.map(t => (
            <button
              key={t.id}
              onClick={() => setParams({ tab: t.id }, { replace: true })}
              className={cn(
                'px-3 py-1.5 text-sm rounded-md transition-colors',
                activeTab === t.id
                  ? 'bg-foreground/5 text-foreground font-medium'
                  : 'text-muted-foreground hover:text-foreground hover:bg-foreground/[0.03]',
              )}
            >
              {t.label}
            </button>
          ))}
        </nav>
        <Button
          variant="ghost"
          size="sm"
          onClick={() => navigate('/dashboard?view=settings&settingsView=whatsapp')}
          className="gap-2 text-muted-foreground"
        >
          <SettingsIcon className="h-4 w-4" /> Settings
        </Button>
      </header>
      <main className="flex-1 min-h-0 overflow-hidden">
        <Active />
      </main>
      <footer className="border-t px-6 py-2 text-[11px] text-muted-foreground">
        Meta charges your clinic directly for delivered messages. Figures shown reflect Meta's published rates for Pakistan as of 2026-05-13. Free-form replies inside the 24-hour window and service messages are free. Your OdontoX subscription (PKR 7,500/mo) covers the software only.
      </footer>
    </div>
  );
}
  • Step 2: Register the view in the dashboard router
Find where other views map IDs to components (search for view === 'settings' or views[view] in DashboardPage.tsx or similar). Add a whatsapp case:
import WhatsAppModule from '@/components/whatsapp/WhatsAppModule';
// …
if (view === 'whatsapp') return <WhatsAppModule />;
  • Step 3: Stub each tab so the shell compiles
Each of the 5 tab files gets a one-line stub so Task 8 compiles independently of Tasks 10–13:
// ui/src/components/whatsapp/ConversationsTab.tsx (and the other four)
export function ConversationsTab() { return <div className="p-6 text-sm text-muted-foreground">Loading…</div>; }
// Also export EventsTab, TemplatesTab, QuickRepliesTab, AnalyticsTab the same way in their own files.
  • Step 4: Typecheck + visual smoke
cd ui && npx tsc --noEmit -p tsconfig.json && npm run build 2>&1 | tail -5
Expected: clean build.
  • Step 5: Commit
git add ui/src/components/whatsapp/
git commit -m "feat(whatsapp): module shell + tab router + stubs"

Task 9: Composer pack (WindowTimer, CostPill, Composer) + PatientPanel

Files:
  • Create: ui/src/components/whatsapp/composer/WindowTimer.tsx
  • Create: ui/src/components/whatsapp/composer/CostPill.tsx
  • Create: ui/src/components/whatsapp/composer/Composer.tsx
  • Create: ui/src/components/whatsapp/PatientPanel.tsx
  • Step 1: WindowTimer
ui/src/components/whatsapp/composer/WindowTimer.tsx:
import { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import type { WaWindowState } from '@/lib/serverComm';

function format(secs: number): string {
  const h = Math.floor(secs / 3600);
  const m = Math.floor((secs % 3600) / 60);
  return h > 0 ? `${h}h ${m}m` : `${m}m`;
}

const TOTAL_24H = 24 * 3600;
const TOTAL_72H = 72 * 3600;

export function WindowTimer({ state }: { state: WaWindowState }) {
  const [secsLeft, setSecsLeft] = useState(state.secondsLeft);
  useEffect(() => {
    setSecsLeft(state.secondsLeft);
    if (state.status === 'red') return;
    const tick = setInterval(() => setSecsLeft(s => Math.max(0, s - 1)), 1000);
    return () => clearInterval(tick);
  }, [state.secondsLeft, state.status]);

  const totalSecs = state.status === 'free-entry' ? TOTAL_72H : TOTAL_24H;
  const pct = state.status === 'red' ? 0 : (secsLeft / totalSecs) * 100;

  const ring = {
    green:        'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30',
    amber:        'bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30',
    red:          'bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/30',
    'free-entry': 'bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500/30',
  }[state.status];

  const label = {
    green:        `${format(secsLeft)} of free replies left`,
    amber:        `Window closes in ${format(secsLeft)}`,
    red:          'Free-reply window closed — template required',
    'free-entry': `Free entry — ${format(secsLeft)} of free templates left`,
  }[state.status];

  const bar = {
    green:        'bg-emerald-500',
    amber:        'bg-amber-500 animate-pulse',
    red:          'bg-rose-500',
    'free-entry': 'bg-sky-500',
  }[state.status];

  return (
    <div className={cn('border rounded-md px-3 py-1.5 text-xs flex items-center gap-2 relative overflow-hidden', ring)}>
      <span className="font-medium">{label}</span>
      <div className="absolute bottom-0 left-0 right-0 h-px bg-foreground/10">
        <div className={cn('h-full transition-[width] duration-1000', bar)} style={{ width: `${pct}%` }} />
      </div>
    </div>
  );
}
  • Step 2: CostPill
ui/src/components/whatsapp/composer/CostPill.tsx:
import { Circle } from 'lucide-react';
import { cn } from '@/lib/utils';

export interface CostPillProps {
  pricePkr: number;
  category: 'marketing' | 'utility' | 'authentication' | 'service' | null;
  insideWindow: boolean;
}

export function CostPill({ pricePkr, category, insideWindow }: CostPillProps) {
  const isFree = pricePkr === 0;
  const label = isFree
    ? (category === 'utility' && insideWindow ? 'Utility template · Free in window' : 'Free message')
    : `${category === 'marketing' ? 'Marketing' : 'Utility'} · PKR ${pricePkr.toFixed(2)}`;
  return (
    <div className={cn(
      'inline-flex items-center gap-1.5 text-[11px] px-2 py-0.5 rounded-full border',
      isFree
        ? 'border-emerald-500/40 text-emerald-700 dark:text-emerald-300 bg-emerald-500/5'
        : 'border-amber-500/40 text-amber-700 dark:text-amber-300 bg-amber-500/5',
    )}>
      <Circle className={cn('h-2 w-2', isFree ? 'fill-emerald-500 stroke-emerald-500' : 'fill-amber-500 stroke-amber-500')} />
      <span>{label}</span>
      <span className="opacity-60 ml-1" title="Charged by Meta directly. Separate from your OdontoX subscription."></span>
    </div>
  );
}
  • Step 3: Composer (the 3-state composer)
ui/src/components/whatsapp/composer/Composer.tsx:
import { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Lock, Send, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { WindowTimer } from './WindowTimer';
import { CostPill } from './CostPill';
import {
  getWaWindow,
  sendWaText,
  sendWaTemplate,
  getWaCostPreview,
  getWaQuickReplies,
  getWaTemplates,
  type WaWindowState,
  type WaTemplate,
} from '@/lib/serverComm';
import { toast } from '@/lib/toast';

export interface ComposerProps {
  patientId: string;
}

export function Composer({ patientId }: ComposerProps) {
  const qc = useQueryClient();
  const { data: window } = useQuery({
    queryKey: ['wa-window', patientId],
    queryFn: () => getWaWindow(patientId),
    refetchInterval: 60_000,
  });
  const { data: quickReplies = [] } = useQuery({
    queryKey: ['wa-quick-replies'],
    queryFn: getWaQuickReplies,
    staleTime: 5 * 60_000,
  });
  const { data: templatesResp } = useQuery({
    queryKey: ['wa-templates'],
    queryFn: getWaTemplates,
    enabled: window?.status === 'red' || window?.status === 'free-entry',
    staleTime: 5 * 60_000,
  });

  const [text, setText] = useState('');
  const [showTemplatePicker, setShowTemplatePicker] = useState(false);
  const [pendingTemplate, setPendingTemplate] = useState<WaTemplate | null>(null);

  // Switch to template picker automatically when the window closes.
  useEffect(() => {
    if (window?.status === 'red') setShowTemplatePicker(true);
  }, [window?.status]);

  const insideWindow = window?.status === 'green' || window?.status === 'amber';

  const { data: costPreview } = useQuery({
    queryKey: ['wa-cost', insideWindow, pendingTemplate?.category ?? 'service'],
    queryFn: () => getWaCostPreview({ category: pendingTemplate?.category ?? 'service', insideWindow }),
  });

  const sendText = useMutation({
    mutationFn: () => sendWaText(patientId, text.trim()),
    onSuccess: () => {
      setText('');
      qc.invalidateQueries({ queryKey: ['wa-thread-messages', patientId] });
      qc.invalidateQueries({ queryKey: ['wa-threads'] });
    },
    onError: (e: any) => toast.error(e.message || 'Failed to send'),
  });

  const sendTpl = useMutation({
    mutationFn: () => {
      if (!pendingTemplate) throw new Error('No template selected');
      return sendWaTemplate(patientId, { templateName: pendingTemplate.name, languageCode: pendingTemplate.language });
    },
    onSuccess: () => {
      setPendingTemplate(null);
      setShowTemplatePicker(false);
      qc.invalidateQueries({ queryKey: ['wa-thread-messages', patientId] });
    },
    onError: (e: any) => toast.error(e.message || 'Failed to send template'),
  });

  // Slash autocomplete for quick replies
  const slashMatches = useMemo(() => {
    const m = text.match(/(^|\s)(\/[\w-]*)$/);
    if (!m) return [];
    const term = m[2].toLowerCase();
    return quickReplies.filter(q => q.shortcut.startsWith(term)).slice(0, 5);
  }, [text, quickReplies]);

  return (
    <div className="border-t bg-background">
      {window && (
        <div className="px-4 pt-3"><WindowTimer state={window} /></div>
      )}
      <div className="p-3">
        {window?.status === 'red' ? (
          <TemplatePicker
            templates={(templatesResp?.templates ?? []).filter(t => t.status === 'APPROVED')}
            selected={pendingTemplate}
            onSelect={setPendingTemplate}
          />
        ) : (
          <div className="relative">
            <Textarea
              value={text}
              onChange={e => setText(e.target.value)}
              onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (text.trim()) sendText.mutate(); } }}
              placeholder="Type a message…  (use / for quick replies)"
              className="resize-none min-h-[60px] text-sm"
              maxLength={4096}
            />
            {slashMatches.length > 0 && (
              <div className="absolute bottom-full left-0 mb-1 w-72 bg-background border rounded-md shadow-lg overflow-hidden">
                {slashMatches.map(q => (
                  <button
                    key={q.id}
                    onClick={() => setText(t => t.replace(/(^|\s)\/[\w-]*$/, `$1${q.body}`))}
                    className="w-full text-left px-3 py-1.5 text-xs hover:bg-foreground/5 flex justify-between"
                  >
                    <span className="font-mono">{q.shortcut}</span>
                    <span className="text-muted-foreground truncate ml-2">{q.body.slice(0, 40)}</span>
                  </button>
                ))}
              </div>
            )}
          </div>
        )}
        <div className="flex items-center justify-between pt-2">
          <CostPill
            pricePkr={costPreview?.pricePkr ?? 0}
            category={pendingTemplate?.category as any ?? 'service'}
            insideWindow={insideWindow}
          />
          <div className="flex items-center gap-2 text-[11px] text-muted-foreground">
            {window?.status !== 'red' && <span>{text.length} / 4096</span>}
            <Button
              size="sm"
              onClick={() => window?.status === 'red' ? sendTpl.mutate() : sendText.mutate()}
              disabled={
                window?.status === 'red'
                  ? !pendingTemplate || sendTpl.isPending
                  : !text.trim() || sendText.isPending
              }
              className="gap-1.5"
            >
              {window?.status === 'red' ? <FileText className="h-3.5 w-3.5" /> : <Send className="h-3.5 w-3.5" />}
              Send
            </Button>
          </div>
        </div>
      </div>
    </div>
  );
}

function TemplatePicker({ templates, selected, onSelect }: { templates: WaTemplate[]; selected: WaTemplate | null; onSelect: (t: WaTemplate | null) => void }) {
  return (
    <div className="border rounded-md p-2 max-h-[180px] overflow-auto space-y-1">
      <div className="flex items-center gap-2 text-xs text-muted-foreground pb-1 border-b mb-1">
        <Lock className="h-3 w-3" /> Choose an approved template
      </div>
      {templates.length === 0 && (
        <div className="text-xs text-muted-foreground py-4 text-center">No approved templates. Add one in Meta Business Manager.</div>
      )}
      {templates.map(t => (
        <button
          key={`${t.name}-${t.language}`}
          onClick={() => onSelect(selected?.name === t.name ? null : t)}
          className={cn(
            'w-full text-left text-xs p-2 rounded-md border',
            selected?.name === t.name ? 'border-foreground/40 bg-foreground/5' : 'border-transparent hover:bg-foreground/[0.03]',
          )}
        >
          <div className="flex justify-between items-center">
            <span className="font-mono">{t.name}</span>
            <span className="uppercase text-[10px] text-muted-foreground">{t.category} · {t.language}</span>
          </div>
        </button>
      ))}
    </div>
  );
}
  • Step 4: PatientPanel
ui/src/components/whatsapp/PatientPanel.tsx:
import { useQuery } from '@tanstack/react-query';
import { Phone, Calendar, Banknote } from 'lucide-react';
import { getPatient, getAppointmentsForPatient, getInvoicesForPatient } from '@/lib/serverComm';
import { format } from 'date-fns';

export function PatientPanel({ patientId }: { patientId: string | null }) {
  if (!patientId) {
    return <div className="h-full grid place-items-center text-xs text-muted-foreground p-6">Select a conversation</div>;
  }
  const { data: patient } = useQuery({ queryKey: ['patient', patientId], queryFn: () => getPatient(patientId) });
  const { data: appts = [] } = useQuery({ queryKey: ['appts', patientId], queryFn: () => getAppointmentsForPatient(patientId), enabled: !!patient });
  const { data: invoices = [] } = useQuery({ queryKey: ['invoices', patientId], queryFn: () => getInvoicesForPatient(patientId), enabled: !!patient });

  const nextAppt = appts.filter((a: any) => new Date(a.appointmentDate) >= new Date()).sort((a: any, b: any) => +new Date(a.appointmentDate) - +new Date(b.appointmentDate))[0];
  const lastAppt = appts.filter((a: any) => new Date(a.appointmentDate) < new Date()).sort((a: any, b: any) => +new Date(b.appointmentDate) - +new Date(a.appointmentDate))[0];
  const balance = invoices.reduce((s: number, inv: any) => s + Number(inv.balance ?? 0), 0);

  return (
    <aside className="h-full overflow-auto p-4 space-y-4 text-sm">
      <div>
        <h3 className="font-medium text-base">{patient ? `${patient.firstName} ${patient.lastName}` : '—'}</h3>
        <div className="text-xs text-muted-foreground font-mono">{patient?.patientNumber}</div>
      </div>
      <Row icon={<Phone className="h-3.5 w-3.5" />} label="Phone" value={patient?.phone ?? '—'} />
      <Row icon={<Calendar className="h-3.5 w-3.5" />} label="Next appointment" value={nextAppt ? format(new Date(nextAppt.appointmentDate), 'd MMM yyyy, HH:mm') : 'None scheduled'} />
      <Row icon={<Calendar className="h-3.5 w-3.5" />} label="Last visit" value={lastAppt ? format(new Date(lastAppt.appointmentDate), 'd MMM yyyy') : 'No prior visits'} />
      <Row icon={<Banknote className="h-3.5 w-3.5" />} label="Balance" value={balance > 0 ? `PKR ${balance.toLocaleString()}` : 'Settled'} />
    </aside>
  );
}

function Row({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
  return (
    <div className="flex items-start gap-2">
      <div className="mt-0.5 text-muted-foreground">{icon}</div>
      <div className="flex-1">
        <div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
        <div className="text-sm">{value}</div>
      </div>
    </div>
  );
}
If getAppointmentsForPatient / getInvoicesForPatient don’t exist in serverComm.ts, fall back to a thin wrapper around the existing patient-detail fetches.
  • Step 5: Typecheck
cd ui && npx tsc --noEmit -p tsconfig.json 2>&1 | tail -5
Expected: exit 0.
  • Step 6: Commit
git add ui/src/components/whatsapp/composer/ ui/src/components/whatsapp/PatientPanel.tsx
git commit -m "feat(whatsapp): window-aware composer + patient panel"

Task 10: ConversationsTab — 3-pane layout

Files:
  • Modify: ui/src/components/whatsapp/ConversationsTab.tsx
  • Step 1: Replace the stub with the real tab
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getWaThreads, getWaThreadMessages, type WaThread } from '@/lib/serverComm';
import { Composer } from './composer/Composer';
import { PatientPanel } from './PatientPanel';
import { cn } from '@/lib/utils';
import { format, isToday, isYesterday } from 'date-fns';
import { Search } from 'lucide-react';
import { Input } from '@/components/ui/input';

function fmtTime(s: string): string {
  const d = new Date(s);
  if (isToday(d)) return format(d, 'HH:mm');
  if (isYesterday(d)) return 'Yesterday';
  return format(d, 'd MMM');
}

const FILTERS = ['All', 'Unread', 'Awaiting reply', 'Today'] as const;

export function ConversationsTab() {
  const [filter, setFilter] = useState<typeof FILTERS[number]>('All');
  const [search, setSearch] = useState('');
  const [selected, setSelected] = useState<string | null>(null);
  const { data: threads = [], isLoading } = useQuery({ queryKey: ['wa-threads'], queryFn: getWaThreads, refetchInterval: 30_000 });

  const filtered = threads.filter(t => {
    if (search && !t.patientName.toLowerCase().includes(search.toLowerCase())) return false;
    if (filter === 'Unread') return t.unreadCount > 0;
    if (filter === 'Awaiting reply') return t.lastMessage.direction === 'inbound';
    if (filter === 'Today') return isToday(new Date(t.lastMessage.createdAt));
    return true;
  });

  return (
    <div className="grid h-full" style={{ gridTemplateColumns: '320px 1fr 320px' }}>
      <ThreadList
        threads={filtered}
        filter={filter}
        setFilter={setFilter}
        search={search}
        setSearch={setSearch}
        selected={selected}
        setSelected={setSelected}
        loading={isLoading}
      />
      <ThreadPane patientId={selected} />
      <div className="border-l">
        <PatientPanel patientId={selected} />
      </div>
    </div>
  );
}

function ThreadList({ threads, filter, setFilter, search, setSearch, selected, setSelected, loading }: {
  threads: WaThread[];
  filter: typeof FILTERS[number];
  setFilter: (f: typeof FILTERS[number]) => void;
  search: string;
  setSearch: (s: string) => void;
  selected: string | null;
  setSelected: (id: string) => void;
  loading: boolean;
}) {
  return (
    <aside className="border-r flex flex-col min-h-0">
      <div className="p-3 border-b space-y-2">
        <div className="relative">
          <Search className="h-3.5 w-3.5 absolute left-2.5 top-2.5 text-muted-foreground" />
          <Input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search patients…" className="pl-8 h-8 text-sm" />
        </div>
        <div className="flex gap-1 text-[11px]">
          {FILTERS.map(f => (
            <button key={f} onClick={() => setFilter(f)} className={cn(
              'px-2 py-1 rounded-md',
              filter === f ? 'bg-foreground/10 text-foreground' : 'text-muted-foreground hover:bg-foreground/5',
            )}>{f}</button>
          ))}
        </div>
      </div>
      <div className="overflow-auto flex-1">
        {loading && <div className="p-6 text-xs text-muted-foreground">Loading…</div>}
        {!loading && threads.length === 0 && <div className="p-6 text-xs text-muted-foreground">No conversations yet.</div>}
        {threads.map(t => {
          const dot = {
            green: 'bg-emerald-500',
            amber: 'bg-amber-500',
            red:   'bg-rose-500',
            'free-entry': 'bg-sky-500',
          }[t.window.status];
          return (
            <button
              key={t.patientId}
              onClick={() => setSelected(t.patientId)}
              className={cn(
                'w-full text-left px-3 py-2.5 border-b flex gap-3 items-start hover:bg-foreground/[0.02]',
                selected === t.patientId && 'bg-foreground/[0.04]',
              )}
            >
              <div className="flex-1 min-w-0">
                <div className="flex items-center justify-between gap-2">
                  <span className="text-sm font-medium truncate">{t.patientName}</span>
                  <span className="text-[10px] text-muted-foreground shrink-0">{fmtTime(t.lastMessage.createdAt)}</span>
                </div>
                <div className="flex items-center gap-2 mt-0.5">
                  <span className={cn('inline-block h-1.5 w-1.5 rounded-full shrink-0', dot)} />
                  <span className="text-xs text-muted-foreground truncate">{t.lastMessage.preview || '—'}</span>
                  {t.unreadCount > 0 && (
                    <span className="ml-auto text-[10px] font-medium bg-emerald-500 text-white rounded-full px-1.5 py-0.5">{t.unreadCount}</span>
                  )}
                </div>
              </div>
            </button>
          );
        })}
      </div>
    </aside>
  );
}

function ThreadPane({ patientId }: { patientId: string | null }) {
  if (!patientId) {
    return <div className="grid place-items-center text-sm text-muted-foreground">Choose a conversation on the left</div>;
  }
  const { data: msgs = [], isLoading } = useQuery({
    queryKey: ['wa-thread-messages', patientId],
    queryFn: () => getWaThreadMessages(patientId),
    refetchInterval: 30_000,
  });
  return (
    <section className="flex flex-col min-h-0">
      <div className="flex-1 overflow-auto p-4 space-y-2">
        {isLoading && <div className="text-xs text-muted-foreground">Loading…</div>}
        {msgs.map((m: any) => <Bubble key={m.id} msg={m} />)}
      </div>
      <Composer patientId={patientId} />
    </section>
  );
}

function Bubble({ msg }: { msg: any }) {
  const out = msg.direction === 'outbound';
  return (
    <div className={cn('flex', out ? 'justify-end' : 'justify-start')}>
      <div className={cn(
        'max-w-[70%] px-3 py-2 rounded-lg text-sm',
        out ? 'bg-emerald-500 text-white rounded-br-sm' : 'bg-muted rounded-bl-sm',
      )}>
        <div className="whitespace-pre-wrap break-words">{msg.content || msg.body}</div>
        <div className={cn('text-[10px] mt-1 flex items-center gap-1', out ? 'text-emerald-100' : 'text-muted-foreground')}>
          <span>{format(new Date(msg.createdAt), 'HH:mm')}</span>
          {out && msg.status === 'read' && <span>✓✓</span>}
          {out && msg.status === 'delivered' && <span></span>}
        </div>
      </div>
    </div>
  );
}
  • Step 2: Typecheck + visual smoke in browser
cd ui && npx tsc --noEmit -p tsconfig.json && npm run dev
Navigate to /dashboard?view=whatsapp, verify list+pane+panel render.
  • Step 3: Commit
git add ui/src/components/whatsapp/ConversationsTab.tsx
git commit -m "feat(whatsapp): conversations tab with 3-pane layout"

Task 11: EventsTab — Linear-style activity timeline

Files:
  • Modify: ui/src/components/whatsapp/EventsTab.tsx
  • Step 1: Implement the timeline
import { useState, useMemo } from 'react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { format, isSameDay } from 'date-fns';
import { ArrowDownLeft, ArrowUpRight, FileText, Check, CheckCheck, X, Clock, Lock, Target } from 'lucide-react';
import { getWaEvents, type WaEvent } from '@/lib/serverComm';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';

const TYPE_META: Record<WaEvent['type'], { icon: React.ComponentType<any>; label: (e: WaEvent) => string; tone: string }> = {
  inbound_message:  { icon: ArrowDownLeft, label: () => 'Inbound message',  tone: 'text-foreground' },
  outbound_message: { icon: ArrowUpRight,  label: () => 'Outbound message', tone: 'text-foreground' },
  template_sent:    { icon: FileText,      label: (e) => `Template sent · ${e.templateName ?? ''}`, tone: 'text-foreground' },
  delivered:        { icon: Check,         label: (e) => `Delivered${Number(e.costPkr) > 0 ? ` · PKR ${Number(e.costPkr).toFixed(2)}` : ''}`, tone: 'text-muted-foreground' },
  read:             { icon: CheckCheck,    label: () => 'Read',             tone: 'text-muted-foreground' },
  failed:           { icon: X,             label: () => 'Failed',           tone: 'text-rose-600' },
  window_opened:    { icon: Clock,         label: () => '24h window opened',tone: 'text-emerald-700' },
  window_closed:    { icon: Lock,          label: () => 'Window closed',    tone: 'text-rose-700' },
  free_entry:       { icon: Target,        label: () => 'Free-entry chat (72h free)', tone: 'text-sky-700' },
};

const TYPES = Object.keys(TYPE_META) as Array<keyof typeof TYPE_META>;

export function EventsTab() {
  const [typeFilter, setTypeFilter] = useState<string>('');
  const [patientFilter, setPatientFilter] = useState<string>('');
  const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery({
    queryKey: ['wa-events', typeFilter, patientFilter],
    queryFn: ({ pageParam }) => getWaEvents({ cursor: pageParam, type: typeFilter || undefined, patientId: patientFilter || undefined, limit: 50 }),
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (last) => last.nextCursor ?? undefined,
  });

  const flat = useMemo(() => data?.pages.flatMap(p => p.events) ?? [], [data]);
  const grouped = useMemo(() => {
    const out: Array<{ day: string; rows: WaEvent[] }> = [];
    for (const ev of flat) {
      const day = format(new Date(ev.createdAt), 'EEEE, d MMMM');
      const last = out[out.length - 1];
      if (last && last.day === day) last.rows.push(ev);
      else out.push({ day, rows: [ev] });
    }
    return out;
  }, [flat]);

  return (
    <div className="h-full flex flex-col min-h-0">
      <div className="border-b p-3 flex gap-2 flex-wrap">
        <select value={typeFilter} onChange={e => setTypeFilter(e.target.value)} className="text-xs border rounded-md px-2 py-1 bg-background">
          <option value="">All event types</option>
          {TYPES.map(t => <option key={t} value={t}>{TYPE_META[t].label({ templateName: '' } as any)}</option>)}
        </select>
        <Input value={patientFilter} onChange={e => setPatientFilter(e.target.value)} placeholder="Patient ID filter…" className="h-7 text-xs max-w-xs" />
      </div>
      <div className="flex-1 overflow-auto">
        {grouped.length === 0 && !isFetching && (
          <div className="p-12 text-center text-sm text-muted-foreground">No events yet.</div>
        )}
        {grouped.map(g => (
          <div key={g.day}>
            <div className="sticky top-0 bg-background/95 backdrop-blur border-b px-4 py-1.5 text-[11px] font-medium text-muted-foreground uppercase tracking-wide">{g.day}</div>
            <ul>
              {g.rows.map(ev => {
                const meta = TYPE_META[ev.type];
                const Icon = meta.icon;
                return (
                  <li key={ev.id} className="flex items-start gap-3 px-4 py-2 border-b hover:bg-foreground/[0.02]">
                    <span className="text-[11px] text-muted-foreground font-mono w-12 pt-0.5">{format(new Date(ev.createdAt), 'HH:mm')}</span>
                    <Icon className={cn('h-3.5 w-3.5 mt-0.5', meta.tone)} />
                    <span className={cn('text-sm', meta.tone)}>{meta.label(ev)}</span>
                    {ev.patientId && <span className="text-[11px] text-muted-foreground ml-auto font-mono">{ev.patientId.slice(0, 8)}</span>}
                  </li>
                );
              })}
            </ul>
          </div>
        ))}
        {hasNextPage && (
          <div className="p-4 text-center">
            <Button variant="outline" size="sm" onClick={() => fetchNextPage()} disabled={isFetching}>Load more</Button>
          </div>
        )}
      </div>
    </div>
  );
}
  • Step 2: Typecheck
cd ui && npx tsc --noEmit -p tsconfig.json 2>&1 | tail -5
Expected: exit 0.
  • Step 3: Commit
git add ui/src/components/whatsapp/EventsTab.tsx
git commit -m "feat(whatsapp): events tab with day-grouped timeline"

Task 12: TemplatesTab + send-to-patient dialog

Files:
  • Modify: ui/src/components/whatsapp/TemplatesTab.tsx
  • Step 1: Implement the tab + dialog
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { getWaTemplates, sendWaTemplate, type WaTemplate } from '@/lib/serverComm';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { toast } from '@/lib/toast';

const CAT_TONE: Record<string, string> = {
  marketing:      'bg-amber-500/10 text-amber-700 border-amber-500/30',
  utility:        'bg-emerald-500/10 text-emerald-700 border-emerald-500/30',
  authentication: 'bg-sky-500/10 text-sky-700 border-sky-500/30',
};

export function TemplatesTab() {
  const { data, isLoading } = useQuery({ queryKey: ['wa-templates'], queryFn: getWaTemplates });
  const [selected, setSelected] = useState<WaTemplate | null>(null);
  const templates = (data?.templates ?? []).filter(t => t.status === 'APPROVED');

  return (
    <div className="h-full overflow-auto p-6">
      {isLoading && <div className="text-sm text-muted-foreground">Loading templates…</div>}
      {!isLoading && templates.length === 0 && (
        <div className="text-center max-w-md mx-auto py-12">
          <div className="text-sm font-medium mb-1">No approved templates</div>
          <div className="text-xs text-muted-foreground">Create templates in Meta Business Manager. Once approved, they appear here.</div>
        </div>
      )}
      <div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-3">
        {templates.map(t => (
          <button
            key={`${t.name}-${t.language}`}
            onClick={() => setSelected(t)}
            className="text-left border rounded-md p-3 hover:border-foreground/30 hover:bg-foreground/[0.02] transition-colors"
          >
            <div className="flex items-start justify-between gap-2 mb-2">
              <span className="font-mono text-sm truncate">{t.name}</span>
              <Badge className={cn('text-[10px] uppercase border', CAT_TONE[t.category] ?? '')}>{t.category}</Badge>
            </div>
            <div className="text-[11px] text-muted-foreground mb-2 uppercase tracking-wide">{t.language}</div>
            <div className="text-xs text-muted-foreground line-clamp-3">
              {extractBody(t.components)}
            </div>
          </button>
        ))}
      </div>
      <SendDialog template={selected} onClose={() => setSelected(null)} />
    </div>
  );
}

function extractBody(components: any[]): string {
  const body = components?.find((c: any) => c.type === 'BODY');
  return body?.text ?? '—';
}

function SendDialog({ template, onClose }: { template: WaTemplate | null; onClose: () => void }) {
  const [patientId, setPatientId] = useState('');
  const send = useMutation({
    mutationFn: () => sendWaTemplate(patientId, { templateName: template!.name, languageCode: template!.language }),
    onSuccess: () => { toast.success('Template sent'); onClose(); },
    onError: (e: any) => toast.error(e.message || 'Send failed'),
  });
  return (
    <Dialog open={!!template} onOpenChange={(o) => !o && onClose()}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Send "{template?.name}"</DialogTitle>
        </DialogHeader>
        <div className="space-y-3 text-sm">
          <div>
            <label className="text-xs text-muted-foreground">Patient ID</label>
            <Input value={patientId} onChange={e => setPatientId(e.target.value)} placeholder="patient-uuid" className="font-mono text-xs" />
          </div>
          <div className="text-xs text-muted-foreground bg-muted/40 p-2 rounded-md">
            Cost depends on category and window state — preview shown at send time.
          </div>
        </div>
        <DialogFooter>
          <Button variant="outline" onClick={onClose}>Cancel</Button>
          <Button disabled={!patientId || send.isPending} onClick={() => send.mutate()}>
            Send
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}
For v1 patient selection is a plain text Patient ID input. v1.5 will add a typeahead.
  • Step 2: Typecheck + commit
cd ui && npx tsc --noEmit -p tsconfig.json && git add ui/src/components/whatsapp/TemplatesTab.tsx && \
git commit -m "feat(whatsapp): templates tab with send dialog"

Task 13: QuickRepliesTab + AnalyticsTab

Files:
  • Modify: ui/src/components/whatsapp/QuickRepliesTab.tsx
  • Modify: ui/src/components/whatsapp/AnalyticsTab.tsx
  • Step 1: QuickRepliesTab
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getWaQuickReplies, createWaQuickReply, updateWaQuickReply, deleteWaQuickReply, type WaQuickReply } from '@/lib/serverComm';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Trash2, Plus } from 'lucide-react';
import { toast } from '@/lib/toast';

export function QuickRepliesTab() {
  const qc = useQueryClient();
  const { data: replies = [] } = useQuery({ queryKey: ['wa-quick-replies'], queryFn: getWaQuickReplies });
  const [shortcut, setShortcut] = useState('');
  const [body, setBody] = useState('');

  const create = useMutation({
    mutationFn: () => createWaQuickReply({ shortcut, body }),
    onSuccess: () => { setShortcut(''); setBody(''); qc.invalidateQueries({ queryKey: ['wa-quick-replies'] }); toast.success('Quick reply added'); },
    onError: (e: any) => toast.error(e.message || 'Failed'),
  });
  const remove = useMutation({
    mutationFn: (id: string) => deleteWaQuickReply(id),
    onSuccess: () => qc.invalidateQueries({ queryKey: ['wa-quick-replies'] }),
  });

  return (
    <div className="h-full overflow-auto p-6 max-w-3xl mx-auto">
      <h2 className="text-sm font-medium mb-1">Quick replies</h2>
      <p className="text-xs text-muted-foreground mb-4">Type / in the composer to pick one. Tokens: <code className="text-[11px]">{`{{patientFirstName}}`}</code>, <code className="text-[11px]">{`{{nextAppointment}}`}</code>, <code className="text-[11px]">{`{{balance}}`}</code>.</p>

      <form onSubmit={e => { e.preventDefault(); if (shortcut && body) create.mutate(); }} className="border rounded-md p-3 mb-6 space-y-2">
        <div className="grid grid-cols-[140px_1fr] gap-2">
          <Input value={shortcut} onChange={e => setShortcut(e.target.value)} placeholder="/balance" className="font-mono text-xs h-8" />
          <Input value={body} onChange={e => setBody(e.target.value)} placeholder="Your current balance is PKR {{balance}}." className="text-xs h-8" />
        </div>
        <Button type="submit" size="sm" className="gap-1.5" disabled={!shortcut || !body || create.isPending}>
          <Plus className="h-3.5 w-3.5" /> Add
        </Button>
      </form>

      <table className="w-full text-xs">
        <thead className="text-muted-foreground border-b">
          <tr className="text-left">
            <th className="py-2 px-2">Shortcut</th>
            <th className="py-2 px-2">Body</th>
            <th className="py-2 px-2 w-12"></th>
          </tr>
        </thead>
        <tbody>
          {replies.length === 0 && <tr><td colSpan={3} className="py-6 text-center text-muted-foreground">No quick replies yet.</td></tr>}
          {replies.map((r: WaQuickReply) => (
            <tr key={r.id} className="border-b">
              <td className="py-2 px-2 font-mono">{r.shortcut}</td>
              <td className="py-2 px-2 text-muted-foreground">{r.body}</td>
              <td className="py-2 px-2">
                <Button variant="ghost" size="icon" onClick={() => remove.mutate(r.id)}>
                  <Trash2 className="h-3.5 w-3.5" />
                </Button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
  • Step 2: AnalyticsTab
import { useQuery } from '@tanstack/react-query';
import { getWaAnalytics } from '@/lib/serverComm';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';

export function AnalyticsTab() {
  const { data, isLoading } = useQuery({ queryKey: ['wa-analytics'], queryFn: getWaAnalytics, refetchInterval: 5 * 60_000 });
  if (isLoading || !data) return <div className="p-6 text-sm text-muted-foreground">Loading analytics…</div>;

  const sent      = data.byCategory.reduce((s: number, r: any) => s + Number(r.sent ?? 0), 0);
  const delivered = data.byCategory.reduce((s: number, r: any) => s + Number(r.delivered ?? 0), 0);
  const read      = data.byCategory.reduce((s: number, r: any) => s + Number(r.read ?? 0), 0);
  const deliveryRate = sent > 0 ? Math.round((delivered / sent) * 100) : 0;
  const readRate = delivered > 0 ? Math.round((read / delivered) * 100) : 0;

  return (
    <div className="h-full overflow-auto p-6 space-y-5 max-w-5xl mx-auto">
      <section className="border rounded-md p-4">
        <header className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">This month with Meta</header>
        <div className="text-2xl font-medium mb-1">PKR {data.monthCostPkr.toFixed(2)}</div>
        <div className="text-xs text-muted-foreground mb-4">spent on delivered messages this month</div>
        <table className="w-full text-xs">
          <thead className="text-muted-foreground border-b"><tr className="text-left"><th className="py-1">Category</th><th>Sent</th><th>Delivered</th><th>Read</th><th>Cost (PKR)</th></tr></thead>
          <tbody>
            {data.byCategory.map((r: any) => (
              <tr key={r.category} className="border-b last:border-0">
                <td className="py-1.5 capitalize">{r.category}</td>
                <td>{r.sent}</td>
                <td>{r.delivered}</td>
                <td>{r.read}</td>
                <td>{Number(r.cost ?? 0).toFixed(2)}</td>
              </tr>
            ))}
          </tbody>
        </table>
        <div className="text-[10px] text-muted-foreground mt-3">Rate card last verified: {data.lastUpdated}. <a className="underline" href="https://developers.facebook.com/documentation/business-messaging/whatsapp/pricing" target="_blank" rel="noreferrer">Verify against Meta</a>.</div>
      </section>

      <div className="grid grid-cols-2 gap-4">
        <Card label="Delivery rate (30d)" value={`${deliveryRate}%`} hint={`${delivered} of ${sent} delivered`} />
        <Card label="Read rate (30d)"    value={`${readRate}%`} hint={`${read} of ${delivered} read`} />
      </div>

      <section className="border rounded-md p-4">
        <header className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Busiest hours (last 30d, Asia/Karachi)</header>
        <ResponsiveContainer width="100%" height={180}>
          <BarChart data={data.busiestHours}>
            <XAxis dataKey="hour" tick={{ fontSize: 11 }} />
            <YAxis tick={{ fontSize: 11 }} />
            <Tooltip />
            <Bar dataKey="n" fill="#25D366" radius={[2, 2, 0, 0]} />
          </BarChart>
        </ResponsiveContainer>
      </section>
    </div>
  );
}

function Card({ label, value, hint }: { label: string; value: string; hint: string }) {
  return (
    <div className="border rounded-md p-4">
      <div className="text-[11px] text-muted-foreground uppercase tracking-wide">{label}</div>
      <div className="text-2xl font-medium mt-1">{value}</div>
      <div className="text-[11px] text-muted-foreground">{hint}</div>
    </div>
  );
}
  • Step 3: Typecheck + commit
cd ui && npx tsc --noEmit -p tsconfig.json && \
git add ui/src/components/whatsapp/QuickRepliesTab.tsx ui/src/components/whatsapp/AnalyticsTab.tsx && \
git commit -m "feat(whatsapp): quick replies + analytics tabs"

Task 14: Superadmin parity — WhatsApp clinics section

Files:
  • Modify: ui/src/components/superadmin/SuperadminModulesPanel.tsx
  • Create: server/src/routes/superadmin-whatsapp.ts (small endpoint)
  • Modify: server/src/api.ts
  • Step 1: Add the superadmin endpoint
server/src/routes/superadmin-whatsapp.ts:
import { Hono } from 'hono';
import { sql } from 'drizzle-orm';
import { getReadDb } from '../lib/db';
import { requireSuperadmin } from '../middleware/permissions';
import { handleError } from '../lib/errors';

const r = new Hono();
r.use('*', requireSuperadmin);

r.get('/clinics', async (c) => {
  try {
    const db = getReadDb();
    const rows = await db.execute(sql`
      SELECT
        cm.clinic_id,
        cl.name AS clinic_name,
        cm.is_enabled,
        cm.updated_at,
        (SELECT SUM(cost_pkr)::numeric FROM whatsapp_events e WHERE e.clinic_id = cm.clinic_id AND e.created_at >= NOW() - INTERVAL '30 days' AND e.type = 'delivered') AS cost_30d,
        (SELECT COUNT(*) FROM whatsapp_events e WHERE e.clinic_id = cm.clinic_id AND e.created_at >= NOW() - INTERVAL '30 days' AND e.type IN ('outbound_message', 'template_sent')) AS sends_30d
      FROM clinic_modules cm
      JOIN clinics cl ON cl.id = cm.clinic_id
      WHERE cm.module_key = 'whatsapp'
      ORDER BY cm.updated_at DESC
    `);
    return c.json({ clinics: rows.rows });
  } catch (err) {
    return handleError(c, err);
  }
});

export default r;
Register in api.ts near other superadmin routes:
import superadminWhatsappRoute from './routes/superadmin-whatsapp';
protectedRoutes.route('/superadmin/whatsapp', superadminWhatsappRoute);
  • Step 2: Add the panel section
In SuperadminModulesPanel.tsx, add a new section that fetches /api/v1/protected/superadmin/whatsapp/clinics and renders a table with: clinic name, enabled date, last 30d cost (PKR), last 30d sends. Match the existing panel’s table style (don’t reinvent).
  • Step 3: Typecheck + commit
cd server && npx tsc --noEmit 2>&1 | tail -3 && cd ../ui && npx tsc --noEmit -p tsconfig.json 2>&1 | tail -3
git add server/src/routes/superadmin-whatsapp.ts server/src/api.ts ui/src/components/superadmin/SuperadminModulesPanel.tsx
git commit -m "feat(whatsapp): superadmin parity — clinics table with 30d cost"

Task 15: Manual smoke + RELEASES + deploy

Files:
  • Modify: RELEASES.md
  • Step 1: Smoke checklist (manual, in dev)
cd ui && npm run dev
# open http://localhost:5173/dashboard?view=whatsapp
Verify:
  • Module shell renders, all 5 tabs clickable, settings cog links to /dashboard?view=settings&settingsView=whatsapp
  • Empty state shown when whatsapp_api module not enabled for the clinic
  • Conversations: list loads, click thread → messages render, patient panel populates
  • Composer: timer shows in correct color, free-text disabled when window red, template picker forced
  • Cost pill flips between Free / PKR x.xx as state changes
  • Events: types and groupings correct
  • Templates: cards render, send dialog opens
  • Quick replies: add/delete works; /-autocomplete in composer inserts body
  • Analytics: month cost matches sum from DB; charts render
  • Step 2: Append RELEASES entry
In RELEASES.md, above the most recent entry:
## [2026-05-13] — WhatsApp module v1: full inbox, costs, and event timeline

### What's new
- **WhatsApp is now a top-level module** in the sidebar with its own page at /dashboard?view=whatsapp. Five tabs: Conversations, Events, Templates, Quick replies, Analytics.
- **The composer enforces Meta's 24-hour window visually.** A green timer counts down free-reply time; turns amber in the last 2 hours; locks the message box and forces template selection when the window has closed. No more guessing whether a message will silently fail.
- **Every cost is shown inline.** Free-form replies inside the window show "Free message". Templates show "Marketing · PKR 13.50" or "Utility · PKR 2.85" — and "Free in window" when applicable. The Analytics tab shows the running monthly Meta bill, separate from your OdontoX subscription.
- **The Events tab is a complete activity timeline** — every inbound, outbound, delivery receipt, read receipt, template send, failure, and window state change is grouped by day and filterable by patient or event type.
- **Quick replies** with /-autocomplete: define snippets like /balance or /hours and inject them into the composer.

### Internal / Technical
- Module gated on `whatsapp_api` clinic_module + new `comms.whatsapp.*` permission keys.
- New tables: whatsapp_events (every inbound/outbound/delivery event), whatsapp_quick_replies. Pakistan rate card hardcoded with superadmin override; verify against Meta's pricing page.
- Webhook + outbound paths write event rows. A new cron task writes window_closed events idempotently.
- Doctor scope: doctors only see conversations with patients assigned to them via appointments.

### Affected areas
- UI: yes — new WhatsApp module, sidebar entry visible only when clinic has the add-on enabled.
- Backend: yes — new /protected/whatsapp-module API surface, migration 0034.
- Bridge: no
  • Step 3: Run odontox-commit-deploy skill
Invoke /odontox-commit-deploy to bundle, deploy server + UI, force-promote canonical, verify domains.

Self-review

Spec coverage: every section of 2026-05-13-whatsapp-module-design.md maps to a task —
  • §3 file map → Task 1–14 file targets ✓
  • §4 layout / §5 composer states / §6 cost surfaces → Task 9 + 10 ✓
  • §7.1–7.5 tabs → Tasks 10–13 ✓
  • §8 permissions → Task 6 ✓
  • §9 data model → Task 1 ✓
  • §10 API surface → Task 5 ✓
  • §11 UI principles → enforced inline (single accent color, no gradients, ambient timer, inline costs, no chrome emojis, 13/14/11px scale) ✓
  • §12 settings deep-link → Task 8 (header button + empty-state CTA) ✓
  • §13 superadmin parity → Task 14 ✓
  • §16 done criteria → Task 15 smoke checklist mirrors them ✓
  • §17 pricing-transparency footer → Task 8 module shell footer ✓
Placeholder scan: no “TBD” / “TODO” / “similar to”. One pragmatic concession in Task 12 (plain text Patient ID input, typeahead deferred to v1.5) — explicitly called out. Type consistency: WaWindowState, WaThread, WaEvent, WaTemplate, WaQuickReply, WaAnalytics defined in Task 7 are used unchanged in Tasks 8–13. priceForMessage / computeWindowState signatures defined in Task 2 are used as-is in Tasks 3, 4, 5. Helper name writeWhatsappEvent is reused unchanged across Tasks 3 and 4. Migration safety: Task 1 Step 4 explicitly gates production migration on user confirmation per the no_live_tenant_execution rule. No further fixes needed.