# The DisExcel Project — Context for Claude Code Backend: FastAPI + SQLAlchemy (async) + Pydantic v2 + PostgreSQL. JWT auth (access + refresh tokens), RBAC permissions, Redis cache, RabbitMQ background workers. Python >=3.13,<4.0, Poetry for dependency management. ## Auth & Permissions - Users have `direct_permissions` (list of `Permissions`) and `group` (list of `PermissionsGroups`, each with its own `permissions`) — many-to-many both ways. - Effective permissions = `direct_permissions ∪ (union of all groups' permissions)`. - `require_permissions(*permissions)` in `src/web/protected_routes` is a FastAPI dependency factory — wraps `CurrentUserService.get_current_user`. Call with no args (`require_permissions()`) for "just authenticated, no specific permission needed". - Access tokens carry a `jti` claim. Logout writes `revoked_access_token:{jti}` to Redis with TTL = remaining token lifetime — `get_current_user` checks this key before anything else. - `secure` flag on refresh_token cookie is driven by `env_settings.PROD_MODE` (bool) — `False` locally/tests so cookies work over plain HTTP, `True` in prod. ## Redis (`src/cache/`) - `RedisClient(redis.Redis)` — module-level shared singleton, subclasses `redis.Redis` directly (inherits all commands, no manual wrapping needed). - Three uses: permissions is-cache was considered and rejected (no real DB savings — `get_user_by_id` already eager-loads everything via `selectin` in one call); rate limiting on login (`RateLimit.rate_limit(ip)` — `INCR` + `EXPIRE` on first attempt, blocks >5/60s); access-token revoke blacklist (see above). - Rate limit is only triggered inside `except HTTPException` on `/protected/token` — i.e. only on failed logins, not successful ones (otherwise legitimate repeated logins would trip it). ## RabbitMQ (`src/messaging/`) - `RabbitMQClient` — shared class, lazy `connect()` (can't be async `__init__`), holds one `connection` + one `channel`, `get_channel()` ensures setup. `connect()` retries `connect_robust()` up to 5 times with exponential backoff (`asyncio.sleep(2**attempt)`) before raising — needed because when running the app locally (outside Docker Compose) right after `docker compose up rabbitmq`, the broker's AMQP listener isn't immediately ready and resets the connection mid-handshake (`ConnectionResetError`). Compose's own `depends_on: condition: service_healthy` only helps when the app itself also runs as a Compose service — it does nothing when the app is started on the host. - **Topology is centralized, not declared ad-hoc.** `src/models/rabbitmq_models/email.py` defines the whole topology as pydantic data (`Topology`/`ExchangeSpec`/`QueueSpec`, exported as `email_topology`) — topic exchange `"email"`, one durable quorum queue per message type (`queue_welcome_email`/`email.welcome`, `queue_reset_email`/`email.reset`), each with a matching dead-letter queue. `src/messaging/topology_setup.py::apply_topology(channel, topology)` is the only place that actually calls `declare_exchange`/`declare_queue`/`bind` against RabbitMQ. It's called once at startup — in `main.py`'s `lifespan` (web) and in `daemon_run.py`'s `main()` (daemon entrypoint) — **before** any producer/consumer touches the exchange/queues. `EmailProducer`/ `WelcomeEmailConsumer`/`ResetEmailConsumer` no longer declare or bind anything themselves — they only `get_exchange("email")` / `get_queue("queue_...")`, assuming `apply_topology` already ran. Changing a queue's arguments in `email_topology` requires manually deleting the existing queue in RabbitMQ first — `declare_queue` on an already-existing queue with different arguments fails with `PRECONDITION_FAILED`, it doesn't update in place. - **Dead-lettering**: each original queue's `x-dead-letter-exchange` points at `email.dlx`, a **`direct`** exchange (not `fanout` — fanout would broadcast every dead-lettered message to *all* DLQs, mixing up welcome/reset failures). Each DLQ is bound to `email.dlx` with the same routing key as the original queue (`email.welcome`/`email.reset`); RabbitMQ preserves a message's original routing key when dead-lettering, so no explicit `x-dead-letter-routing-key` override is needed — the `direct` exchange alone routes each dead letter back to the correct DLQ. - `message.process(ignore_processed=True)` + manual `await message.nack(...)` in `process_message`, not the plain auto-ack/nack that `message.process()` does by default — needed because business-logic exceptions are caught *inside* the block (to log them and decide retry vs. dead-letter) rather than left to propagate, so `process()` itself must be told not to also try to ack/nack on exit. Classification: transient errors (SMTP/network: `SMTPServerDisconnected`, `SMTPConnectError`, `TimeoutError`, `ConnectionRefusedError`) → `nack(requeue=True)`; everything else (bad message body, `SMTPAuthenticationError`, bugs) → `nack(requeue=False)` → DLQ. Note `SMTPAuthenticationError` is a known rough edge here — it's a systemic config problem (bad credentials), not a per-message one, but is currently DLQ'd per-message like any other permanent error rather than halting the daemon with an alert. - Email templates: Jinja2 (`src/service/email/jinja_env.py` — one shared `Environment`/`FileSystemLoader` singleton, templates in `src/service/email/templates/`), inline CSS (email clients don't support `