Quorentra

Quorentra CRM Company Management: Building from Zero — Part 11

Series: Building Quorentra CRM from Zero — A Modular, ChatGPT-Native AI CRM

Building the first complete CRM module with tenant-owned companies, CRUD operations, repository and service layers, RBAC enforcement, and cross-tenant isolation.

Quorentra Company Management: Building from Zero — Part 11
Quorentra Company Management: Building from Zero — Part 11

1. Introduction

Quorentra now has a substantial platform foundation.

We have built:

Repository
FastAPI
PostgreSQL
SQLAlchemy
Alembic
Organizations
Users
Memberships
Registration
Authentication
Tenant Context
RBAC

But Quorentra is not yet much of a CRM.

It cannot store a customer company.

It cannot list accounts.

It cannot update an organization we do business with.

That changes in Part 11.

We are going to build the first genuine CRM capability:

Company Management

By the end of this article, an authorized user will be able to:

Create Company
Read Company
List Companies
Update Company
Delete Company

while Quorentra automatically enforces:

Authentication
+
Tenant Isolation
+
Permissions

Quorentra will reach:

Quorentra 0.0.9 — Company Management

More importantly, we will establish the reusable pattern that future modules such as Contacts and Opportunities can follow.


2. Why Start with Companies?

Most CRMs revolve around several fundamental business entities:

Companies
Contacts
Opportunities
Activities
Tasks

We need to choose one as the first vertical slice.

Companies are the logical starting point because many future CRM records naturally relate to them.

Conceptually:

Company
├── Contacts
├── Opportunities
├── Activities
├── Tasks
├── Notes
└── Documents

A company represents an external business organization with which the Quorentra tenant has a relationship.

Examples might include:

Contoso Ltd.
Northwind Traders
Adventure Works
Fabrikam

The Quorentra organization itself is represented by:

Organization

External businesses stored in the CRM are represented by:

Company

These concepts must remain distinct.


3. Organization Versus Company

This distinction is important.

An Organization represents the Quorentra customer or workspace.

For example:

Organization
Quorentra Consulting

Inside that workspace, users may manage:

Companies
Contoso
Fabrikam
Northwind
Adventure Works

Therefore:

Organization
├── Company
├── Company
├── Company
└── Company

The organization is the tenant.

Companies are CRM records owned by that tenant.


4. Tenant Ownership

Every company must belong to exactly one Quorentra organization.

Therefore our Company model needs:

organization_id

Conceptually:

Organization A
├── Company A1
├── Company A2
└── Company A3
Organization B
├── Company B1
└── Company B2

Organization A must never see:

Company B1
Company B2

This gives us our first opportunity to apply Part 9’s tenant architecture to real CRM data.


5. The Most Important Company Security Rule

From this point forward, remember:

A company ID alone is never sufficient to retrieve a company.

Suppose the API receives:

GET /companies/{company_id}

A dangerous query would be:

SELECT *
FROM companies
WHERE id = :company_id;

Instead, Quorentra must use:

SELECT *
FROM companies
WHERE id = :company_id
AND organization_id = :organization_id;

The organization ID comes from:

TenantContext

not directly from arbitrary client input.


6. The Complete Vertical Slice

Part 11 will create:

HTTP Request
Authentication
TenantContext
RBAC
Company Router
Company Service
Company Repository
SQLAlchemy
PostgreSQL

This is our first complete CRM request path.


7. Starting Checkpoint

Part 11 assumes Part 10 is working.

Run:

cd quorentra\backend
python -m pytest

All tests should pass.

Start the API:

python -m uvicorn app.main:app --reload

Verify:

GET /api/v1/health

Expected version:

0.0.8

We are now ready to introduce our first tenant-owned CRM entity.


8. Define the MVP Company Model

We deliberately want a small Company model.

For the first implementation:

Company
├── id
├── organization_id
├── name
├── website
├── industry
├── phone
├── description
├── created_at
└── updated_at

That is enough to build useful CRM behavior without overengineering the entity.


9. What We Are Deliberately Not Adding Yet

Enterprise CRM company records can become enormous.

They may contain:

annual revenue
employee count
billing address
shipping address
VAT number
tax number
parent company
subsidiaries
territories
account owner
account status
account type
lead source
custom fields
tags
social profiles
external IDs
enrichment data
AI scores

We do not need these yet.

Our modular principle remains:

Build the smallest useful entity, prove the vertical slice, then expand.


10. Create the Companies Module

Create:

app/modules/companies/

From:

quorentra/backend

run:

mkdir app\modules\companies
New-Item app\modules\companies\__init__.py -ItemType File
New-Item app\modules\companies\model.py -ItemType File
New-Item app\modules\companies\repository.py -ItemType File
New-Item app\modules\companies\service.py -ItemType File
New-Item app\modules\companies\schemas.py -ItemType File
New-Item app\modules\companies\router.py -ItemType File
New-Item app\modules\companies\exceptions.py -ItemType File

