apps/api/¶ FastAPI backend
REST + Socket.IO + queue producer; source of truth. Contains SQLAlchemy models, Alembic migrations, services, REST routes, Socket.IO integration, and pytest tests under apps/api/tests.
Contributor conventions
Repository guidelines and the architecture rules that span multiple files. ReplyBox is a multi-tenant shared inbox SaaS for WhatsApp (and future channels), built as a monorepo of three deployable units plus an infra folder.
Project structure & module organization
Status is tracked in docs/plan.md. Through Phase 5 is shipped (auth, RBAC, channel pairing, inbound inbox, web UI). Phase 6 (outbound send) is the recommended next slice.
apps/api/REST + Socket.IO + queue producer; source of truth. Contains SQLAlchemy models, Alembic migrations, services, REST routes, Socket.IO integration, and pytest tests under apps/api/tests.
apps/web/Routes in app, shared UI in components, hooks in hooks, state stores in stores, and API utilities in lib.
apps/baileys-connector/The Node.js WhatsApp adapter. Disposable and restart-safe.
infra/docker/Postgres / Redis / MinIO compose stack. Currently blocked locally; see the infra caveat below.
docs/docs/plan.md is the phased roadmap. Read it before starting non-trivial work.
Build, test, and development commands
Node 20+, Python 3.11+, pnpm 9+ required. Per-app commands run from the app's directory.
./dev.sh # start everything: infra + api + web + connector
docker compose -f infra/docker/compose.yaml up -d # just infra
API setup: python -m venv .venv && source .venv/bin/activate && pip install -e ".[dev]", then alembic upgrade head. Web/connector use pnpm install.
:8000/healthz
:3000/healthz
:8787/health
./dev.shConvenience launcher for the local stack.docker compose -f infra/docker/compose.yaml up -dStart Postgres, Redis, and MinIO.cd apps/api && pip install -e ".[dev]"Install API and dev tooling.cd apps/api && alembic upgrade headApply database migrations.cd apps/api && uvicorn app.main:app --reload --port 8000Run the API.cd apps/api && pytestRun backend tests.cd apps/api && ruff check app tests && mypy appLint and type-check Python.cd apps/web && pnpm install && pnpm devInstall and run the web app on port 3000.cd apps/web && pnpm lint && pnpm typecheck && pnpm buildValidate frontend changes.cd apps/baileys-connector && pnpm devRun the connector in watch mode.apps/apiuvicorn app.main:app --reload --port 8000—ruff check . / mypy apppytest (single: pytest tests/test_health.py::test_name)
apps/webpnpm devpnpm buildpnpm lint / pnpm typechecknone yet
apps/baileys-connectorpnpm devpnpm buildpnpm typecheckvitest configured, no specs yet
Local infra caveat: Docker Desktop is broken on the maintainer's arm64 machine (exec-format error on docker run alpine). The plan is to run Postgres/Redis/MinIO natively via Homebrew on that machine. Don't churn trying to fix Docker; if docker compose up fails with format errors, surface it and move on.
Testing guidelines
apps/api/tests and name files test_*.py.
Test async endpoints with httpx.AsyncClient and ASGITransport as shown in test_health.py.
Frontend and connector test suites are not established yet, so run lint, typecheck, and build before opening a PR.
Two env vars unlock integration tests that skip when unset, so plain pytest runs anywhere. Point BOTH at disposable instances: the DB harness TRUNCATEs between tests.
TEST_DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/replybox_test pytest # DB-backed
TEST_REDIS_URL=redis://localhost:6379/15 pytest # Redis/BullMQ-backed
Skipping is silent, so a green pytest does not mean these ran. Check the skip count.
TEST_REDIS_URL covers queue behaviour that can only be verified against real Redis running the real Lua (see tests/test_promote_job_integration.py). The fake-backed unit tests assert what we send and share the code's own assumptions, which is how a broken job promotion went unnoticed in production for its whole life. Use db 15; local dev uses 0/1/2.
Coding style & naming conventions
apps/api/pyproject.toml)
Modulessnake_case
Pydantic / SQLAlchemy classesPascalCase
Async DB / HTTP pathsKeep them explicit
use
Shared helperscamelCase
Prefer existing component, hook, service, and query-key patterns before adding abstractions.
alembic revision --autogenerate -m "..." from apps/api, then alembic upgrade head).
The connector is disposable; correctness lives in the API. Session blobs in MinIO are the only durable connector state.
When extending channel lifecycle or messaging, route everything through the existing BullMQ producer + HMAC webhook + Socket.IO emitter trio. Don't add a parallel path.
Ruff line length 100, Python 3.11 target (apps/api/pyproject.toml).
Architecture
Data flow for WhatsApp messages. Read this before touching the connector or channel/message code.
Web ── REST ──▶ API ── BullMQ "whatsapp" queue ──▶ connector ──▶ Baileys
▲ │
│ Socket.IO room org:{id} │ inbound events
│ ▼
└────────────── API ◀── HMAC-signed HTTP ──── /internal/v1/...
Every row carries organization_id. User-facing public endpoints enforce membership.
The pinned /api/v1/disciplinary/* provider integration is the sole server-to-server exception: it uses its configured bearer + channel/organization and grants no inbox access. Do not add another exception without documenting its fixed tenant boundary.
Producer at apps/api/app/queue/producer.py pushes WhatsApp mutations (connect, disconnect, send) onto BullMQ queue whatsapp. The Python bullmq package is wire-compatible with the Node worker.
The disciplinary bridge's groupMetadata() and onWhatsApp() checks are the narrow exception: read-only with respect to WhatsApp over private HMAC HTTP (the roster probe may refresh the connector's in-memory LID mapping), never a send path.
Slow background enrichment gets its own queue + worker so it can never occupy a slot a user-initiated send needs: whatsapp_media (download_media) and whatsapp_avatars (fetch_avatar). Keep new job kinds off the whatsapp queue unless a user is waiting on them, and deploy the connector before the API when adding a queue.
scheduled, flow_runs, broadcasts are consumed by Python workers inside the API process, not the connector. They only decide when something is sent and then call the same producer functions the synchronous endpoints call. Adding one needs no connector deploy.
The rule they all follow: an orchestration worker never talks to WhatsApp directly.
HMAC-signed HTTP POST to /internal/v1/.... Signature is HMAC-SHA256 over {ts}\n{METHOD}\n{path}\n{body}, sent as X-Replybox-Timestamp + X-Replybox-Signature.
Verifier: apps/api/app/security/hmac.py. Signer: apps/baileys-connector/src/internal/api-client.ts.
Do not introduce a second connector→API mechanism (no Redis pub/sub, no websockets). Extend this one.
Socket.IO room per organization (org:{id}). Server: app/realtime/sio.py (validates access cookie + membership on connect). Typed emitters: app/realtime/emit.py.
channel.qrchannel.statusmessage.createdconversation.updatedWeb client folds these into React Query caches. Do not trigger refetches on realtime events.
CHANNEL_SESSION_KEY
Single S3/MinIO blob at channels/{id}/auth.bin
Crypto + S3 helpers in apps/baileys-connector/src/session/. Sessions are not in Postgres.
Pairing is QR-only; pairing-code flow is deferred.
Idempotent on (channel_id, provider_message_id).
The connector filters groups (@g.us), broadcasts, and protocol/reaction/poll updates before forwarding.
A recurring report with a settled answer: run python -m app.cli diagnose-stuck-sends before reading code.
It classifies every queued outbound row by BullMQ job state × the connector's send-guard cache, which separates "the send failed" from "the browser's cache is wrong" in one command. As of 2026-08 production measures ~0% of the former.
messages.client_message_id is what lets the client match an ack to its optimistic bubble, so it must stay on the row and serialise from it, never be stitched onto individual responses. Full history in docs/rca-stuck-sending-bubble.md.
One message → many contacts, each a normal 1:1 chat. The audience is materialized into broadcast_recipients at create time: the contacts filter is resolved once, never re-run, so a campaign's audience can't drift as tags change under it.
The drip worker (app/queue/broadcast_worker.py) sends to exactly one recipient per tick and enqueues its own successor 60/messages_per_minute later. A lost tick freezes the campaign silently, which is what BROADCAST_SWEEP_SECONDS exists to recover.
This is not WhatsApp's broadcast-list feature: each recipient gets an ordinary messages row in their own conversation.
Split three ways by cost, in services/broadcast_stats.py.
messages.status (WhatsApp's own receipts), never from sent_count, which only means "handed to the connector" and diverges exactly when it matters.
Replies are a maintained counter (record_replies, hooked into the inbound-ingest batch beside the cancel-on-reply hooks) because computing them live means scanning every recipient's conversation.
Attention items are derived per request, never stored: a persisted alert has to be invalidated when the thing it describes recovers.
Rates are always over resolved messages, and reply rate is per recipient while delivery is per message. Mixing those denominators under-reports a flow campaign by its step count.
Three association tables share one org-wide Tag catalog:
conversation_tags: inbox.
contact_directory_entry_tags: what the /contacts dashboard reads, one row per (contact, channel).
contact_tags: person-level, a derived rollup maintained by Postgres triggers in db/tag_rollup.py.
The two chat-level tables are mirrored bidirectionally by every write path (inbox assign/unassign, both bulk paths, and the ChatDaddy import), so a directory-only tag is always pre-mirror drift, never a live bug.
This is load-bearing for broadcasts: audience filters read the directory side, so a tag that reached only conversation_tags silently shrinks a campaign while it still reports success. python -m app.cli reconcile-tags reports and repairs historical drift. Group chats are deliberately absent from the dashboard (bump_directory_entry skips @g.us).
16 seeded permissions + 4 system role templates, cloned per org on signup.
owneradminagentviewerSystem roles are read-only in the UI; custom roles are editable. Gate REST endpoints with require_permission(). Seed source: app/seeds/permissions.py.
Cookie-based JWT: httpOnly access + refresh cookies.
Web has an axios refresh interceptor (apps/web/lib/api.ts). Socket.IO reuses the access cookie on handshake.
Key file map
apps/api/app/
api/v1/Public REST (auth, orgs, members, roles, channels, conversations)api/internal/HMAC-protected connector callbacksmodels/ schemas/SQLAlchemy ORM + Pydantic I/Oservices/Business logicqueue/producer.pyBullMQ producerqueue/broadcast_worker.pyBroadcast drip engine + stalled-campaign sweepservices/broadcasts.pyAudience materialization, send-window + pacing mathservices/broadcast_stats.pyDashboard aggregates + derived attention itemsrealtime/{sio,emit}.pySocket.IO server + typed emitterssecurity/Hashing, JWT, HMAC, cookiesseeds/permissions.pyPermission catalog + system role templatesapps/baileys-connector/src/
whatsapp/manager.tsPer-channel Baileys socket lifecyclewhatsapp/messages.tsWAMessage → wire payload mappersession/{crypto,s3,store}.tsSession crypto, S3, and storeinternal/api-client.tsHMAC-signed HTTP clientqueue/worker.tsBullMQ workerapps/web/
app/(app)/Authenticated routescomponents/broadcasts/Dashboard (KPIs, table, calendar, attention rail), 3-step composerhooks/useAuth / useInbox / useRealtime / useMembers / useRoles / useChannelsAdmin / useOrganization / useBroadcastslib/api.tsAxios + refresh + toast interceptorslib/socket.tssocket.io-client singletonstores/Zustand stores (orgStore, toastStore)Reply Copilot and saved-message delivery rule
Reply Copilot suggestions are drafts, never sends. The API may return bounded, tenant-scoped text to the existing composer; only the established composer send/Message Flow paths may create or queue an outbound message.
Keep Message Flows as the one saved-message system. Improve, classify, and retrieve them by proven usage; do not add a parallel canned-reply table.
For the current composer slice: show the actual words and source, stage them as editable text, and require the staff member's normal Enter/send action. The requested customer follow-up automation remains a planned next slice; it needs its own evaluation set and explicit production gate.
Never claim to summarise a call from metadata alone. Once a recording, transcript, or staff-authored notes exist with source provenance, use that evidence to build the requested call-summary and customer follow-up slice.
Safe ReplyBox slices continue through merge, deployment, and exact live browser/API proof. Do not park a finished safe feature on an unmerged branch without a concrete collision or failed release gate.
Commits, pull requests, and security
This repository has no existing commit history to derive a convention from. Use concise, imperative commit subjects:
Add inbox filtersFix channel sync statusPull requests should include:
Use the provided .env.example files in each app as templates.
Do not commit:
.env valuesDatabase dumpsWhatsApp session dataGenerated build output