Skip to content

Architecture Overview

import { Badge } from ‘@astrojs/starlight/components’;

End-to-end system design, multi-tenancy model, and request lifecycle.

Last reviewed: 2026-06-23


graph TB
subgraph Clients["🌐 Clients"]
Browser["Browser (SPA)"]
PWA["PWA (installed)"]
WhatsApp["WhatsApp (patient)"]
end
subgraph Railway["🚂 Railway Platform"]
FE["Frontend Service<br/>React 18 + Vite + Caddy<br/>:80 → dist/*"]
BE["Backend Service<br/>Node.js 22+ / Express 4<br/>:3001 → /api/* /webhooks/*"]
end
subgraph Supabase_["🗄️ Supabase"]
PG[("PostgreSQL<br/>+ RLS")]
Auth["Auth Service<br/>ES256 JWTs"]
end
subgraph Redis_["⚡ Redis (Railway)"]
BQ["BullMQ Queues"]
Cache["Cache + Locks"]
end
Browser -->|HTTPS| FE
PWA -->|HTTPS| FE
FE -->|HTTPS /api/*| BE
WhatsApp -->|Messages| MetaCloud["Meta Cloud API"]
MetaCloud -->|POST /webhooks/whatsapp| BE
BE --> PG
BE --> BQ
BE --> Cache
BE -->|Claude Haiku| Anthropic["Anthropic API"]
BE -->|Payment webhooks| LS["LemonSqueezy"]
BE --> Sentry
FE --> Sentry
BQ -.->|Delayed jobs| BE
style Clients fill:rgba(5,150,105,0.13),stroke:#059669
style Railway fill:rgba(5,150,105,0.13),stroke:#059669
style Supabase_ fill:rgba(5,150,105,0.13),stroke:#059669
style Redis_ fill:rgba(5,150,105,0.13),stroke:#059669
style BE fill:rgba(5,150,105,0.25),stroke:#059669
style FE fill:rgba(5,150,105,0.25),stroke:#059669
style PG fill:rgba(5,150,105,0.13),stroke:#059669
style BQ fill:rgba(5,150,105,0.13),stroke:#059669
style Cache fill:rgba(5,150,105,0.13),stroke:#059669

Every piece of data is scoped to a clinic_id. Three enforcement layers:

  1. JWT payloadclinicId embedded in every token; backend reads from req.user.clinicId.
  2. Application layer — Every DB query explicitly filters .eq('clinic_id', req.user.clinicId). No exceptions. Automated regression guards (tenantScopingGuard.test.js) catch any miss.
  3. Row-Level Security (RLS) — Supabase policies at the database level using app.clinic_id session variable. Safety net, not primary enforcement.

Two Supabase clients support this pattern:

Client Key RLS When to use
supabase (req-scoped req.db) Publishable/anon Enforced All user-scoped reads
supabaseAdmin Service role Bypassed Writes with code-enforced tenancy, background jobs, bootstrap

Middleware chain on every protected route:

Request → authenticateToken → requireActiveSubscription
→ [requireAdmin] → [checkDoctorLimit | checkUserLimit | requirePlan] → handler

User login:

POST /api/auth/login (email, password)
→ bcrypt.compare → check is_active (user + clinic)
→ build ES256 JWT { userId, clinicId, email, role, subscriptionPlan, subscriptionExpiresAt }
→ Frontend stores token in sessionStorage
→ Subsequent requests: Authorization: Bearer <token>
→ Every request: requireActiveSubscription live DB check
sequenceDiagram
actor User as Staff User
participant FE as Frontend (React)
participant BE as Backend (Express)
participant DB as Supabase (PostgreSQL)
participant JWT as JWT Service
User->>FE: Enter email + password
FE->>BE: POST /api/auth/login
BE->>DB: SELECT user WHERE email = ?
BE->>BE: bcrypt.compare(password, hash)
BE->>DB: SELECT clinic WHERE id = ?
BE->>DB: Check user.is_active AND clinic.is_active
BE->>JWT: Build ES256 JWT (userId, clinicId, role, plan)
JWT-->>BE: Signed token
BE-->>FE: { token, user: {...} }
FE->>FE: Store in sessionStorage
Note over FE,BE: All subsequent requests include Authorization: Bearer <token>

WhatsApp appointment booking:

Patient sends WhatsApp → Meta Cloud API POST /webhooks/whatsapp
→ HMAC-SHA256 verify (timingSafeEqual)
→ 24h dedup check (Redis)
→ Route to clinic by phone_number_id
→ Acquire concurrency lock per (clinicId, patientPhone)
→ Load/create chat_session (state machine context)
→ Safety checks: bot_enabled? is_blocked? human_handover?
→ Short-circuit known button IDs (CONFIRM_BOOKING, RESET, dental recall replies)
→ Claude Haiku NLU with clinic context + real availability slots
→ JSON response: { action, collected, readyToConfirm, reply }
→ If readyToConfirm: verify slot → insert appointment → schedule reminders → send card
→ Otherwise: send clarifying text reply
→ Release concurrency lock

Three BullMQ queues, all backed by a shared Redis instance. All cron schedules in UTC (El Salvador = UTC-6, no DST).

Queue Workers Purpose
appointment-reminders 5 24h/2h reminder messages, attendance verification
daily-agenda 2 Morning schedule to clinic staff
dental-messages 3 Payment reminders, recall campaigns
gantt
title Job Schedule (24h UTC Cycle)
dateFormat HH:mm
axisFormat %H:%M
section Agenda
Daily Agenda (13:00 UTC) :milestone, 13:00, 0m
section No-Show
Auto-Mark (05:00 UTC) :milestone, 05:00, 0m
section Attendance
Dispatch (every 30min) :active, 00:00, 24h
section Payments
Reminders (14:00 UTC) :milestone, 14:00, 0m
section Recall
Recall (06:00 UTC) :milestone, 06:00, 0m

Bull Board admin dashboard at /admin/queues (basic auth via QUEUES_ADMIN_USER/QUEUES_ADMIN_PASS).

Layer Mechanism
CORS Locked to FRONTEND_URL env var (no wildcard)
HTTP headers Helmet on all responses
Rate limiting Per-IP on auth, demo requests, calendar feeds; per-phone on WhatsApp
Encryption at rest AES-256 for WhatsApp tokens and patient DUI
Webhook verification HMAC-SHA256 (Meta), signing secret (LemonSqueezy)
Superadmin auth Separate X-Admin-Key header, never mixed with JWT routes
Auth password bcrypt cost factor 12
JWT ES256 (P-256 asymmetric), 8-hour expiry, live DB check on every request
Tenant isolation App-layer filter + RLS safety net + automated regression guards
Technology Version Purpose
Node.js 22+ Backend runtime
Express 4.18 HTTP framework
React 18.2 UI library
Vite 5+ Build tool
Supabase JS 2.39 DB client
BullMQ 5.1 Job queues
ioredis 5.3 Redis client
@anthropic-ai/sdk 0.100+ Claude Haiku
jsonwebtoken 9.0 JWT
bcryptjs 2.4 Password hashing
TanStack Query 5.17 Server state
lucide-react 0.323 Icons
Vitest 4.1 Testing
TypeScript 5.x Incremental migration