The module becomes:

companies/
├── __init__.py
├── exceptions.py
├── model.py
├── repository.py
├── router.py
├── schemas.py
└── service.py

11. The Company Module Architecture

Responsibilities remain separated.

router.py
HTTP concerns
schemas.py
API contracts
service.py
Business logic
repository.py
Persistence queries
model.py
SQLAlchemy entity
exceptions.py
Domain/application errors

This is the same modular pattern we will reuse throughout Quorentra.


12. Create the Company SQLAlchemy Model

Open:

app/modules/companies/model.py

Add imports similar to:

from uuid import UUID, uuid4
from sqlalchemy import ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base

Then define:

class Company(Base):
__tablename__ = "companies"
id: Mapped[UUID] = mapped_column(
primary_key=True,
default=uuid4,
)
organization_id: Mapped[UUID] = mapped_column(
ForeignKey(
"organizations.id",
ondelete="CASCADE",
),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
website: Mapped[str | None] = mapped_column(
String(500),
nullable=True,
)
industry: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
)
phone: Mapped[str | None] = mapped_column(
String(100),
nullable=True,
)
description: Mapped[str | None] = mapped_column(
Text,
nullable=True,
)

If your existing project uses timestamp mixins, inherit from them rather than duplicating timestamp fields.


13. Add Timestamp Support

If Part 6 introduced something such as:

TimestampMixin

use:

class Company(
TimestampMixin,
Base,
):

Then every company automatically receives:

created_at
updated_at

This is preferable to redefining timestamp behavior in every module.


14. Why organization_id Is Indexed

Most Company queries will contain:

organization_id

For example:

SELECT *
FROM companies
WHERE organization_id = :organization_id;

As the dataset grows, indexing this column becomes important.

Tenant ownership is not merely metadata.

It is one of the primary query dimensions of the application.


15. Should Company Names Be Globally Unique?

No.

Two Quorentra organizations may both have a company called:

Contoso

Therefore this would be wrong:

UNIQUE(name)

Company uniqueness, if we enforce it, should be tenant-scoped.

Conceptually:

organization_id + normalized company name

But even that may be too restrictive in some real-world scenarios.

For the MVO, we will handle obvious duplicates at the application level without creating an overly aggressive database rule.


16. Register the Company Model

Alembic must know the model exists.

Depending on how Part 6 configured model discovery, update the central model import file.

For example:

app/db/models.py

Add:

from app.modules.companies.model import Company

If the import appears unused, that is acceptable.

Its purpose is to register the SQLAlchemy metadata before Alembic examines it.


17. Generate the Migration

From:

quorentra/backend

run:

alembic revision --autogenerate -m "create companies table"

Alembic should detect:

companies

Review the generated migration carefully.

Never blindly apply an autogenerated migration.


18. Inspect the Migration

The migration should conceptually create:

companies
├── id
├── organization_id
├── name
├── website
├── industry
├── phone
├── description
├── created_at
└── updated_at

It should also create:

Foreign Key
companies.organization_id
organizations.id

and an index for:

organization_id

Check both:

upgrade()

and:

downgrade()

19. Apply the Migration

Run:

alembic upgrade head

Then verify:

alembic current

The current revision should be the newly created migration.


20. Verify in pgAdmin

Open PostgreSQL through pgAdmin.

Navigate to:

Databases
quorentra
Schemas
public
Tables

You should now see:

companies

Inspect the columns and confirm:

organization_id

exists and references:

organizations.id

Quorentra now has its first CRM table.


21. Create Company Schemas

Open:

app/modules/companies/schemas.py

We need separate schemas for:

Create
Update
Response

Do not use the SQLAlchemy model directly as an API contract.


22. Company Create Schema

Add:

from pydantic import BaseModel, Field
class CompanyCreate(BaseModel):
name: str = Field(
min_length=1,
max_length=255,
)
website: str | None = Field(
default=None,
max_length=500,
)
industry: str | None = Field(
default=None,
max_length=255,
)
phone: str | None = Field(
default=None,
max_length=100,
)
description: str | None = None

Notice what is missing:

organization_id

That is intentional.


23. Never Accept organization_id in Company Creation

A dangerous API would accept:

{
"organization_id": "some-other-tenant",
"name": "Contoso"
}

Instead, the client sends:

{
"name": "Contoso",
"website": "https://example.com"
}

The backend derives:

organization_id

from:

TenantContext

This prevents a client from creating records inside arbitrary organizations.


24. Company Update Schema

Add:

class CompanyUpdate(BaseModel):
name: str | None = Field(
default=None,
min_length=1,
max_length=255,
)
website: str | None = Field(
default=None,
max_length=500,
)
industry: str | None = Field(
default=None,
max_length=255,
)
phone: str | None = Field(
default=None,
max_length=100,
)
description: str | None = None

All fields are optional because:

PATCH

represents a partial update.


25. Company Response Schema

Add:

