13 KiB
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.profileand 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=oncreate()andserializer.save(). RLS is the safety net, not the contract. - Server-owned fields (
org,created_by,profile, publish flags) areread_only=Trueor 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.mddescribes.
House rules
- Web and mobile ship together. A user-facing change reaches
frontend/src/andmobile/lib/in the same pass, whichever side it starts on. If one half genuinely cannot ship, add a row to the Backlog inmobile/docs/PARITY.mdand 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.cssturns.v2-tablerows into cards below 768px (refined per cell withdata-m:title,meta,lead,tag,bar,hide) and supplies.v2-tabbarand.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.
versioninbackend/pyproject.tomlis the only version, andbackend/uv.lockrepeats it on itsdjango-crmline. 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 publishesghcr.io/django-crm/bottlecrm-frontend:<version>; a mobile-only change does not. Seedocs/contributing/releasing.md. - A new Django app is two edits:
INSTALLED_APPSand the hatchpackages/includelists inbackend/pyproject.toml. A missing entry imports fine from a checkout and fails afterpip install;common/tests/test_packaging.pycatches 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 compareroleyourself. Permission classesHasOrgContext,IsOrgAdminandIsSuperAdminlive 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'saccess.py(visible_leads_qs,visible_cases_qs,has_*_access,may_delete_*). - Routes use
<uid:pk>(common.converters.UUIDLikeConverter), never<uuid:pk>or<str:pk>. Query params and payload ids go throughcommon.validators(uuid_param,uuid_list_param,payload_id_list,date_param,decimal_param,choice_list_param). - RLS:
common.tasks.set_rls_contextandclear_rls_context(pair them infinally);get_enable_policy_sqlandORG_SCOPED_TABLESincommon/rls/__init__.py. - Uploads:
common.utils.validate_attachmentbefore the first write, thencreate_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_urlandapi_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 byconftest.py.
Frontend:
- Formatting:
#lib/v2/format.js(money(n, currency), which defaults to USD, so passdata.org.currency;shortDate,longDate,relativeTime). The org's "today":todayIn(tz)from#lib/v2/dates.jswithdata.org.timezone. - Admin:
isOrgAdminfrom#lib/admin.js. Labels and tones:#lib/v2/enums.js. Classes:cnfrom#lib/utils.js. DRF errors to a sentence:readableErrorfrom#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:MentionTextareaandMentionBodyin#lib/v2/components/, fed bymentionPeople()from#lib/v2/mentions.js. - Components:
#lib/v2/components/<Name>.svelte(PageHeader,Pill,EmptyState,StatCard,ConfirmAction,FilterBar, ...).
Commands
# 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=<non-superuser role> \
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_byis aUserFK;assigned_toholdsProfiles. Compareprofile.user_id == obj.created_by_id. Mixing the two is how permission checks went silently dead.BaseModel.savestampscreated_by/updated_byfrom the request user inside a request and keeps caller values outside one.ATOMIC_REQUESTSis off: validate everything, uploads included, before the first write.Task,CommentandAttachmentscallfull_clean()insave(), so a modelValidationErrorarrives as a 500 unless the serializer checks first.Case.clean()never runs on save.Meta.orderingleaks intoDISTINCTandGROUP BY: call.order_by()before.distinct()or.values().annotate().- A
read_onlyfield 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/<module>/<uid:pk>/takes GET (record plus comments and attachments), POST (adds a comment or attachment), PUT, PATCH and DELETE./comment/<uid:pk>/is PUT, PATCH, DELETE by comment id;/attachment/<uid:pk>/is DELETE only. Deals are/api/opportunities/. Swagger:/swagger-ui/. - SQLite tests cannot see RLS:
crm.test_settingsis in-memory SQLite, where the RLS helpers do nothing. Tenant isolation needs apostgres_onlytest. - Most core models predate
BaseOrgModeland declare their ownorgFK. A new org-scoped model inheritsBaseOrgModel, joinsORG_SCOPED_TABLESand gets an RLS migration; followdocs/contributing/adding-an-org-scoped-model.md. - Celery workers have no middleware: call
set_rls_context(org_id)before any query.
Frontend:
- JavaScript with JSDoc, not TypeScript. Imports use the
#lib/...subpath with the full file (#lib/components/ui/button/index.js), never$lib. Runes only: noexport let,on:click,<slot>,createEventDispatcheror$app/stores. There is no Zod. - Data loads on the server. A route's
+page.server.jscalls#lib/server/v2/<entity>.js, which callsapiRequestfrom#lib/api-helpers.jswith the cookies; writes are form actions withuse:enhanceandfail(). There are no+page.jsfiles, because the JWT is an httpOnly cookie the browser cannot read. Browser code talks tosrc/routes/api/*/+server.jsproxies or?/action, never to Django, and cannot import#lib/server/*. - A load's returned names are the page's contract.
server/v2returns{ results, totals }and each load renames them; a mismatch is an SSR 500 while the API answers 200, andpnpm checkdoes not catch it. Load the page. - Forms drop values silently. A
<select>with no option matching the stored value submits nothing and clears it on save (usemissingOptionsfrom#lib/v2/pickers.js); an unchecked checkbox submits nothing, so send an explicitfalse. - There are no toasts and no toast library. Feedback is
fail()plusform?.errororform?.message. - Env vars are declared in
src/env.jsand read through$app/env/publicor$app/env/private. In SvelteKit 3 a string starting with/is a route ID: link withresolve()from$app/paths, orasInternalPathfor a built string. - Vitest runs in Node: it cannot compile
.svelteor resolve$app/*beyond the env stubs, so a testable module imports neither. Tests sit beside the code as*.test.js; run them fromfrontend/. - Never log a raw axios error in
hooks.server.js, since it carries the tokens: usedescribeError./help(the signed-in user's tickets) is not/help-center(public) and stays out ofPUBLIC_ROUTES.
Checking a page in the browser
cd backend && uv run python manage.py devlogin <email> --org MicroPyramid. It only runs withDEBUG=True. With seeded data,<email>isseed_data's default--email. It prints both tokens and a devtools snippet.- Open the app and run the snippet in the console (or
evaluate_script). It sets thejwt_access,jwt_refreshandorgcookies thathooks.server.jsreads, then reloads. Tokens inlocalStoragealone still redirect to/login. /is the dashboard; there is no/dashboardroute. Check narrow layouts with device emulation at 390px, since a resized window may not go below about 500px.
--org takes an id or a name, and the user needs an active profile in that org (seed_data creates one).