ClinicBot · Build Crew · Architecture
Many clinics. Many numbers. One brain. Each clinic keeps its own WhatsApp number; they all route into a single codebase that wears whichever clinic's identity is calling. Adding a clinic is a row in a table — not a new server.
Follow one message through the whole system. This single round-trip
is the product; everything else is a feature hanging off it. It
lives as one use case: handleInboundMessage.
/api/webhooks/whatsapp
→ TenantRouter resolves phone_number_id → clinic_id
→ PatientDirectory find-or-create → LlmProvider intent +
language → BookingService idempotent write → Translator →
MessagingChannel reply, in their language.
Every arrow above crosses an interface (port), never a
vendor SDK. That is what makes the loop fully unit-testable with fakes —
see src/modules/bot-brain/handle-inbound-message.test.ts.
Each ownership vector maps to a module with one clear purpose.
Intent, dialog state, the core loop, no-show rescue.
Gemini & Sarvam adapters, transliteration, voice later.
Doctor web app — calendar, analytics, FAQ setup.
WhatsApp, Practo, UPI behind ports.
Tenant router, DB, config, composition root.
The booking layer we own — shared by all.
Domain code depends on the interface; vendors are
adapters chosen in one file (src/server/composition.ts).
Swap Sarvam for another model = one new adapter, zero domain changes.
| Port (domain owns) | Real adapter | Stub / fake default |
|---|---|---|
ClinicResolver | DrizzleClinicResolver | InMemoryClinicResolver |
LlmProvider | GeminiLlmProvider | FakeLlmProvider |
TranslationProvider | SarvamTranslationProvider | IdentityTranslationProvider |
CalendarProvider | PractoCalendarProvider | InternalCalendarProvider |
BookingRepository | DrizzleBookingRepository | InMemoryBookingRepository |
MessagingChannel | WhatsAppMessagingChannel | InMemoryMessagingChannel |
The selection rule: use the real adapter when its credentials exist, else fall back to a credential-free stub. So the repo builds, tests, and runs the whole loop with zero secrets.
Simple shapes, one hard rule: every tenant row carries
clinic_id, and every query is scoped to it. A clinic
literally cannot see another's patients.
clinics ──1:∞── phone_numbers (phone_number_id → clinic_id : the router)
│
├──1:∞── doctors
├──1:∞── patients ──1:∞── conversations
└────────────────────────── bookings (unique: clinic_id + idempotency_key)
Isolation is enforced in two layers: application
(scoped helpers) and database (Row-Level Security,
keyed on current_setting('app.current_clinic_id') — see
drizzle/0001_rls.sql and runWithClinic()).
One repo: webhook API and the doctor dashboard. If a layer doesn't save us a week, it doesn't ship.
src/
modules/ each = one bounded context, one owner
tenancy/ V5 — phone_number_id → clinic_id
bot-brain/ V1 — core loop, intents, dialog
language/ V2 — LLM + translation ports & adapters
booking/ core — idempotent bookings, calendar port
patients/ core — patient directory
integrations/ V4 — messaging (WhatsApp) etc.
server/
config/env.ts the ONLY reader of process.env
db/ drizzle schema, client, tenant RLS helper, seed
supabase/ auth clients (server + browser)
composition.ts composition root — picks real vs stub adapters
app/
api/webhooks/whatsapp/ the inbound front door
(dashboard)/ V3 — doctor dashboard