from datetime import datetime
from uuid import UUID
from pydantic import ConfigDict
class CompanyResponse(BaseModel):
model_config = ConfigDict(
from_attributes=True
)
id: UUID
organization_id: UUID
name: str
website: str | None
industry: str | None
phone: str | None
description: str | None
created_at: datetime
updated_at: datetime

from_attributes=True allows Pydantic to serialize the SQLAlchemy entity.


26. Should organization_id Be Returned?

For the MVP, yes.

Returning:

organization_id

makes tenant behavior explicit and easier to test.

Later, the frontend may not need to display it.

But including it in the API response is useful for:

debugging
integration testing
MCP context
API consumers

27. Create Company Exceptions

Open:

app/modules/companies/exceptions.py

Add:

class CompanyError(Exception):
pass
class CompanyNotFoundError(
CompanyError
):
pass
class CompanyAlreadyExistsError(
CompanyError
):
pass

Again, the service layer should not need to know about HTTP status codes.


28. Build the Company Repository

Open:

app/modules/companies/repository.py

Add imports:

from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.modules.companies.model import Company

Then create:

class CompanyRepository:
def __init__(
self,
db: Session,
) -> None:
self.db = db

29. Create a Company

Add:

def create(
self,
company: Company,
) -> Company:
self.db.add(company)
self.db.flush()
self.db.refresh(company)
return company

Notice that we do not necessarily commit here.

Transaction ownership should remain consistent with the pattern established in earlier parts.

If your service layer currently commits transactions, continue using that approach.


30. Tenant-Scoped Company Listing

Add:

def list_by_organization(
self,
organization_id: UUID,
) -> list[Company]:
statement = (
select(Company)
.where(
Company.organization_id
== organization_id
)
.order_by(
Company.name.asc()
)
)
return list(
self.db.scalars(statement).all()
)

This is our first real tenant-scoped CRM query.


31. Tenant-Scoped Company Lookup

Add:

def get_by_id_and_organization(
self,
company_id: UUID,
organization_id: UUID,
) -> Company | None:
statement = select(Company).where(
Company.id == company_id,
Company.organization_id
== organization_id,
)
return self.db.scalar(statement)

This method is deliberately verbose.

The name makes the security property obvious:

get_by_id_and_organization

not:

get_by_id

32. Why We Avoid get_by_id

Suppose:

Company A

belongs to:

Organization A

and a user from Organization B somehow obtains its UUID.

If we call:

get_by_id(company_id)

we could accidentally return the record.

Instead:

get_by_id_and_organization(
company_id,
tenant.organization.id,
)

produces no result.

The company behaves as though it does not exist within the current tenant.


33. Tenant-Scoped Name Lookup

For basic duplicate detection, add:

def get_by_name_and_organization(
self,
name: str,
organization_id: UUID,
) -> Company | None:
statement = select(Company).where(
Company.organization_id
== organization_id,
func.lower(Company.name)
== name.lower(),
)
return self.db.scalar(statement)

This makes duplicate detection tenant-specific.


34. Delete Company

Add:

def delete(
self,
company: Company,
) -> None:
self.db.delete(company)
self.db.flush()

The repository does not decide whether deletion is authorized.

That has already been handled by:

RBAC

before the service executes.


35. Repository Responsibility

Our repository now answers persistence questions:

Create this company.
List companies belonging to this organization.
Find this company inside this organization.
Find a same-name company inside this organization.
Delete this company.

It does not decide:

Can this user create companies?

That belongs to authorization.

It also does not parse:

X-Organization-ID

That belongs to tenant resolution.

Responsibilities remain separated.


36. Build the Company Service

Open:

app/modules/companies/service.py

Import:

from uuid import UUID
from sqlalchemy.orm import Session
from app.modules.companies.exceptions import (
CompanyAlreadyExistsError,
CompanyNotFoundError,
)
from app.modules.companies.model import Company
from app.modules.companies.repository import (
CompanyRepository,
)
from app.modules.companies.schemas import (
CompanyCreate,
CompanyUpdate,
)
from app.modules.tenants.context import TenantContext

Then:

class CompanyService:
def __init__(
self,
db: Session,
) -> None:
self.db = db
self.repository = CompanyRepository(db)

37. Create Company Business Logic

Add:

def create_company(
self,
tenant: TenantContext,
data: CompanyCreate,
) -> Company:
name = data.name.strip()
existing = (
self.repository
.get_by_name_and_organization(
name=name,
organization_id=(
tenant.organization.id
),
)
)
if existing is not None:
raise CompanyAlreadyExistsError
company = Company(
organization_id=(
tenant.organization.id
),
name=name,
website=data.website,
industry=data.industry,
phone=data.phone,
description=data.description,
)
company = self.repository.create(
company
)
self.db.commit()
self.db.refresh(company)
return company

The tenant owns the company automatically.


38. Why Tenant Context Enters the Service

Notice:

tenant: TenantContext

rather than:

