Key concepts

A short tour of the moving parts in BottleCRM.

Organizations (tenants)

Everything in BottleCRM is scoped to an organization. A user belongs to one or more organizations through a Profile. The currently active organization is encoded in the JWT, and the backend enforces tenant isolation in two layers:

  1. Application layerModel.objects.filter(org=request.profile.org) on every queryset.
  2. Database layer — PostgreSQL row-level security policies that read app.current_org from a session variable set per request.

The database user the app connects as is intentionally not a superuser, so even a buggy query cannot return another tenant's rows. See Postgres + RLS.

Profiles and roles

A Profile joins a user to an organization with a role: ADMIN, SALES MANAGER, SALES REP, or USER. Permissions are derived server-side from the profile — never trust an is_admin boolean sent from the client.

Entities

The core CRM entities and what they're for:

Entity Purpose
Lead Top of funnel — an unqualified person or company.
Account A qualified customer (or prospect) organization.
Contact A person at an account.
Opportunity A potential deal attached to an account.
Task Anything someone needs to do, optionally linked to a record.
Case (Ticket) A customer support issue.
Invoice / Estimate / RecurringInvoice Billing artifacts.

Each of these supports per-organization custom fields — see Custom fields.

Authentication

The web app and mobile app authenticate with JWT access + refresh tokens. Tokens are minted by:

  • Google OAuth (POST /api/auth/google/)
  • Magic link email (POST /api/auth/magic-link/request/POST /api/auth/magic-link/verify/)
  • Dev login (manage.py devlogin, local only)

The SvelteKit shell stores tokens in httpOnly cookies (jwt_access, jwt_refresh, org); the mobile and API clients hold them in their respective secure stores.

Background work

Long-running operations (sending invoice emails, importing CSVs) are dispatched to Celery. Critically, Celery tasks must re-establish the tenant context with set_rls_context(org_id) from common.tasks before touching the ORM — there is no middleware in the worker.