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
File map (locks decomposition)
| Action | Path | Task |
|---|---|---|
| Create | server/drizzle/0034_whatsapp_module.sql | 1 |
| Create | server/src/schema/whatsapp_events.ts | 1 |
| Create | server/src/schema/whatsapp_quick_replies.ts | 1 |
| Modify | server/src/schema/index.ts | 1 |
| Create | server/src/lib/whatsapp-pricing.ts | 2 |
| Modify | server/src/routes/whatsapp-webhook.ts | 3 |
| Modify | server/src/lib/whatsapp.ts | 4 |
| Modify | server/src/scheduled.ts | 4 |
| Create | server/src/routes/whatsapp.ts | 5 |
| Modify | server/src/api.ts | 5 |
| Modify | ui/src/lib/permissionTree.ts | 6 |
| Modify | ui/src/components/layout/AppLayout.tsx | 6 |
| Modify | ui/src/lib/serverComm.ts | 7 |
| Create | ui/src/components/whatsapp/WhatsAppModule.tsx | 8 |
| Create | ui/src/components/whatsapp/composer/WindowTimer.tsx | 9 |
| Create | ui/src/components/whatsapp/composer/CostPill.tsx | 9 |
| Create | ui/src/components/whatsapp/composer/Composer.tsx | 9 |
| Create | ui/src/components/whatsapp/PatientPanel.tsx | 9 |
| Create | ui/src/components/whatsapp/ConversationsTab.tsx | 10 |
| Create | ui/src/components/whatsapp/EventsTab.tsx | 11 |
| Create | ui/src/components/whatsapp/TemplatesTab.tsx | 12 |
| Create | ui/src/components/whatsapp/QuickRepliesTab.tsx | 13 |
| Create | ui/src/components/whatsapp/AnalyticsTab.tsx | 13 |
| Modify | ui/src/components/superadmin/SuperadminModulesPanel.tsx | 14 |
| Modify | RELEASES.md | 15 |
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
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)
cd server && npx wrangler d1 --remote-no-thx execute || \
npx drizzle-kit push --config drizzle.config.ts
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)"
- 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
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');
"
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)
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
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 },
});
}
gte and desc from drizzle-orm if not already imported.
- Step 3: Find the status-update handler (delivered / read / failed) and add event writes
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 ?? [] },
});
}
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"
- 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
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
});
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.
});
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';
}
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
server/src/scheduled.ts, add the import at the top with the other scheduled imports:
import { handleWhatsappWindowCloser } from './scheduled/whatsapp-window-closer';
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"
- 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;
}
// 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),
});
});
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
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);
/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"
- 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
AppLayout.tsx or a sibling nav.ts). Add a new entry there.
- Step 2: Add the WhatsApp nav entry
{
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',
},
- Step 3: Register permission keys in
permissionTree.ts
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
- 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
- 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=whatsappto 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
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
// 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
- 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>
);
}
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
- 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
/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
- 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>
);
}
- 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;
api.ts near other superadmin routes:
import superadminWhatsappRoute from './routes/superadmin-whatsapp';
protectedRoutes.route('/superadmin/whatsapp', superadminWhatsappRoute);
- Step 2: Add the panel section
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
- Module shell renders, all 5 tabs clickable, settings cog links to /dashboard?view=settings&settingsView=whatsapp
-
Empty state shown when
whatsapp_apimodule 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
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
/odontox-commit-deploy to bundle, deploy server + UI, force-promote canonical, verify domains.
Self-review
Spec coverage: every section of2026-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 ✓
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.