organization_id: UUID

This communicates something important.

The service is not receiving an arbitrary organization identifier.

It is receiving an already validated application security context.

The company is therefore created for:

tenant.organization.id

39. Normalize Company Names

At minimum:

name = data.name.strip()

prevents records such as:

" Contoso "

from being stored unnecessarily.

We could add more sophisticated normalization later.

For now:

trim whitespace
case-insensitive duplicate check

is sufficient.


40. List Companies

Add:

def list_companies(
self,
tenant: TenantContext,
) -> list[Company]:
return (
self.repository
.list_by_organization(
tenant.organization.id
)
)

This is intentionally simple.

The important part is that the tenant scope cannot be omitted.


41. Retrieve One Company

Add:

def get_company(
self,
tenant: TenantContext,
company_id: UUID,
) -> Company:
company = (
self.repository
.get_by_id_and_organization(
company_id=company_id,
organization_id=(
tenant.organization.id
),
)
)
if company is None:
raise CompanyNotFoundError
return company

A company outside the tenant produces:

CompanyNotFoundError

not:

CrossTenantCompanyError

That is intentional.


42. Do Not Reveal Cross-Tenant Records

Suppose Company X exists in another tenant.

Should the API say:

This company exists, but belongs to another organization.

No.

That leaks information.

Instead, within the current tenant:

Company X

simply does not exist.

Therefore:

404 Not Found

is appropriate.


43. Update Company

Add:

def update_company(
self,
tenant: TenantContext,
company_id: UUID,
data: CompanyUpdate,
) -> Company:
company = self.get_company(
tenant,
company_id,
)
update_data = data.model_dump(
exclude_unset=True
)
if "name" in update_data:
name = update_data["name"].strip()
existing = (
self.repository
.get_by_name_and_organization(
name=name,
organization_id=(
tenant.organization.id
),
)
)
if (
existing is not None
and existing.id != company.id
):
raise CompanyAlreadyExistsError
update_data["name"] = name
for field, value in update_data.items():
setattr(
company,
field,
value,
)
self.db.commit()
self.db.refresh(company)
return company

This gives us partial updates while preserving tenant scope.


44. Delete Company

Add:

def delete_company(
self,
tenant: TenantContext,
company_id: UUID,
) -> None:
company = self.get_company(
tenant,
company_id,
)
self.repository.delete(company)
self.db.commit()

Again:

get_company()

ensures the company belongs to the current tenant before deletion.


45. Service Layer Security

Notice the service never does this:

Company(
organization_id=data.organization_id
)

Instead:

Company(
organization_id=tenant.organization.id
)

And it never retrieves:

repository.get_by_id(company_id)

Instead:

repository.get_by_id_and_organization(
company_id,
tenant.organization.id,
)

Tenant isolation is therefore embedded into the business path.


46. Build the Company Router

Open:

app/modules/companies/router.py

Add imports:

from uuid import UUID
from fastapi import (
APIRouter,
Depends,
HTTPException,
Response,
status,
)
from sqlalchemy.orm import Session
from app.db.dependencies import get_db
from app.modules.authorization.dependencies import (
require_permission,
)
from app.modules.authorization.permissions import (
Permission,
)
from app.modules.companies.exceptions import (
CompanyAlreadyExistsError,
CompanyNotFoundError,
)
from app.modules.companies.schemas import (
CompanyCreate,
CompanyResponse,
CompanyUpdate,
)
from app.modules.companies.service import (
CompanyService,
)
from app.modules.tenants.context import (
TenantContext,
)

47. Configure the Router

Add:

router = APIRouter(
prefix="/companies",
tags=["companies"],
)

Our API will expose:

/api/v1/companies

48. Create Company Endpoint

Add:

@router.post(
"",
response_model=CompanyResponse,
status_code=status.HTTP_201_CREATED,
)
def create_company(
data: CompanyCreate,
tenant: TenantContext = Depends(
require_permission(
Permission.COMPANIES_CREATE
)
),
db: Session = Depends(get_db),
) -> CompanyResponse:
service = CompanyService(db)
try:
return service.create_company(
tenant,
data,
)
except CompanyAlreadyExistsError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"A company with this name "
"already exists."
),
) from exc

This is our first real permission-protected CRM write endpoint.


49. The Create Company Request Pipeline

A request now travels through:

POST /companies
JWT
Current User
X-Organization-ID
Membership
TenantContext
companies.create
CompanyService
CompanyRepository
PostgreSQL

This is the architecture we have been building toward.


50. List Companies Endpoint

Add:

@router.get(
"",
response_model=list[CompanyResponse],
)
def list_companies(
tenant: TenantContext = Depends(
require_permission(
Permission.COMPANIES_READ
)
),
db: Session = Depends(get_db),
) -> list[CompanyResponse]:
service = CompanyService(db)
return service.list_companies(
tenant
)

A viewer can use this endpoint because:

viewer
companies.read ✓

