# CLAUDE.md BottleCRM community edition, a multi-tenant CRM. `backend/` is a Django 6.1 + DRF API managed with uv and published to PyPI as `django-crm`. `frontend/` is SvelteKit 3 with Svelte 5 runes, Tailwind 4, shadcn-svelte and pnpm. `mobile/` is a Flutter app with its own `mobile/CLAUDE.md`. Both clients use the same API. Tenants are isolated by PostgreSQL row-level security underneath explicit ORM filters. ## Security first This is a production multi-tenant app under active attack. The two targets are account takeover and superadmin escalation: assume every change is probed for a way to act as another user, read another org's data, or reach `is_superuser`. Full rules: `docs/contributing/security-rules.md`. - **The backend is the only trust boundary.** Web and mobile validation are UX; every rule must also live in the DRF serializer or view. - **Identity, org and role are server-derived** from `request.profile` and the JWT, never the request body. Login, OAuth, JWT claims, token refresh and rotation, email or password change, invitations, membership and role assignment are high-risk by default. - **Every view gets `permission_classes = (IsAuthenticated, HasOrgContext)`** plus its object or role check. When you touch a check, prove it can return both true and false, with a test for each: comparing mismatched types makes a check silently always-false. - **Scope every query by org**, then check visibility (see Reuse below), and pass `org=` on `create()` and `serializer.save()`. RLS is the safety net, not the contract. - **Server-owned fields** (`org`, `created_by`, `profile`, publish flags) are `read_only=True` or popped before save. State transitions validate the source state, not just the target. - Check by name for: org auto-join by email domain, IDOR on detail and nested routes, mass assignment, refresh tokens surviving a privilege change, SSRF, path traversal, stored-markdown XSS, and secrets or another tenant's data in responses and logs. - **Never weaken a protection** (RLS policy, permission class, org filter, validator, CSRF or auth check) to make something work: surface it and ask. Flag vulnerabilities you notice, but never in a public issue, PR or commit message: they are reported as `SECURITY.md` describes. ## House rules - **Web and mobile ship together.** A user-facing change reaches `frontend/src/` and `mobile/lib/` in the same pass, whichever side it starts on. If one half genuinely cannot ship, add a row to the Backlog in `mobile/docs/PARITY.md` and say so. Parity covers behaviour too: validators, status sets, role gates, filter params, currency and timezone rules. Change one client, grep the other. - **Mobile first.** A web page is done when it works at 390px: no horizontal scroll, no stranded action, tap targets about 44px. `frontend/src/lib/v2/styles/v2.css` turns `.v2-table` rows into cards below 768px (refined per cell with `data-m`: `title`, `meta`, `lead`, `tag`, `bar`, `hide`) and supplies `.v2-tabbar` and `.v2-fab`. Bespoke markup opts out of all of it. 768px is the phone breakpoint; other one-off values in route files are drift. - **Bump the version with the change.** `version` in `backend/pyproject.toml` is the only version, and `backend/uv.lock` repeats it on its `django-crm` line. Feature or endpoint: minor. Fix, docs or packaging: patch. Major only for a REST break or a migration that cannot be applied forward. A frontend change bumps too, since every release publishes `ghcr.io/django-crm/bottlecrm-frontend:`; a mobile-only change does not. See `docs/contributing/releasing.md`. - **A new Django app is two edits**: `INSTALLED_APPS` and the hatch `packages`/`include` lists in `backend/pyproject.toml`. A missing entry imports fine from a checkout and fails after `pip install`; `common/tests/test_packaging.py` catches the drift. - **Build what was asked, and reuse before adding.** No config knob or abstraction without a second real caller. A feature is done when its failure paths, malformed input and wrong-role caller are tested. When you fix a block, check its other branches; no dead code, and no docstring describing behaviour the code lacks. - **Comment sparingly.** Comment only what the code cannot say: a non-obvious reason, a workaround, a subtle invariant, a deliberate departure from the obvious approach. Never narrate what it does. Two or three lines, five at most; a longer reason belongs in a docstring or `docs/`, with a one-line pointer. State a fact once, where a reader meets it first. Much of this code predates the rule, so the file you are editing is not the standard. This overrides matching the surrounding comment density. - **No em dashes** in anything: code, comments, docstrings, UI copy, DRF error strings, docs, test names, seed data. Rewrite with a comma, colon, parentheses or two sentences; an en dash or spaced hyphen in the same role is not a fix. A bare `'—'` rendered as an empty-value placeholder is a glyph and stays. - **Agents do not commit, stage or push.** Leave changes unstaged for a human to review. ## Reuse before writing Backend: - Admin: `common.permissions.is_org_admin(profile)`. Never compare `role` yourself. Permission classes `HasOrgContext`, `IsOrgAdmin` and `IsSuperAdmin` live in the same module. - One record: `common.lookups.get_scoped_or_404(model, pk, org)` (404 for missing, cross-tenant and malformed ids); a comment or attachment on a visible parent: `get_on_visible_record_or_404`. Visibility rules live in each module's `access.py` (`visible_leads_qs`, `visible_cases_qs`, `has_*_access`, `may_delete_*`). - Routes use `` (`common.converters.UUIDLikeConverter`), never `` or ``. Query params and payload ids go through `common.validators` (`uuid_param`, `uuid_list_param`, `payload_id_list`, `date_param`, `decimal_param`, `choice_list_param`). - RLS: `common.tasks.set_rls_context` and `clear_rls_context` (pair them in `finally`); `get_enable_policy_sql` and `ORG_SCOPED_TABLES` in `common/rls/__init__.py`. - Uploads: `common.utils.validate_attachment` before the first write, then `create_attachment`. - Money: `common.money` (`org_currency`, `currency_block`, which never adds two currencies). Time: `common.org_time` (`org_timezone`, `activate_org_timezone`). Links in emails: `common.links.frontend_url` and `api_url`. Audit: `common.audit_log.audit_log`. Custom fields: `common.custom_fields.validate_payload`. - Test fixtures: `common.testing` (`org_a`, `org_b`, `admin_client`, `user_client`, `set_rls_context`, ...), re-exported by `conftest.py`. Frontend: - Formatting: `#lib/v2/format.js` (`money(n, currency)`, which defaults to USD, so pass `data.org.currency`; `shortDate`, `longDate`, `relativeTime`). The org's "today": `todayIn(tz)` from `#lib/v2/dates.js` with `data.org.timezone`. - Admin: `isOrgAdmin` from `#lib/admin.js`. Labels and tones: `#lib/v2/enums.js`. Classes: `cn` from `#lib/utils.js`. DRF errors to a sentence: `readableError` from `#lib/server/v2/form-errors.js`. - Lists and filters: `#lib/v2/filters.js`, `#lib/server/v2/filter-params.js`, `#lib/server/v2/list-queries.js`, `#lib/server/v2/saved-views.js`. Custom fields: `#lib/server/v2/custom-fields.js`. @mentions: `MentionTextarea` and `MentionBody` in `#lib/v2/components/`, fed by `mentionPeople()` from `#lib/v2/mentions.js`. - Components: `#lib/v2/components/.svelte` (`PageHeader`, `Pill`, `EmptyState`, `StatCard`, `ConfirmAction`, `FilterBar`, ...). ## Commands ```bash # backend/ uv sync uv run python manage.py migrate uv run python manage.py runserver # :8000 uv run celery -A crm worker --loglevel=INFO uv run pytest leads/tests/test_leads_kanban.py --no-cov # one file (preferred) uv run pytest --no-cov -x # whole suite, in-memory SQLite DJANGO_SETTINGS_MODULE=crm.test_settings_postgres DBUSER= \ uv run pytest --no-cov -m postgres_only # RLS tests; DB* env vars as in CI uv run ruff check . && uv run ruff format . # ruff only: no black, no isort uv run python manage.py makemigrations --check --dry-run uv run python manage.py manage_rls --status # also --verify-user, --test # frontend/ pnpm install pnpm run dev # :5181 pnpm test # vitest pnpm run check # svelte-kit sync + svelte-check pnpm run lint # prettier --check + eslint pnpm run format docker compose up --build # full stack, from the repo root ``` CI (`.github/workflows/tests.yml`) blocks on `makemigrations --check`, `ruff check`, `ruff format --check`, pytest on SQLite and on Postgres, the `postgres_only` tests as a non-superuser role, a wheel install check, `eslint`, `pnpm check`, `pnpm test`, `pnpm build` and `mkdocs --strict`. Prettier only warns. Nothing runs for mobile. Markers, the Postgres role and the three CI runs: `docs/contributing/testing.md`. ## Gotchas Backend: - **`created_by` is a `User` FK; `assigned_to` holds `Profile`s.** Compare `profile.user_id == obj.created_by_id`. Mixing the two is how permission checks went silently dead. - **`BaseModel.save` stamps `created_by`/`updated_by`** from the request user inside a request and keeps caller values outside one. - **`ATOMIC_REQUESTS` is off**: validate everything, uploads included, before the first write. - **`Task`, `Comment` and `Attachments` call `full_clean()` in `save()`**, so a model `ValidationError` arrives as a 500 unless the serializer checks first. `Case.clean()` never runs on save. - **`Meta.ordering` leaks into `DISTINCT` and `GROUP BY`**: call `.order_by()` before `.distinct()` or `.values().annotate()`. - **A `read_only` field is dropped silently**: the write returns 200 and nothing changes. Privilege fields turn read-only for callers who may not grant them. - **The detail route carries comments and attachments.** `/api///` takes GET (record plus comments and attachments), POST (adds a comment or attachment), PUT, PATCH and DELETE. `/comment//` is PUT, PATCH, DELETE by comment id; `/attachment//` is DELETE only. Deals are `/api/opportunities/`. Swagger: `/swagger-ui/`. - **SQLite tests cannot see RLS**: `crm.test_settings` is in-memory SQLite, where the RLS helpers do nothing. Tenant isolation needs a `postgres_only` test. - **Most core models predate `BaseOrgModel`** and declare their own `org` FK. A new org-scoped model inherits `BaseOrgModel`, joins `ORG_SCOPED_TABLES` and gets an RLS migration; follow `docs/contributing/adding-an-org-scoped-model.md`. - Celery workers have no middleware: call `set_rls_context(org_id)` before any query. Frontend: - **TypeScript throughout**: every module and test is `.ts` and every component uses `