Skip to content

Architecture

Doug edited this page Aug 8, 2026 · 2 revisions

Architecture

A high-level map of how GDX Dispatch fits together. It's a single-tenant, self-hosted app: one instance serves one business.

Container topology

                        ┌──────────────────────────┐
   browser  ──HTTPS──►  │  app (FastAPI + Vue SPA)  │  :8001 → :8000
                        │  uvicorn gdx_dispatch.main │
                        └───────┬───────────┬────────┘
                                │           │
                   ┌────────────▼──┐   ┌────▼──────────┐
                   │  db (Postgres)│   │  redis        │
                   │  one database │   │  auth / cache │
                   │  "gdx"        │   │  celery broker│
                   └────────────▲──┘   └────▲──────────┘
                                │           │
              ┌─────────────────┴───────────┴───────────────┐
              │  celery-high   celery-low   celery-beat      │  background jobs
              └──────────────────────────────────────────────┘

   (optional)  plugin-host  ◄── /api/plugins/* proxied from app   [see Authoring Plugins]

The Vue 3 SPA is built by Vite and served from the same origin as the API in Docker — there's no separate web container.

The pieces

  • app — FastAPI (gdx_dispatch.main:app) on uvicorn. Serves the API and the built SPA. On boot its entrypoint runs alembic upgrade head then a bootstrap that seeds the default tenant + admin (idempotent).

  • db — a single PostgreSQL 16 database, gdx. It holds both:

    • control-plane tables — tenants, tenant_module_grants, platform_feature_flags, service_accounts, tenant_settings, …
    • data-plane tables — customers, jobs, work-orders, estimates, invoices, users, company_module_grants, …

    (The control/data split is logical, from the multi-tenant era; single-tenant collapse keeps them in one database. The SaaS-facing control-plane surfaces — platform analytics, status page, developer-portal UI, the bare /v1 public API — were removed outright in v1.38.0; the tables remain as the tenant/settings substrate.)

  • redis — JWT refresh-token families + denylist, idempotency keys, rate limiting, and the Celery broker/result backend.

  • celery-high / celery-low — priority worker queues (e.g. user-facing vs batch). celery-beat — the scheduler for periodic jobs.

  • plugin-host (optional) — runs third-party plugins, isolated from app; the core proxies /api/plugins/* to it. See Authoring Plugins.

Single-tenant model

TenantMiddleware pins every request to the one tenant identified by GDX_TENANT_ID. Data rows still carry company_id (so the schema is unchanged from its multi-tenant origins), but there is no per-tenant DB resolution — the connection is the tenant boundary.

Auth & access

  • JWT (RS256 preferred, HS256 fallback) issued at /auth/login; refresh + denylist via Redis.
  • Rolesowner > admin > dispatcher > technician > viewer.
  • Module grants — features are gated per tenant by company_module_grants (require_module(...)), which is also what plugins use.

Request lifecycle (typical)

  1. SPA calls the API with a Bearer JWT.
  2. Middleware sets the tenant context, enforces rate limits / idempotency.
  3. The router resolves auth + role + module gates, then does its work against the gdx database.
  4. Long-running work is handed to Celery (via Redis) and processed by a worker.

Migrations

Schema lives in Alembic (gdx_dispatch/migrations) with a squashed baseline. The app container migrates on boot under a Postgres advisory lock, so exactly one migrator runs even if several containers start together.

See also

Clone this wiki locally