51. Get Company Endpoint

Add:

@router.get(
"/{company_id}",
response_model=CompanyResponse,
)
def get_company(
company_id: UUID,
tenant: TenantContext = Depends(
require_permission(
Permission.COMPANIES_READ
)
),
db: Session = Depends(get_db),
) -> CompanyResponse:
service = CompanyService(db)
try:
return service.get_company(
tenant,
company_id,
)
except CompanyNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found.",
) from exc

The tenant scope is applied inside the service and repository.


52. Update Company Endpoint

Add:

@router.patch(
"/{company_id}",
response_model=CompanyResponse,
)
def update_company(
company_id: UUID,
data: CompanyUpdate,
tenant: TenantContext = Depends(
require_permission(
Permission.COMPANIES_UPDATE
)
),
db: Session = Depends(get_db),
) -> CompanyResponse:
service = CompanyService(db)
try:
return service.update_company(
tenant,
company_id,
data,
)
except CompanyNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found.",
) from exc
except CompanyAlreadyExistsError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"A company with this name "
"already exists."
),
) from exc

53. Delete Company Endpoint

Add:

@router.delete(
"/{company_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_company(
company_id: UUID,
tenant: TenantContext = Depends(
require_permission(
Permission.COMPANIES_DELETE
)
),
db: Session = Depends(get_db),
) -> Response:
service = CompanyService(db)
try:
service.delete_company(
tenant,
company_id,
)
except CompanyNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found.",
) from exc
return Response(
status_code=status.HTTP_204_NO_CONTENT
)

For the initial policy:

owner ✓
admin ✓
member ✗
viewer ✗

can delete companies.


54. Register the Company Router

Open:

app/api/v1/router.py

Add:

from app.modules.companies.router import (
router as companies_router,
)

Then:

api_router.include_router(
companies_router
)

Restart the server.


55. Inspect Swagger

Open:

http://127.0.0.1:8000/docs

You should now see:

companies

with:

POST /api/v1/companies
GET /api/v1/companies
GET /api/v1/companies/{company_id}
PATCH /api/v1/companies/{company_id}
DELETE /api/v1/companies/{company_id}

Quorentra finally exposes real CRM functionality.


56. Create the First Company

Login and obtain:

access_token

Also obtain your:

organization_id

Send:

POST /api/v1/companies

with headers:

Authorization: Bearer <token>
X-Organization-ID: <organization-id>

and body:

{
"name": "Contoso",
"website": "https://www.example.com",
"industry": "Technology",
"phone": "+31 20 000 0000",
"description": "Example CRM account."
}

Expected:

201 Created

57. Inspect the Response

The response should resemble:

{
"id": "...",
"organization_id": "...",
"name": "Contoso",
"website": "https://www.example.com",
"industry": "Technology",
"phone": "+31 20 000 0000",
"description": "Example CRM account.",
"created_at": "...",
"updated_at": "..."
}

The critical point is:

organization_id

was assigned by the backend.

The client never supplied it in the request body.


58. Verify PostgreSQL

In pgAdmin, run:

SELECT
id,
organization_id,
name,
industry
FROM companies;

You should see:

Contoso

associated with the correct Quorentra organization.

We now have persistent tenant-owned CRM data.


59. List Companies

Create a few companies.

Then send:

GET /api/v1/companies

Expected:

[
{
"id": "...",
"organization_id": "...",
"name": "Contoso"
},
{
"id": "...",
"organization_id": "...",
"name": "Fabrikam"
}
]

Only companies belonging to the selected tenant should appear.


60. Retrieve a Company

Send:

GET /api/v1/companies/{company_id}

with the correct tenant context.

Expected:

200 OK

The repository executes the equivalent of:

company_id
+
organization_id

not merely:

company_id

61. Update a Company

Send:

PATCH /api/v1/companies/{company_id}

with:

{
"industry": "Cloud Services",
"description": "Strategic cloud customer."
}

Expected:

200 OK

Fields not included in the request remain unchanged.


62. Delete a Company

As an owner or admin, send:

DELETE /api/v1/companies/{company_id}

Expected:

204 No Content

Then:

GET /api/v1/companies/{company_id}

should return:

404 Not Found

The basic CRUD lifecycle now works.


63. Test Duplicate Company Names

Create:

Contoso

Then attempt to create:

contoso

inside the same organization.

Expected:

409 Conflict

because our initial duplicate rule is case-insensitive within the tenant.


64. Duplicate Names Across Tenants

Now create:

Contoso

inside Organization A.

Then create another:

Contoso

inside Organization B.

This should succeed.

Why?

Because CRM data is tenant-owned.

The duplicate rule applies only within:

organization_id

65. Test Viewer Read Access

A viewer should be able to call:

GET /companies
GET /companies/{id}

because the viewer has:

companies.read

Expected:

200 OK

This confirms read-only CRM access works.


66. Test Viewer Create Access

As a viewer, send:

POST /companies

Expected:

