A full-stack chat prototype built for PharmaCorp's patient engagement platform. Patients on biologic therapy can ask common questions about their treatment — dosage timing, side effects, injection logistics, diet — and get instant, consistent answers, 24/7.
- System Diagram
- Data Flow
- Tech Stack
- Architectural Decisions
- Chatbot Persona
- Extra Features Implemented
- Security Considerations
- Setup & Run
- API Reference
┌─────────────────────────────────────────────────────┐
│ EXPO CLIENT │
│ │
│ ChatScreen │
│ └── useChat (hook — owns all state) │
│ ├── apiClient.ts ──► GET /api/messages/ │
│ └── wsClient.ts ──► WS /ws/{session_id} │
│ │
│ Components (all memoized, props-only): │
│ MessageBubble, TypingIndicator, │
│ ConnectionBanner, ChatInput │
└─────────────────┬───────────────────────────────────┘
│
REST on load │ WebSocket for live messages
│
┌─────────────────▼───────────────────────────────────┐
│ FASTAPI BACKEND │
│ │
│ chat_router.py ← transport only (HTTP/WS) │
│ │ │
│ ▼ │
│ chat_service.py ← orchestration, no I/O │
│ chatbot_service.py ← pure function, no I/O │
│ │ │
│ ▼ │
│ message_repository.py ← only layer touching DB │
│ │ │
│ ▼ │
│ domain/models.py ← SQLAlchemy ORM │
│ domain/schemas.py ← Pydantic in/out shapes │
└─────────────────┬───────────────────────────────────┘
│ asyncpg
┌─────────────────▼───────────────────────────────────┐
│ POSTGRESQL 16 (Docker) │
│ │
│ messages │
│ ├── id UUID PRIMARY KEY │
│ ├── session_id VARCHAR(255) [indexed] │
│ ├── role VARCHAR(10) CHECK ('user'|'bot') │
│ ├── content TEXT │
│ └── created_at TIMESTAMPTZ DEFAULT now() │
└─────────────────────────────────────────────────────┘
1. App opens
2. useChat calls apiClient.getMessages("default")
3. GET /api/messages/default → chat_router → chat_service → message_repository
4. If no history exists, a welcome message is seeded and persisted
5. Messages populate the FlatList
6. useChat immediately opens a WebSocket connection
The REST call and the WebSocket connection start in sequence — history first, then live. This avoids a race condition where a message could arrive over WebSocket before history loads.
1. User types → sendMessage("Can I drink coffee?")
2. Message appended to local state immediately (optimistic UI)
3. wsClient sends { "content": "Can I drink coffee?" }
4. Rate limit check — if < 0.5s since last message, error returned, message dropped
5. Server validates via Pydantic WSMessageIn (empty check, max 2000 chars)
6. Single DB session opens
→ user message persisted
→ server sends { "type": "typing" } → client shows animated dots
→ chatbot_service.respond() runs — pure function, no I/O
→ bot message persisted
→ session commits and closes
7. Server sends { "type": "message", ...bot_msg }
8. Client hides typing indicator → appends bot message
Both the user message and bot response are written in the same DB session. If anything fails between the two saves, neither commits — no orphaned user messages with missing replies.
Connection drops
→ status = "disconnected"
→ retry after 1s → 2s → 4s → ... (doubles each time, caps at 30s)
→ reconnected → status = "connected" → retry delay resets to 1s
| Layer | Choice | Why |
|---|---|---|
| Frontend | React Native (Expo SDK 52) | Required. Single codebase for iOS, Android, and web |
| Frontend language | TypeScript | Catches type mismatches between WS event shapes and component props at compile time |
| Backend | Python + FastAPI | Native async WebSocket support, Pydantic validation built in, no extra WS library needed |
| Database | PostgreSQL 16 | UUID primary keys, timezone-aware timestamps, reliable async driver (asyncpg) |
| ORM | SQLAlchemy 2.0 async | Type-safe mapped columns, async session management |
| Containerisation | Docker Compose | Single command to start DB and backend, reproducible across machines |
The app makes one REST call on load to fetch conversation history, then switches to WebSocket for everything new. This is intentional.
REST is the right tool for history: it's straightforward to reason about, the response is complete and ordered, and it works reliably even before the WebSocket handshake completes. WebSocket is the right tool for live chat: low latency, bidirectional, no polling.
Using WebSocket for history too would mean handling ordering, completeness guarantees, and connection timing all at once. Not worth it.
WebSocket connections are long-lived. A single shared SQLAlchemy async session held open for the duration of a connection would accumulate state, hold a connection pool slot indefinitely, and behave unpredictably under concurrent load.
Each incoming message opens one session, does both writes inside it, and closes:
async with SessionLocal() as db:
service = ChatService(repository=MessageRepository(db), chatbot=DrSquigglesChatbotService())
await service.handle_user_message(session_id, incoming.content)
await send({"type": "typing"}) # WebSocket send — no DB session needed
bot_msg = await service.generate_bot_response(session_id, incoming.content)
# session commits and closes — pool slot returned
The typing signal fires between the two saves because it is just a WebSocket send — it does not need a DB session. If the process dies after the user message saves but before the bot response commits, the session rolls back and neither write persists. No orphaned messages.
No rate limiting means a script — or an impatient user — can flood the endpoint with unlimited DB writes and bot responses. The router tracks the last message timestamp per connection using time.monotonic(). Messages arriving within 0.5s of the previous one get an error event back and are dropped — no DB write, no bot response generated. Per-connection means legitimate concurrent users are unaffected by each other.
All SQLAlchemy lives in message_repository.py. Nothing else imports it. ChatService depends on AbstractMessageRepository, not the PostgreSQL implementation.
The service layer is testable without a database (swap in an in-memory implementation), and the DB can be swapped without touching service logic. For a prototype these are not urgent — but the cost of doing it right here was minimal and the structure pays off fast when requirements change.
DrSquigglesChatbotService.respond() takes a string and returns a string. No DB session, no network call, no file read. This means:
- Trivially testable: call
respond("I feel nauseous"), assert on the output - Adding a new topic means adding entries to
_TOPIC_KEYWORDSand_RESPONSES— therespond()logic itself never changes - Swapping to an LLM-backed chatbot means implementing
AbstractChatbotServiceand changing one line in the router. Nothing else changes.
When the user sends a message, it is appended to local state immediately before the server responds. The server persists the user message but does not echo it back — it only sends the bot reply.
This avoids duplicates (the user message is already in local state), makes the app feel instant, and keeps the WebSocket protocol simple. If the server rejects the message, the optimistic entry is rolled back via the error event type.
All state lives in useChat.ts: message list, connection status, typing indicator, retry logic. Components receive props only — no knowledge of WebSocket or REST. This makes the transport layer easy to reason about in isolation and components trivial to test.
Dr. Squiggles is an overly enthusiastic goldfish MD who exclusively answers Crohn's Disease and biologic treatment questions. Every response uses goldfish analogies and disproportionate excitement.
User message
│
▼
Layer 1 — URGENCY CHECK (always first, character dropped entirely)
│
│ Keywords: "emergency", "severe pain", "bleeding", "hospital",
│ "chest pain", "can't breathe", "faint", "ambulance"
│
match? ──YES──► Plain language. Direct to emergency services (112/911).
│ No goldfish puns. No exceptions.
NO
│
▼
Layer 2 — TOPIC KEYWORD MATCH
│
match? ──YES──► Random response from that topic pool (in character)
│
NO
│
▼
Layer 3 — IN-CHARACTER DEFLECT (catches everything else)
"Blub blub! That's outside my fishbowl! Dr. Squiggles only swims
in Crohn's, biologics, medication, diet, and treatment waters."
Layer 1 exists because this is a patient-facing medical app. A user typing "I'm in severe pain" should not get goldfish puns — they need to be directed to emergency services clearly and immediately. Layer 3 is the guardrail for everything else: the bot either answers a health question or redirects in character. It cannot be pulled into general conversation regardless of what the user sends.
| Keywords matched | Topic |
|---|---|
| emergency, severe pain, bleeding, hospital, chest pain... | Urgency — character dropped |
| hello, hi, hey, howdy | Greeting |
| dose, dosage, forgot, miss, skip, late | Missed / late doses |
| side effect, nausea, headache, fatigue, fever | Side effects |
| food, eat, drink, coffee, alcohol, diet, dairy | Diet and lifestyle |
| inject, injection, needle, pen, syringe, fridge | Injection logistics |
| crohn, flare, symptom, bowel, ibd, remission | General Crohn's info |
| anything else | Fishbowl deflection |
Welcome message on first load — new sessions are seeded with a welcome message from Dr. Squiggles on the first REST call. Persisted to the DB so it appears in all subsequent history loads. No user ever sees an empty screen.
Connection status indicators — three states, rendered in real time. connecting: yellow banner. connected: green dot, no banner. disconnected: red banner with auto-retry. Input is disabled when not connected.
Typing indicator — animated dots appear between the user sending and the bot responding, driven by the { "type": "typing" } WebSocket event the server sends before generating a response.
Exponential backoff reconnect — retries at 1s, 2s, 4s, doubling each time up to a 30s cap. Resets to 1s on successful reconnect.
Optimistic UI — user messages appear instantly without waiting for server confirmation. Makes the app feel native on slow connections.
Per-connection rate limiting — WebSocket endpoint throttled to one message per 0.5s per connection. Messages arriving faster than that get an error event back — no DB write, no bot response.
FlatList performance — removeClippedSubviews, maxToRenderPerBatch, windowSize tuned. renderItem and keyExtractor wrapped in useCallback to prevent unnecessary re-renders on every state update.
Input validation — Pydantic validates every incoming WebSocket message server-side before any DB write: non-empty, max 2000 characters. Invalid messages get an error event back rather than a silent failure.
DB-level role constraint — CHECK (role IN ('user', 'bot')) enforced at the PostgreSQL level, not just in application code. Invalid values cannot reach the table regardless of how data gets written.
Stale connection detection — pool_pre_ping=True on the SQLAlchemy engine. Detects dead DB connections after a container restart instead of failing silently on the next query.
Implemented:
| Measure | Where |
|---|---|
CORS origins from CORS_ORIGINS env var |
main.py — defaults to * in dev, restrict in prod by setting the env var |
| Input validation — non-empty, max 2000 chars | schemas.py — Pydantic WSMessageIn, runs before any DB write |
| WebSocket payload validation — malformed JSON returns error event | chat_router.py — connection stays open, error event returned |
| Per-connection rate limiting — 0.5s between messages | chat_router.py — time.monotonic() check, no DB write on breach |
| Parameterised queries | All DB access via SQLAlchemy ORM — no raw SQL |
| DB-level role constraint | models.py — CheckConstraint enforced in PostgreSQL |
Production additions (not implemented, documented):
WebSocket authentication — issue a short-lived signed JWT on login, validate in a FastAPI dependency before accepting the connection. The session_id comes from the token payload, not the URL — prevents a user accessing another patient's session by changing the path:
@router.websocket("/ws/{session_id}")
async def ws_endpoint(
websocket: WebSocket,
session_id: str = Depends(verify_ws_token),
):
...
Transport — HTTPS/WSS via reverse proxy (nginx, Caddy). Plain HTTP/WS rejected at the proxy level.
Global rate limiting — the current per-connection throttle handles individual abuse. Production would add global rate limiting via Redis or API gateway for coordinated traffic.
Session isolation — already scoped by session_id in every DB query. In production this maps to the JWT subject, giving per-patient data isolation with no changes to the repository layer.
- Docker + Docker Compose
- Node.js 18+
- Expo CLI:
npm install -g expo-cli
git clone <your-repo-url>
cd HealthChatdocker-compose up --buildThis starts PostgreSQL on port 5432 and the FastAPI backend on port 8000. Database tables are created automatically on startup via SQLAlchemy's init_db().
First run after pulling latest changes? Drop the existing volume so the DB recreates with the correct schema including the
CHECKconstraint onrole:docker-compose down -v && docker-compose up --build
curl http://localhost:8000/api/messages/default
# Returns the seeded welcome message from Dr. Squiggles
# Interactive API docs
open http://localhost:8000/docsThe client auto-detects the platform — no manual changes needed for simulators or web:
- iOS simulator / web — uses
localhostautomatically - Android emulator — uses
10.0.2.2automatically - Physical device — open
client/src/constants/config.ts, setMANUAL_IPto your machine's local IP
Find your IP:
# macOS
ipconfig getifaddr en0
# Linux
hostname -I | awk '{print $1}'
# Windows (PowerShell)
(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -notmatch 'Loopback' }).IPAddresscd client
npm install# Web (instant, no setup needed)
npx expo start --web
# iOS Simulator (requires Xcode)
npx expo start --clear
# press 'i'
# Physical device via Expo Go (same WiFi network)
npx expo start --lan
# scan the QR code in the Expo Go appGET /api/messages/{session_id}
Returns full conversation history ordered by created_at ASC.
Seeds a welcome message if the session has no prior history.
Response 200:
[
{
"id": "uuid",
"session_id": "default",
"role": "user" | "bot",
"content": "string",
"created_at": "2024-01-01T00:00:00Z"
}
]
Connect: ws://localhost:8000/ws/{session_id}
Client → Server:
{ "content": "Can I drink coffee?" }
Server → Client:
{ "type": "typing" }
{ "type": "message", "id": "uuid", "role": "bot", "content": "...", "created_at": "..." }
{ "type": "error", "detail": "Content cannot be empty" }
{ "type": "error", "detail": "Slow down! Wait 0.3s before sending again." }
Copy .env.example to .env:
cp .env.example .env| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
`postgresql+asyncpg://:@db:5432/chatdb | |
| ` | Async DB connection string passed to SQLAlchemy | |
CORS_ORIGINS |
* |
Comma-separated allowed origins. In production: https://yourapp.com |