ClinicBot · Build Crew · Architecture

The hotel switchboard.

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.

// 01 — The core loop

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.

Patient (WhatsApp) → webhook /api/webhooks/whatsappTenantRouter resolves phone_number_id → clinic_idPatientDirectory find-or-create → LlmProvider intent + language → BookingService idempotent write → TranslatorMessagingChannel 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.

// 02 — The five vectors → folders

Each ownership vector maps to a module with one clear purpose.

V1 · Bot brain

modules/bot-brain

Intent, dialog state, the core loop, no-show rescue.

V2 · Language

modules/language

Gemini & Sarvam adapters, transliteration, voice later.

V3 · Dashboard

app/(dashboard)

Doctor web app — calendar, analytics, FAQ setup.

V4 · Integrations

modules/integrations · booking adapters

WhatsApp, Practo, UPI behind ports.

V5 · Glue / infra

modules/tenancy · server/*

Tenant router, DB, config, composition root.

core

modules/booking · patients

The booking layer we own — shared by all.

// 03 — Ports & adapters (the swap-ability)

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 adapterStub / fake default
ClinicResolverDrizzleClinicResolverInMemoryClinicResolver
LlmProviderGeminiLlmProviderFakeLlmProvider
TranslationProviderSarvamTranslationProviderIdentityTranslationProvider
CalendarProviderPractoCalendarProviderInternalCalendarProvider
BookingRepositoryDrizzleBookingRepositoryInMemoryBookingRepository
MessagingChannelWhatsAppMessagingChannelInMemoryMessagingChannel

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.

// 04 — Data model — one table per idea

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()).

// 05 — The stack

Next.js 16 · App Router TypeScript Supabase Postgres + Auth + RLS Drizzle ORM Upstash Redis Zod Vitest WhatsApp Cloud API Gemini Flash Sarvam AI

One repo: webhook API and the doctor dashboard. If a layer doesn't save us a week, it doesn't ship.

// 06 — Where things live

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