403 Forbidden

because:

viewer
companies.create ✗

The request should never reach company creation logic.


67. Test Member Create Access

As a member:

POST /companies

should succeed.

Our Part 10 policy grants:

member
companies.create ✓

This reflects normal CRM usage.


68. Test Member Delete Access

As a member:

DELETE /companies/{id}

should return:

403 Forbidden

because:

member
companies.delete ✗

The permission policy is now protecting actual business data.


69. The Critical Cross-Tenant Test

Now create:

Organization A
Company A
Organization B
Company B

Login as a member of Organization A.

Then request:

GET /companies/{company-b-id}

while sending:

X-Organization-ID: Organization-A-ID

Expected:

404 Not Found

This is one of the most important tests in the entire series.


70. Why Cross-Tenant Company Access Returns 404

The user is authorized to access Organization A.

Therefore tenant resolution succeeds.

The user also has:

companies.read

Therefore permission evaluation succeeds.

But the repository asks:

Find company B
inside Organization A.

There is no such company.

Therefore:

404

This prevents the API from revealing that Company B exists elsewhere.


71. Test Cross-Tenant Update

Login as Organization A.

Attempt:

PATCH /companies/{company-b-id}

Expected:

404 Not Found

Company B must remain unchanged.

This proves tenant isolation applies to writes as well as reads.


72. Test Cross-Tenant Delete

Attempt:

DELETE /companies/{company-b-id}

from Organization A.

Expected:

404 Not Found

Then verify Company B still exists inside Organization B.

Cross-tenant destructive operations must be impossible.


73. Add Company API Tests

Create:

tests/api/test_companies.py

We want automated coverage for:

create
list
retrieve
update
delete
duplicates
permissions
tenant isolation

This module becomes the first full business API test suite.


74. Test Company Creation

Conceptually:

