Multi-tenant SaaS where organizations embed a chat widget on their websites that answers questions based on their uploaded documents (RAG).
Built with NestJS monorepo, Drizzle ORM, PostgreSQL + pgvector, BullMQ, OpenRouter.ai, and Cloudflare R2.
| Layer | Choice |
|---|---|
| Runtime | Node.js 24, TypeScript 6.0 |
| Monorepo | NestJS built-in (nest generate app) |
| API | NestJS (Express) |
| ORM | Drizzle |
| Database | PostgreSQL 16 + pgvector |
| Queue | BullMQ + Redis 7 |
| Embeddings | all-MiniLM-L6-v2 (Q8) via @huggingface/transformers |
| LLM Provider | OpenRouter.ai |
| File Storage | Cloudflare R2 (S3-compatible) |
| Auth | JWT (dashboard) + API Keys (widgets) |
| Multi-tenancy | Shared DB + organization_id column |
| Streaming | SSE (Server-Sent Events) |
| Container | Docker Compose (base + prod overrides) |
- Docker + Docker Compose (for Postgres+pgvector + Redis)
- Node.js 24
npm install
docker compose up -d # start PG + Redis
npm run start:api:dev # API on :3000
npm run start:worker:dev # BullMQ worker| Command | Description |
|---|---|
npm run build api |
Build API app |
npm run build worker |
Build Worker app |
npm run start:api:dev |
API watch mode |
npm run start:worker:dev |
Worker watch mode |
npm run test |
Unit tests |
npm run test:e2e:api |
API e2e tests |
npm run test:e2e:worker |
Worker e2e tests |
apps/api/ — HTTP API (NestJS)
apps/worker/ — BullMQ worker (NestJS, no HTTP)
libs/database/ — Drizzle schemas + client; shared queries (countReadyDocuments) (@app/database)
libs/queue/ — BullMQ config + job types (@app/queue)
libs/ai/ — OpenRouter client + local embeddings (@app/ai)
libs/storage/ — R2 client (@app/storage)
libs/config/ — @nestjs/config, env validation (@app/config)
libs/common/ — Guards, decorators, DTOs (@app/common)
Postman collection at postman_collection.json covering all endpoints.
Auth: POST /auth/register, POST /auth/login, GET/POST /auth/api-keys, DELETE /auth/api-keys/:id
API keys are project-scoped: POST /auth/api-keys requires a projectId, and the key resolves its org/project on the widget face.
Users: GET /users/me, PATCH /users/me
Organizations: GET/POST /organizations, GET/PATCH/DELETE /organizations/:id, GET/POST /organizations/:id/members, PATCH/DELETE /organizations/:id/members/:userId
Projects: GET/POST /organizations/:orgId/projects, GET/PATCH/DELETE /organizations/:orgId/projects/:id — PATCH with { widget } updates the widget appearance config (free-form object).
Documents (JWT, project-scoped): organizations/:orgId/projects/:projectId/documents
GET /list (paged) ·POST /multipart uploadPOST /signed-url→ presigned PUT URL (primary upload path)POST /:id/complete→ verify upload + enqueue ingest (idempotent)GET /:id·DELETE /:id
Projects (JWT, org-scoped): organizations/:orgId/projects
GET /:id/events→ SSE stream of project updates via Redis pub/sub on channelproject:<projectId>. Emitsevent: ready-doc-count({ count }, current number ofreadydocuments) immediately on connect, then relays pubSub{ event, data }payloads verbatim (currentlyevent: document-statuswith{ documentId, status, chunkCount?, readyDocumentCount }:processing,ready,failed).readyDocumentCountis computed by the worker onready/failed; document deletes republishready-doc-countwith the post-delete count. Stays open until the client disconnects.
Chat widget face (API key, X-API-Key header):
POST /chat/conversations{ sessionId }·GET /chat/conversations/:id/messages·POST /chat/conversations/:id/messages{ content }→ SSE stream
Conversations dashboard face (JWT): organizations/:orgId/projects/:projectId/conversations
POST /create ·GET /list (paged, with message counts) ·GET /:iddetail + messages ·GET /:id/messages·POST /:id/messages→ SSE stream ·DELETE /:id(admin)
Both message-SSE endpoints guard on the project's ready-document count (countReadyDocuments): if zero, they emit a single error SSE event and persist nothing.
Users ↔ Organizations (M:N via organization_users). Organizations have Projects. All scoped entities carry organization_id + project_id.
- All DB tables use
organization_idfor multi-tenancy (nevertenant_id) - Drizzle for all DB access, TypeScript-native
- Cloudflare R2 via S3 SDK (
@aws-sdk/client-s3) - SSE for chat streaming
- BullMQ for async RAG jobs
- OpenRouter.ai for embeddings + chat
@app/*path aliases for all libraries- TypeScript 6.0,
moduleResolution: nodenext
Phases tracked in DESIGN_TODO.md. See AGENTS.md for the full design, schema, and implementation reference.