def test_owner_can_create_company(
authenticated_owner,
) -> None:
response = client.post(
"/api/v1/companies",
headers={
"Authorization": (
f"Bearer "
f"{authenticated_owner['token']}"
),
"X-Organization-ID": (
authenticated_owner[
"organization_id"
]
),
},
json={
"name": "Contoso",
"industry": "Technology",
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Contoso"
assert data["organization_id"] == (
authenticated_owner[
"organization_id"
]
)

This verifies tenant ownership is assigned correctly.


75. Test Company Listing

Create two companies in the same tenant.

Then:

response = client.get(
"/api/v1/companies",
headers=headers,
)
assert response.status_code == 200
assert len(response.json()) == 2

Prefer isolated database fixtures so one test does not depend on another.


76. Test Company Retrieval

Create a company.

Then:

response = client.get(
f"/api/v1/companies/{company_id}",
headers=headers,
)
assert response.status_code == 200
assert response.json()["id"] == company_id

This verifies the tenant-scoped lookup.


77. Test Company Update

Create:

Contoso

Then patch:

{
"industry": "Cloud Services"
}

Verify:

assert response.status_code == 200
assert (
response.json()["industry"]
== "Cloud Services"
)

Also verify the company name remains unchanged.


78. Test Company Deletion

Create a company.

Delete it.

Then retrieve it.

Expected:

assert delete_response.status_code == 204
assert get_response.status_code == 404

This covers the full lifecycle.


79. Test Duplicate Detection

Create:

Contoso

Then attempt:

CONTOSO

Expected:

assert response.status_code == 409

This confirms duplicate detection is case-insensitive.


80. Test Tenant List Isolation

Create:

Tenant A
├── Company A1
└── Company A2
Tenant B
└── Company B1

Call:

GET /companies

as Tenant A.

The result must contain:

A1
A2

and never:

B1

This is an essential regression test.


81. Test Tenant Record Isolation

Create Company B under Tenant B.

Then as Tenant A:

response = client.get(
f"/api/v1/companies/{company_b_id}",
headers=tenant_a_headers,
)
assert response.status_code == 404

Keep this test permanently.


82. Test Viewer Permissions

Create a viewer membership.

Verify:

GET /companies → 200
GET /companies/{id} → 200
POST /companies → 403
PATCH /companies/{id} → 403
DELETE /companies/{id} → 403

This proves RBAC is integrated into the business module.


83. Test Member Permissions

Verify:

GET /companies → 200
GET /companies/{id} → 200
POST /companies → 201
PATCH /companies/{id} → 200
DELETE /companies/{id} → 403

Again, these are business policy tests, not merely technical tests.


84. Test Owner Permissions

Verify:

GET ✓
CREATE ✓
UPDATE ✓
DELETE ✓

The owner should be able to perform the complete company lifecycle.


85. Run the Full Test Suite

Run:

python -m pytest

We now expect coverage across:

Platform
├── health
├── database
├── configuration
└── migrations
Identity
├── registration
├── password hashing
├── login
├── JWT
└── current user
Multi-Tenancy
├── organizations
├── memberships
├── tenant context
└── cross-tenant isolation
Authorization
├── roles
├── permissions
├── owner
├── admin
├── member
└── viewer
CRM
└── Companies
├── create
├── list
├── retrieve
├── update
├── delete
├── duplicates
├── RBAC
└── tenant isolation

All tests should pass.


86. Update Quorentra Version

Open:

app/core/constants.py

Change:

APP_VERSION = "0.0.8"

to:

APP_VERSION = "0.0.9"

Restart:

python -m uvicorn app.main:app --reload

Check:

GET /api/v1/health

Expected:

{
"status": "healthy",
"service": "quorentra-api",
"version": "0.0.9",
"database": "connected"
}

87. Quorentra 0.0.9

Our application status is now:

Repository ✓
FastAPI ✓
PostgreSQL ✓
SQLAlchemy ✓
Alembic ✓
Organizations ✓
Users ✓
Memberships ✓
Registration ✓
Authentication ✓
JWT ✓
Tenant Context ✓
Tenant Isolation ✓
Roles ✓
Permissions ✓
RBAC ✓
Companies ✓
Company CRUD ✓
Company Tenant Isolation ✓
Company RBAC ✓
Contacts -
Opportunities -
Activities -
Tasks -
React -
MCP -
ChatGPT -
AI -

For the first time, Quorentra is not merely an application platform.

It contains a real CRM business capability.


88. What We Have Proven

Part 11 is more significant than the number of fields in the Company table suggests.

We have proven that the complete architecture works:

Client
FastAPI
Authentication
Tenant Resolution
RBAC
Business Service
Tenant-Aware Repository
SQLAlchemy
PostgreSQL

That pattern can now be reused.


89. The Reusable CRM Module Pattern

Future CRM modules can follow:

Model
Migration
Schemas
Repository
Service
Router
Permissions
Tests

For each tenant-owned entity we ask:

Who owns the record?
What permissions protect it?
How is tenant scope enforced?
How is cross-tenant access tested?

This becomes our standard Quorentra module architecture.


90. Why This Is Better Than Building the Entire CRM First

We could have designed:

Companies
Contacts
Opportunities
Tasks
Activities
Emails
Meetings
Documents
AI
Workflows
Dashboards
Integrations

before running anything.

Instead, we now have:

A small working CRM

with one real business capability.

That is far more valuable.

We can run it.

We can test it.

We can inspect the database.

We can call the API.

We can break it.

We can improve it.

Then we add the next module.


91. We Now Have the Beginning of an MVO

The Minimum Viable Operation is taking shape.

A user can already conceptually:

Register
Create Organization
Login
Authenticate
Select Organization
Pass Authorization
Create Companies
Manage Companies

That is a genuine end-to-end application workflow.

The next objective is to make the CRM progressively more useful without destabilizing this working slice.


92. What Comes After Companies?

The next natural CRM entity is:

Contact

A company alone tells us:

Which business do we deal with?

A contact tells us:

Who do we deal with inside that business?

The relationship becomes:

Organization
Company
├── Contact
├── Contact
└── Contact

This is the next important CRM relationship.


93. Contact Ownership

Contacts should also be directly tenant-owned.

Conceptually:

Contact
├── id
├── organization_id
├── company_id
├── first_name
├── last_name
├── email
├── phone
├── job_title
├── created_at
└── updated_at

Notice both:

organization_id

and:

company_id

This may appear redundant.

It is deliberate.


94. Why Contact Should Carry organization_id

We could derive tenant ownership through:

Contact
Company
Organization

But direct tenant ownership makes queries and security enforcement simpler.

For example:

SELECT *
FROM contacts
WHERE organization_id = :tenant_id;

instead of requiring a company join for every tenant check.

It also makes it easier to support contacts without companies later.


95. Cross-Tenant Relationship Validation

When creating a Contact under Company X, Quorentra must verify:

Contact.organization_id
=
Company.organization_id
=
TenantContext.organization.id

A client must never be able to create:

Tenant A Contact
Tenant B Company

This introduces our next important multi-tenant pattern:

Tenant-safe relationships between CRM entities.


96. Preparing for ChatGPT

Companies are also the first useful entity for the future ChatGPT interface.

A user could eventually ask:

Show me all companies.

That maps to:

companies.read

Or:

Create a company called Northwind Traders.

That maps to:

companies.create

Or:

Change Contoso’s industry to cloud consulting.

That maps to:

companies.update

The application capability already exists.

ChatGPT will eventually become another interface to it.


97. Future MCP Company Tools

The Company service naturally supports future MCP tools such as:

list_companies
get_company
create_company
update_company
delete_company

Each tool can map directly to a permission:

list_companies
companies.read
create_company
companies.create
update_company
companies.update
delete_company
companies.delete

We therefore do not need to redesign Company Management when MCP arrives.


98. Why We Should Not Add MCP Yet

We now have one useful business entity.

But a CRM consisting only of companies is still too limited for a compelling conversational experience.

A better minimum ChatGPT-native CRM slice is:

Companies
Contacts
Opportunities

Then ChatGPT can answer useful questions such as:

Who are my contacts at Contoso?

What opportunities are open with Northwind?

Create an opportunity for Fabrikam.

At that point, introducing MCP becomes much more valuable.


99. Our Near-Term Modular Roadmap

The next sequence should therefore remain disciplined:

Part 11
Company Management
Part 12
Contact Management
Part 13
Opportunity Management
Part 14
Minimal CRM API Integration
Part 15
ChatGPT / MCP Foundation

This creates a compact but meaningful CRM core before adding the conversational interface.


100. Acceptance Criteria

Part 11 is complete when:

✓ companies module exists
✓ Company SQLAlchemy model exists
✓ companies table exists
✓ Company belongs to Organization
✓ organization_id is indexed
✓ Alembic migration succeeds
✓ CompanyCreate exists
✓ CompanyUpdate exists
✓ CompanyResponse exists
✓ client cannot assign organization_id
✓ CompanyRepository exists
✓ company creation is tenant-owned
✓ company listing is tenant-scoped
✓ company retrieval is tenant-scoped
✓ company update is tenant-scoped
✓ company deletion is tenant-scoped
✓ CompanyService exists
✓ duplicate company detection exists
✓ duplicate detection is tenant-specific
✓ cross-tenant records appear nonexistent
✓ POST /companies exists
✓ GET /companies exists
✓ GET /companies/{id} exists
✓ PATCH /companies/{id} exists
✓ DELETE /companies/{id} exists
✓ companies.read is enforced
✓ companies.create is enforced
✓ companies.update is enforced
✓ companies.delete is enforced
✓ viewer is read-only
✓ member can create and update
✓ member cannot delete
✓ owner can perform full CRUD
✓ cross-tenant list isolation is tested
✓ cross-tenant retrieval is tested
✓ cross-tenant updates are blocked
✓ cross-tenant deletion is blocked
✓ full test suite passes
✓ Quorentra reports version 0.0.9

Most importantly:

Quorentra now contains its first tenant-owned, permission-protected CRM business entity.


101. The Architecture After Part 11

Our architecture now looks like:

                    ┌─────────────────┐
                    │     Client      │
                    └────────┬────────┘
                             │
                             ▼
                    ┌─────────────────┐
                    │     FastAPI     │
                    └────────┬────────┘
                             │
                             ▼
                    ┌─────────────────┐
                    │ Authentication  │
                    └────────┬────────┘
                             │
                             ▼
                    ┌─────────────────┐
                    │ Tenant Context  │
                    └────────┬────────┘
                             │
                             ▼
                    ┌─────────────────┐
                    │      RBAC       │
                    └────────┬────────┘
                             │
                             ▼
                    ┌─────────────────┐
                    │ Company Service │
                    └────────┬────────┘
                             │
                             ▼
                   ┌───────────────────┐
                   │Company Repository │
                   └─────────┬─────────┘
                             │
                             ▼
                    ┌─────────────────┐
                    │   PostgreSQL    │
                    └─────────────────┘

This is no longer merely infrastructure.

This is the beginning of Quorentra CRM.


102. The Next Architectural Step

The next module should reuse everything we just proved.

We do not need another authentication architecture.

We do not need another tenant model.

We do not need another RBAC system.

We simply extend the application:

Company
Contact

and reuse:

TenantContext
Permissions
Repository Pattern
Service Pattern
API Pattern
Testing Pattern

That is the payoff from the platform work in Parts 3–10.


103. Next Article

In Part 12, we will build:

Contact Management — Adding People to the CRM

We will introduce:

  • Contact domain model;
  • tenant-owned contacts;
  • optional Company relationship;
  • Contact SQLAlchemy model;
  • Company-to-Contact relationship;
  • Alembic migration;
  • contact repository;
  • contact service;
  • create schema;
  • update schema;
  • response schema;
  • contact creation;
  • contact listing;
  • contact retrieval;
  • contact updates;
  • contact deletion;
  • company-specific contact listing;
  • tenant-safe company validation;
  • contact permissions;
  • duplicate email handling;
  • cross-tenant relationship protection;
  • contact API tests;
  • preparation for Opportunity Management;
  • preparation for ChatGPT contact tools.

The CRM model will evolve from:

Organization
Companies

to:

Organization
Companies
Contacts

And the application will evolve from:

Authenticated
Tenant-Aware
Authorized
Company-Aware

to:

Authenticated
Tenant-Aware
Authorized
Company-Aware
Contact-Aware

Part 11 is the point where Quorentra crosses from building the platform to building the product.

From here, every new module should leave us with a runnable, testable, incrementally more useful CRM.

Next, Quorentra learns about the people behind the companies.

Quorentra

Contact

Menu

Designed with WordPress

Discover more from Quorentra

Subscribe now to keep reading and get access to the full archive.

Continue reading