Quorentra

Quorentra CRM Contact Management: Building from Zero — Part 12

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

Adding tenant-owned contacts, company relationships, CRUD operations, RBAC, tenant-safe relationship validation, and automated tests.

Quorentra Contact Management - Building from Zero — Part 12
Quorentra Contact Management – Building from Zero — Part 12

1. Introduction

Part 11 transformed Quorentra from a platform foundation into the beginning of a working CRM.

We can now:

Register
Login
Select Organization
Authorize User
Create Company
Manage Companies

Our CRM data model currently looks like:

Organization
Companies

That is useful, but incomplete.

A CRM does not merely track businesses.

It tracks the people inside those businesses.

A company called Contoso becomes significantly more useful when Quorentra knows:

Contoso
├── Alice Johnson — CTO
├── Robert Smith — Procurement Manager
└── Sarah Williams — Account Manager

That is the responsibility of Contact Management.

In Part 12, we will build the second real CRM module:

Contacts

By the end of this article, Quorentra will support:

Create Contact
Read Contact
List Contacts
Update Contact
Delete Contact
Associate Contact with Company
List Contacts for Company

while continuing to enforce:

Authentication
+
Tenant Isolation
+
RBAC
+
Tenant-Safe Relationships

Quorentra will reach:

Quorentra 0.0.10 — Contact Management


2. Why Contacts Come After Companies

Companies answer:

Which businesses are we dealing with?

Contacts answer:

Which people are we dealing with?

The relationship becomes:

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

For example:

Quorentra Consulting
Contoso
├── Alice Johnson
├── Robert Smith
└── Sarah Williams

This is one of the fundamental structures of almost every CRM.


3. Contacts Should Also Work Without Companies

Not every contact necessarily belongs to a known company.

For example:

Conference Lead
Independent Consultant
Freelancer
Individual Customer
Unclassified Prospect

Therefore:

company_id

should be optional.

Our model becomes:

Organization
├── Company
│ │
│ └── Contacts
└── Standalone Contacts

This gives us more flexibility without significantly increasing complexity.


4. Contact Tenant Ownership

A Contact will contain both:

organization_id

and optionally:

company_id

Conceptually:

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

The critical security field remains:

organization_id

A Contact belongs directly to the tenant.


5. Why Store organization_id on Contact?

At first glance, this might appear redundant.

If:

Contact
Company
Organization

then perhaps the organization could always be derived through the Company.

But that creates several problems.

First, standalone contacts would have no Company.

Second, tenant-scoped queries would require unnecessary joins.

Third, tenant isolation becomes less explicit.

Instead:

Contact
├── organization_id
└── company_id

makes tenant ownership direct.

We can query:

SELECT *
FROM contacts
WHERE organization_id = :organization_id;

without needing the Company table.


6. The New Security Challenge

Part 11 introduced tenant-owned records.

Part 12 introduces something new:

Relationships between tenant-owned records.

Suppose:

Organization A
└── Company A
Organization B
└── Contact B

We must prevent:

Contact B
Company A

because the records belong to different tenants.

The fundamental rule becomes:

A relationship between tenant-owned entities is valid only when both entities belong to the same tenant.

This principle will become extremely important throughout Quorentra.


7. Tenant-Safe Relationships

When creating:

Contact
Company

we must verify:

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

The client may provide:

company_id

but the client does not determine whether that relationship is valid.

Quorentra does.


8. Starting Checkpoint

Part 12 assumes Part 11 is working.

Run:

cd quorentra\backend
python -m pytest

All tests should pass.

Start:

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

Verify:

GET /api/v1/health

Expected:

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

Also verify that Company CRUD still works.


9. Define Contact Permissions

Part 10 already anticipated Contact permissions.

Open:

app/modules/authorization/permissions.py

Verify these exist:

CONTACTS_READ = "contacts.read"
CONTACTS_CREATE = "contacts.create"
CONTACTS_UPDATE = "contacts.update"
CONTACTS_DELETE = "contacts.delete"

If they were not added previously, add them now.


10. Verify Role Policies

Open:

app/modules/authorization/policy.py

Our MVP policy should give:

                    Owner  Admin  Member  Viewer

contacts.read          ✓      ✓      ✓       ✓
contacts.create        ✓      ✓      ✓       ✗
contacts.update        ✓      ✓      ✓       ✗
contacts.delete        ✓      ✓      ✗       ✗

This mirrors our Company policy.

Normal members can perform routine CRM work.

Destructive deletion remains restricted.


11. Create the Contacts Module

Create:

app/modules/contacts/

From:

quorentra/backend

run:

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

The module becomes:

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

We reuse the same module structure established for Companies.


12. Create the Contact SQLAlchemy Model

Open:

app/modules/contacts/model.py

Add:

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 Contact(Base):
__tablename__ = "contacts"
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,
)
company_id: Mapped[UUID | None] = mapped_column(
ForeignKey(
"companies.id",
ondelete="SET NULL",
),
nullable=True,
index=True,
)
first_name: Mapped[str] = mapped_column(
String(150),
nullable=False,
)
last_name: Mapped[str] = mapped_column(
String(150),
nullable=False,
)
email: Mapped[str | None] = mapped_column(
String(320),
nullable=True,
)
phone: Mapped[str | None] = mapped_column(
String(100),
nullable=True,
)
job_title: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
)
notes: Mapped[str | None] = mapped_column(
Text,
nullable=True,
)

If your project uses TimestampMixin, include it as with Company.


13. Add Timestamp Support

For example:

class Contact(
TimestampMixin,
Base,
):
__tablename__ = "contacts"

This provides:

created_at
updated_at

without duplicating timestamp implementation.


14. Why company_id Uses SET NULL

We defined:

ON DELETE SET NULL

for the Company relationship.

This is deliberate.

Suppose:

Contoso
└── Alice Johnson

and Contoso is deleted.

Should Alice automatically disappear?

Not necessarily.

Alice may still be valuable CRM data.

Instead:

Delete Company
Contact remains
company_id = NULL

The Contact becomes standalone.

This is a safer default for the MVO.


15. Why Organization Deletion Cascades

The tenant relationship is different.

If an entire Quorentra organization is deleted, its tenant-owned CRM data should not survive independently.

Therefore:

Organization
Contacts

uses:

ON DELETE CASCADE

Conceptually:

Delete tenant
Delete tenant-owned CRM data

Actual organization deletion should eventually be heavily protected and audited, but the relational model should still be coherent.


16. Add ORM Relationships

We can make navigation easier with SQLAlchemy relationships.

In the Contact model:

from sqlalchemy.orm import relationship

Then:

company = relationship(
"Company",
back_populates="contacts",
)

Now update:

app/modules/companies/model.py

and add:

contacts = relationship(
"Contact",
back_populates="company",
)

The ORM relationship becomes:

Company
└── contacts
Contact
└── company

17. Do ORM Relationships Provide Tenant Security?

No.

This is important.

SQLAlchemy:

contact.company

does not automatically enforce Quorentra’s tenant policy.

The database foreign key also only verifies:

company_id exists

It does not automatically verify:

company.organization_id
=
contact.organization_id

That validation remains an application responsibility.


18. Register the Contact Model

Open the central SQLAlchemy model registration file, for example:

app/db/models.py

Add:

from app.modules.contacts.model import Contact

Ensure both:

Company
Contact

are imported before Alembic scans metadata.


19. Generate the Contact Migration

Run:

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

Review the migration.

It should create:

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

20. Verify Foreign Keys

The migration should contain:

contacts.organization_id
organizations.id

with:

ON DELETE CASCADE

and:

contacts.company_id
companies.id

with:

ON DELETE SET NULL

Also verify indexes on:

organization_id
company_id

21. Apply the Migration

Run:

alembic upgrade head

Then:

alembic current

Verify the new revision is active.


22. Verify in pgAdmin

Open:

Schemas
public
Tables

You should now see:

contacts

Inspect:

Columns
Constraints
Foreign Keys
Indexes

Quorentra now has its second CRM entity.


23. Create Contact Schemas

Open:

app/modules/contacts/schemas.py

Add:

from datetime import datetime
from uuid import UUID
from pydantic import (
BaseModel,
ConfigDict,
EmailStr,
Field,
)

We need:

ContactCreate
ContactUpdate
ContactResponse

24. Contact Create Schema

Add:

class ContactCreate(BaseModel):
company_id: UUID | None = None
first_name: str = Field(
min_length=1,
max_length=150,
)
last_name: str = Field(
min_length=1,
max_length=150,
)
email: EmailStr | None = None
phone: str | None = Field(
default=None,
max_length=100,
)
job_title: str | None = Field(
default=None,
max_length=255,
)
notes: str | None = None

Again, notice what is absent:

organization_id

The client never chooses tenant ownership.


25. Why company_id Is Allowed

Unlike:

organization_id

the client may select:

company_id

because choosing a CRM relationship is a legitimate business operation.

However:

Client selection is not relationship authorization.

Quorentra must validate that the selected Company belongs to the current tenant.


26. Contact Update Schema

Add:

class ContactUpdate(BaseModel):
company_id: UUID | None = None
first_name: str | None = Field(
default=None,
min_length=1,
max_length=150,
)
last_name: str | None = Field(
default=None,
min_length=1,
max_length=150,
)
email: EmailStr | None = None
phone: str | None = Field(
default=None,
max_length=100,
)
job_title: str | None = Field(
default=None,
max_length=255,
)
notes: str | None = None

There is one subtle issue here that we will address shortly:

company_id omitted

and:

company_id = null

must mean different things.


27. PATCH Semantics

For a partial update:

{
"job_title": "CTO"
}

should mean:

Change only the job title.

But:

{
"company_id": null
}

should mean:

Remove this Contact from its Company.

Pydantic’s:

model_dump(exclude_unset=True)

lets us distinguish these cases.

That will be important in the service.


28. Contact Response Schema

Add:

class ContactResponse(BaseModel):
model_config = ConfigDict(
from_attributes=True
)
id: UUID
organization_id: UUID
company_id: UUID | None
first_name: str
last_name: str
email: EmailStr | None
phone: str | None
job_title: str | None
notes: str | None
created_at: datetime
updated_at: datetime

For now, we return:

company_id

rather than embedding the complete Company.

That keeps the response simple.


29. Create Contact Exceptions

Open:

app/modules/contacts/exceptions.py

Add:

class ContactError(Exception):
pass
class ContactNotFoundError(
ContactError
):
pass
class ContactAlreadyExistsError(
ContactError
):
pass
class InvalidContactCompanyError(
ContactError
):
pass

The last exception handles tenant-unsafe Company relationships.


30. Build the Contact Repository

Open:

app/modules/contacts/repository.py

Add:

from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.modules.contacts.model import Contact

Then:

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

31. Create Contact

Add:

def create(
self,
contact: Contact,
) -> Contact:
self.db.add(contact)
self.db.flush()
self.db.refresh(contact)
return contact

As before, transaction ownership remains with the service.


32. List Contacts by Tenant

Add:

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

Every tenant-wide Contact query remains explicitly scoped.


33. Get Contact by Tenant

Add:

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

Again, we deliberately avoid:

get_by_id(contact_id)

for tenant-owned records.


34. List Contacts for a Company

Add:

def list_by_company_and_organization(
self,
company_id: UUID,
organization_id: UUID,
) -> list[Contact]:
statement = (
select(Contact)
.where(
Contact.company_id == company_id,
Contact.organization_id
== organization_id,
)
.order_by(
Contact.last_name.asc(),
Contact.first_name.asc(),
)
)
return list(
self.db.scalars(statement).all()
)

Notice that we still include:

organization_id

even though company_id is already present.

That is intentional defense in depth.


35. Find Contact by Email

For basic duplicate handling:

def get_by_email_and_organization(
self,
email: str,
organization_id: UUID,
) -> Contact | None:
statement = select(Contact).where(
Contact.organization_id
== organization_id,
func.lower(Contact.email)
== email.lower(),
)
return self.db.scalar(statement)

Email uniqueness is evaluated inside the tenant.


36. Should Email Be Globally Unique?

No.

The same person might legitimately exist in multiple Quorentra organizations.

For example:

alice@example.com

may appear in:

Tenant A CRM
Tenant B CRM

Those tenants are isolated.

Therefore global uniqueness would be incorrect.


37. Should Email Be Mandatory?

Not for the MVO.

A CRM may contain contacts known only by:

name
phone
meeting
company

Therefore:

email

remains optional.

If an email is supplied, we use it for duplicate detection.


38. Delete Contact

Add:

def delete(
self,
contact: Contact,
) -> None:
self.db.delete(contact)
self.db.flush()

The repository again handles persistence, not authorization.


39. Build the Contact Service

Open:

app/modules/contacts/service.py

Import:

from uuid import UUID
from sqlalchemy.orm import Session
from app.modules.companies.repository import (
CompanyRepository,
)
from app.modules.contacts.exceptions import (
ContactAlreadyExistsError,
ContactNotFoundError,
InvalidContactCompanyError,
)
from app.modules.contacts.model import Contact
from app.modules.contacts.repository import (
ContactRepository,
)
from app.modules.contacts.schemas import (
ContactCreate,
ContactUpdate,
)
from app.modules.tenants.context import TenantContext

Then:

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

This service coordinates Contacts and Companies.


40. Validate the Company Relationship

Create a private helper:

def _validate_company(
self,
tenant: TenantContext,
company_id: UUID,
) -> None:
company = (
self.companies
.get_by_id_and_organization(
company_id=company_id,
organization_id=(
tenant.organization.id
),
)
)
if company is None:
raise InvalidContactCompanyError

This is the key tenant-safe relationship check.


41. Why Use the Tenant-Scoped Company Repository?

We could query:

Company.id == company_id

and then compare its organization.

But we already built the safer abstraction:

get_by_id_and_organization()

in Part 11.

Reuse it.

The question becomes:

Does this Company exist inside the current tenant?

If not, the relationship is invalid.


42. Create Contact Business Logic

Add:

def create_contact(
self,
tenant: TenantContext,
data: ContactCreate,
) -> Contact:
first_name = data.first_name.strip()
last_name = data.last_name.strip()
if data.company_id is not None:
self._validate_company(
tenant,
data.company_id,
)
if data.email is not None:
existing = (
self.repository
.get_by_email_and_organization(
email=str(data.email),
organization_id=(
tenant.organization.id
),
)
)
if existing is not None:
raise ContactAlreadyExistsError
contact = Contact(
organization_id=(
tenant.organization.id
),
company_id=data.company_id,
first_name=first_name,
last_name=last_name,
email=(
str(data.email)
if data.email is not None
else None
),
phone=data.phone,
job_title=data.job_title,
notes=data.notes,
)
contact = self.repository.create(
contact
)
self.db.commit()
self.db.refresh(contact)
return contact

Again, tenant ownership comes from TenantContext.


43. Create a Standalone Contact

Because:

company_id

is optional, this request is valid:

{
"first_name": "Alice",
"last_name": "Johnson",
"email": "alice@example.com"
}

The resulting record has:

organization_id = current tenant
company_id = NULL

This is a fully valid Contact.


44. Create a Company Contact

This request is also valid:

{
"company_id": "company-uuid",
"first_name": "Alice",
"last_name": "Johnson",
"email": "alice@example.com",
"job_title": "CTO"
}

Before inserting, Quorentra verifies:

company-uuid
belongs to current organization?
Yes
Create Contact

45. Attempt a Cross-Tenant Relationship

Suppose:

Organization A
└── Company A
Organization B
└── User B

User B sends:

{
"company_id": "company-a-id",
"first_name": "Alice",
"last_name": "Johnson"
}

The service asks:

Does Company A exist
inside Organization B?

Answer:

No

Therefore:

InvalidContactCompanyError

is raised.

The relationship is never created.


46. List Contacts

Add:

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

This returns:

all contacts
inside current tenant

including both:

company contacts
standalone contacts

47. Get Contact

Add:

def get_contact(
self,
tenant: TenantContext,
contact_id: UUID,
) -> Contact:
contact = (
self.repository
.get_by_id_and_organization(
contact_id=contact_id,
organization_id=(
tenant.organization.id
),
)
)
if contact is None:
raise ContactNotFoundError
return contact

Cross-tenant Contacts appear nonexistent.


48. List Contacts for Company

Add:

def list_company_contacts(
self,
tenant: TenantContext,
company_id: UUID,
) -> list[Contact]:
self._validate_company(
tenant,
company_id,
)
return (
self.repository
.list_by_company_and_organization(
company_id=company_id,
organization_id=(
tenant.organization.id
),
)
)

We validate the Company before listing its Contacts.


49. Why Validate the Company First?

Without validation, querying:

company_id + organization_id

would safely return an empty list for another tenant’s Company.

That is secure.

However, validating first gives us better semantics.

We can distinguish:

Valid company with zero contacts

from:

Company not available in this tenant

while still not revealing cross-tenant existence.


50. Update Contact

Add:

def update_contact(
self,
tenant: TenantContext,
contact_id: UUID,
data: ContactUpdate,
) -> Contact:
contact = self.get_contact(
tenant,
contact_id,
)
update_data = data.model_dump(
exclude_unset=True
)
if "company_id" in update_data:
company_id = update_data["company_id"]
if company_id is not None:
self._validate_company(
tenant,
company_id,
)
if "first_name" in update_data:
update_data["first_name"] = (
update_data["first_name"].strip()
)
if "last_name" in update_data:
update_data["last_name"] = (
update_data["last_name"].strip()
)
if (
"email" in update_data
and update_data["email"] is not None
):
email = str(update_data["email"])
existing = (
self.repository
.get_by_email_and_organization(
email=email,
organization_id=(
tenant.organization.id
),
)
)
if (
existing is not None
and existing.id != contact.id
):
raise ContactAlreadyExistsError
update_data["email"] = email
for field, value in update_data.items():
setattr(
contact,
field,
value,
)
self.db.commit()
self.db.refresh(contact)
return contact

This safely handles both field updates and Company reassignment.


51. Removing a Contact from a Company

Send:

{
"company_id": null
}

Because we use:

exclude_unset=True

Quorentra understands that company_id was deliberately supplied.

The update becomes:

company_id = NULL

The Contact becomes standalone.


52. Moving a Contact to Another Company

Send:

{
"company_id": "another-company-id"
}

Quorentra validates:

another-company-id
belongs to current tenant?

Only then is the relationship changed.

This prevents cross-tenant reassignment.


53. Delete Contact

Add:

def delete_contact(
self,
tenant: TenantContext,
contact_id: UUID,
) -> None:
contact = self.get_contact(
tenant,
contact_id,
)
self.repository.delete(contact)
self.db.commit()

The tenant-scoped lookup protects the destructive operation.


54. Build the Contact Router

Open:

app/modules/contacts/router.py

Add the necessary 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.contacts.exceptions import (
ContactAlreadyExistsError,
ContactNotFoundError,
InvalidContactCompanyError,
)
from app.modules.contacts.schemas import (
ContactCreate,
ContactResponse,
ContactUpdate,
)
from app.modules.contacts.service import (
ContactService,
)
from app.modules.tenants.context import (
TenantContext,
)

55. Configure the Router

Add:

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

Our main Contact API will live under:

/api/v1/contacts

56. Create Contact Endpoint

Add:

@router.post(
"",
response_model=ContactResponse,
status_code=status.HTTP_201_CREATED,
)
def create_contact(
data: ContactCreate,
tenant: TenantContext = Depends(
require_permission(
Permission.CONTACTS_CREATE
)
),
db: Session = Depends(get_db),
) -> ContactResponse:
service = ContactService(db)
try:
return service.create_contact(
tenant,
data,
)
except ContactAlreadyExistsError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"A contact with this email "
"already exists."
),
) from exc
except InvalidContactCompanyError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found.",
) from exc

The Company error deliberately becomes:

404

rather than revealing that a Company exists elsewhere.


57. List Contacts Endpoint

Add:

@router.get(
"",
response_model=list[ContactResponse],
)
def list_contacts(
tenant: TenantContext = Depends(
require_permission(
Permission.CONTACTS_READ
)
),
db: Session = Depends(get_db),
) -> list[ContactResponse]:
service = ContactService(db)
return service.list_contacts(
tenant
)

This lists all tenant Contacts.


58. Get Contact Endpoint

Add:

@router.get(
"/{contact_id}",
response_model=ContactResponse,
)
def get_contact(
contact_id: UUID,
tenant: TenantContext = Depends(
require_permission(
Permission.CONTACTS_READ
)
),
db: Session = Depends(get_db),
) -> ContactResponse:
service = ContactService(db)
try:
return service.get_contact(
tenant,
contact_id,
)
except ContactNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Contact not found.",
) from exc

59. Update Contact Endpoint

Add:

@router.patch(
"/{contact_id}",
response_model=ContactResponse,
)
def update_contact(
contact_id: UUID,
data: ContactUpdate,
tenant: TenantContext = Depends(
require_permission(
Permission.CONTACTS_UPDATE
)
),
db: Session = Depends(get_db),
) -> ContactResponse:
service = ContactService(db)
try:
return service.update_contact(
tenant,
contact_id,
data,
)
except ContactNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Contact not found.",
) from exc
except ContactAlreadyExistsError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=(
"A contact with this email "
"already exists."
),
) from exc
except InvalidContactCompanyError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found.",
) from exc

60. Delete Contact Endpoint

Add:

@router.delete(
"/{contact_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
def delete_contact(
contact_id: UUID,
tenant: TenantContext = Depends(
require_permission(
Permission.CONTACTS_DELETE
)
),
db: Session = Depends(get_db),
) -> Response:
service = ContactService(db)
try:
service.delete_contact(
tenant,
contact_id,
)
except ContactNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Contact not found.",
) from exc
return Response(
status_code=status.HTTP_204_NO_CONTENT
)

61. Company-Specific Contact Endpoint

We also want:

GET /companies/{company_id}/contacts

There are several ways to structure this.

For now, we can add a small route to the Contact router with an explicit path:

@router.get(
"/company/{company_id}",
response_model=list[ContactResponse],
)
def list_company_contacts(
company_id: UUID,
tenant: TenantContext = Depends(
require_permission(
Permission.CONTACTS_READ
)
),
db: Session = Depends(get_db),
) -> list[ContactResponse]:
service = ContactService(db)
try:
return service.list_company_contacts(
tenant,
company_id,
)
except InvalidContactCompanyError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company not found.",
) from exc

This gives us:

GET /api/v1/contacts/company/{company_id}

A later API cleanup can introduce nested resources if desired.


62. Register the Contact Router

Open:

app/api/v1/router.py

Add:

from app.modules.contacts.router import (
router as contacts_router,
)

Then:

api_router.include_router(
contacts_router
)

Restart Quorentra.


63. Inspect Swagger

Open:

http://127.0.0.1:8000/docs

You should now see:

contacts

with:

POST /api/v1/contacts
GET /api/v1/contacts
GET /api/v1/contacts/{contact_id}
PATCH /api/v1/contacts/{contact_id}
DELETE /api/v1/contacts/{contact_id}
GET /api/v1/contacts/company/{company_id}

Our CRM API is expanding modularly.


64. Create a Standalone Contact

Send:

POST /api/v1/contacts

with:

{
"first_name": "Alice",
"last_name": "Johnson",
"email": "alice@example.com",
"phone": "+31 20 000 0000",
"job_title": "Technology Consultant"
}

with:

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

Expected:

201 Created

The response should contain:

organization_id = current organization
company_id = null

65. Create a Contact for Contoso

First create or retrieve:

Contoso

Then send:

{
"company_id": "<contoso-id>",
"first_name": "Robert",
"last_name": "Smith",
"email": "robert@example.com",
"job_title": "Procurement Manager"
}

Expected:

201 Created

Now:

Contoso
└── Robert Smith

exists inside the CRM.


66. Verify in PostgreSQL

Run:

SELECT
id,
organization_id,
company_id,
first_name,
last_name,
email
FROM contacts;

Verify:

organization_id

matches the tenant.

For company Contacts, verify:

company_id

references the correct Company.


67. List All Contacts

Send:

GET /api/v1/contacts

Expected:

[
{
"first_name": "Alice",
"last_name": "Johnson",
"company_id": null
},
{
"first_name": "Robert",
"last_name": "Smith",
"company_id": "..."
}
]

Both standalone and company Contacts appear.


68. List Contacts for Contoso

Send:

GET /api/v1/contacts/company/{contoso-id}

Only Contacts associated with Contoso should be returned.

Standalone Contacts must not appear.

Contacts associated with another Company must not appear.

Contacts from another tenant must never appear.


69. Update a Contact

Send:

PATCH /api/v1/contacts/{contact_id}

with:

{
"job_title": "Chief Technology Officer"
}

Expected:

200 OK

Other fields remain unchanged.


70. Remove a Contact from a Company

Send:

{
"company_id": null
}

Expected:

200 OK

The Contact remains in the CRM but becomes standalone.


71. Move a Contact to Another Company

Create:

Fabrikam

Then:

PATCH /contacts/{contact_id}

with:

{
"company_id": "<fabrikam-id>"
}

Expected:

200 OK

provided Fabrikam belongs to the same tenant.


72. Attempt Cross-Tenant Company Assignment

Create:

Tenant A
└── Company A
Tenant B
└── Contact B

As Tenant B, attempt:

{
"company_id": "<company-a-id>"
}

Expected:

404 Not Found

The Contact remains unchanged.

This is our first cross-tenant relationship security test.


73. Test Duplicate Email

Create:

alice@example.com

Then attempt to create:

ALICE@example.com

inside the same tenant.

Expected:

409 Conflict

The comparison is case-insensitive.


74. Same Email Across Tenants

Create:

alice@example.com

inside Tenant A.

Then create:

alice@example.com

inside Tenant B.

This should succeed.

Contact duplicate detection is tenant-scoped.


75. Test Viewer Access

A viewer should be able to:

GET /contacts
GET /contacts/{id}
GET /contacts/company/{company_id}

Expected:

200 OK

But:

POST
PATCH
DELETE

should be denied.


76. Test Member Access

A member should be able to:

Read Contacts ✓
Create Contacts ✓
Update Contacts ✓
Delete Contacts ✗

This mirrors Company authorization.


77. Test Owner and Admin Access

Owner and Admin should have:

Read ✓
Create ✓
Update ✓
Delete ✓

Again, our centralized permission policy controls this behavior.

The Contact module does not need hard-coded role names.


78. Create Contact Tests

Create:

tests/api/test_contacts.py

We want coverage for:

create
standalone contact
company contact
list
retrieve
update
remove company
move company
delete
duplicate email
RBAC
tenant isolation
cross-tenant company relationship

79. Test Standalone Contact Creation

Conceptually:

def test_create_standalone_contact(
authenticated_owner,
) -> None:
response = client.post(
"/api/v1/contacts",
headers=owner_headers(
authenticated_owner
),
json={
"first_name": "Alice",
"last_name": "Johnson",
"email": "alice@example.com",
},
)
assert response.status_code == 201
data = response.json()
assert data["company_id"] is None
assert data["organization_id"] == (
authenticated_owner[
"organization_id"
]
)

This verifies direct tenant ownership.


80. Test Company Contact Creation

Create a Company in the same tenant.

Then create a Contact with:

company_id

Verify:

assert response.status_code == 201
assert (
response.json()["company_id"]
== company_id
)

This verifies a valid tenant-safe relationship.


81. Test Cross-Tenant Company Rejection

Create:

Tenant A → Company A
Tenant B → User B

As Tenant B:

response = client.post(
"/api/v1/contacts",
headers=tenant_b_headers,
json={
"company_id": company_a_id,
"first_name": "Alice",
"last_name": "Johnson",
},
)
assert response.status_code == 404

This test should remain permanently.


82. Test Contact List Isolation

Create:

Tenant A
├── Alice
└── Robert
Tenant B
└── Sarah

As Tenant A:

GET /contacts

must return:

Alice
Robert

and never:

Sarah

83. Test Contact Record Isolation

As Tenant A, request a Contact belonging to Tenant B:

GET /contacts/{tenant-b-contact-id}

Expected:

404 Not Found

The same rule established for Companies applies to Contacts.


84. Test Cross-Tenant Update

As Tenant A:

PATCH /contacts/{tenant-b-contact-id}

Expected:

404

Verify the Tenant B Contact remains unchanged.


85. Test Cross-Tenant Delete

As Tenant A:

DELETE /contacts/{tenant-b-contact-id}

Expected:

404

Verify the Contact still exists for Tenant B.


86. Test Duplicate Email Isolation

Create:

Tenant A → alice@example.com

Then:

Tenant A → ALICE@example.com

Expected:

409

But:

Tenant B → alice@example.com

should succeed.

This verifies that duplicate rules do not leak across tenant boundaries.


87. Test Company Deletion Behavior

Create:

Company
Contact

Delete the Company.

Then retrieve the Contact.

Expected:

Contact still exists
company_id = null

This verifies:

ON DELETE SET NULL

works as intended.


88. Why This Test Matters

Database deletion behavior is part of the business model.

Without a test, a future migration might accidentally change:

SET NULL

to:

CASCADE

and deleting a Company could unexpectedly destroy its Contacts.

Tests protect architectural decisions as well as API behavior.


89. Run the Full Test Suite

Run:

python -m pytest

The application now has tests covering:

Platform
Identity
Authentication
Multi-Tenancy
Authorization
Companies
├── CRUD
├── RBAC
└── Tenant Isolation
Contacts
├── CRUD
├── Standalone Contacts
├── Company Relationships
├── Duplicate Detection
├── RBAC
├── Tenant Isolation
└── Cross-Tenant Relationship Protection

All tests should pass.


90. Update Quorentra Version

Open:

app/core/constants.py

Change:

APP_VERSION = "0.0.9"

to:

APP_VERSION = "0.0.10"

Restart:

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

Check:

GET /api/v1/health

Expected:

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

91. Quorentra 0.0.10

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 ✓
Contacts ✓
Contact CRUD ✓
Company Relationships ✓
Standalone Contacts ✓
Contact Tenant Isolation ✓
Relationship Isolation ✓
Opportunities -
Activities -
Tasks -
React -
MCP -
ChatGPT -
AI -

The CRM is becoming useful.


92. Our Domain Model Is Taking Shape

After Part 12:

Organization
├───────────────┐
│ │
▼ ▼
Company Contact
│ ▲
└───────────────┘
optional
relationship

More naturally:

Organization
├── Companies
│ │
│ └── Contacts
└── Standalone Contacts

This is already recognizable as a CRM domain.


93. We Have Introduced a New Architectural Pattern

Part 11 established:

Tenant-owned entities.

Part 12 establishes:

Tenant-safe relationships.

That pattern will be reused extensively.

For example:

Opportunity → Company
Opportunity → Contact
Task → Company
Task → Contact
Task → Opportunity
Activity → Company
Activity → Contact
Activity → Opportunity

Every relationship must respect the current tenant.


94. Never Trust a Foreign Key from the Client

This deserves a general rule.

If a request contains:

company_id
contact_id
opportunity_id
task_id

Quorentra must not assume the referenced record is valid merely because the UUID exists.

The application must verify:

Referenced record
belongs to current tenant?

Only then may the relationship be established.


95. The General Tenant-Safe Relationship Rule

We can now formulate another Quorentra architectural principle:

Every client-supplied reference to a tenant-owned entity must be resolved within the current TenantContext.

That means:

get_by_id_and_organization()

rather than:

get_by_id()

for relationship validation.

This simple rule prevents an entire class of cross-tenant vulnerabilities.


96. What Comes Next?

We now know:

Which companies do we work with?

and:

Who do we know at those companies?

The next CRM question is:

What business are we trying to win?

That introduces:

Opportunity

The model begins evolving toward:

Company
├── Contacts
└── Opportunities

This is where Quorentra starts becoming a sales CRM rather than merely an address book.


97. The Opportunity Entity

Our first Opportunity model can remain intentionally small:

Opportunity
├── id
├── organization_id
├── company_id
├── name
├── stage
├── amount
├── currency
├── probability
├── expected_close_date
├── description
├── created_at
└── updated_at

This gives us enough information to represent a sales pipeline.


98. Opportunity Stages

For the first implementation, we can define a small stage vocabulary:

qualification
discovery
proposal
negotiation
won
lost

The pipeline becomes:

Qualification
Discovery
Proposal
Negotiation
┌──┴──┐
▼ ▼
Won Lost

We should resist building a fully configurable pipeline immediately.

Static MVP stages are enough to prove the capability.


99. Opportunities Introduce More Business Logic

Companies and Contacts are largely record management.

Opportunities introduce more behavior:

stage
amount
probability
expected close date
won/lost state
pipeline value

This will let us begin adding useful CRM calculations.

For example:

Pipeline Value

and:

Weighted Pipeline
=
Amount × Probability

That will be our first step toward CRM analytics.


100. Opportunities Are Also Important for ChatGPT

Once Opportunities exist, the future conversational interface becomes much more interesting.

Users could ask:

What opportunities are open with Contoso?

Show opportunities expected to close this month.

Create a €50,000 opportunity for Fabrikam.

Move the Northwind opportunity to proposal.

What is my total open pipeline?

These are genuine CRM tasks.


101. Future ChatGPT Tool Mapping

We will eventually have application capabilities such as:

Companies
├── list_companies
├── get_company
└── create_company
Contacts
├── list_contacts
├── get_contact
└── create_contact
Opportunities
├── list_opportunities
├── get_opportunity
├── create_opportunity
└── update_opportunity

These map naturally into future MCP tools.

The application services remain the authoritative execution layer.


102. Why MCP Still Comes After Opportunities

We now have:

Companies
Contacts

That is enough for some conversational functionality.

But adding Opportunities gives ChatGPT something much closer to genuine CRM intelligence.

Then we can expose a compact vertical slice:

Company
+
Contact
+
Opportunity

through MCP.

This is a much stronger first ChatGPT-native milestone.


103. The Modular Roadmap

Our near-term sequence remains:

Part 11
Company Management
Part 12
Contact Management
Part 13
Opportunity Management
Part 14
Minimal CRM Integration
Part 15
MCP Foundation
Part 16
ChatGPT-Native CRM

This keeps each step runnable and understandable.


104. Acceptance Criteria

Part 12 is complete when:

✓ contacts module exists
✓ Contact SQLAlchemy model exists
✓ contacts table exists
✓ Contact belongs directly to Organization
✓ Contact may optionally belong to Company
✓ organization_id is indexed
✓ company_id is indexed
✓ Company deletion sets company_id to NULL
✓ Organization deletion cascades Contacts
✓ ContactCreate exists
✓ ContactUpdate exists
✓ ContactResponse exists
✓ client cannot assign organization_id
✓ client may select company_id
✓ ContactRepository exists
✓ contact creation is tenant-owned
✓ contact listing is tenant-scoped
✓ contact retrieval is tenant-scoped
✓ company contact listing is tenant-scoped
✓ ContactService exists
✓ Company relationship is validated
✓ cross-tenant Company assignment is rejected
✓ Company reassignment is validated
✓ Company relationship can be removed
✓ duplicate email detection exists
✓ duplicate detection is tenant-specific
✓ POST /contacts exists
✓ GET /contacts exists
✓ GET /contacts/{id} exists
✓ PATCH /contacts/{id} exists
✓ DELETE /contacts/{id} exists
✓ company-specific Contact listing exists
✓ contacts.read is enforced
✓ contacts.create is enforced
✓ contacts.update is enforced
✓ contacts.delete is enforced
✓ viewer is read-only
✓ member can create and update
✓ member cannot delete
✓ owner and admin can perform full CRUD
✓ cross-tenant Contact reads are blocked
✓ cross-tenant Contact updates are blocked
✓ cross-tenant Contact deletion is blocked
✓ cross-tenant Company relationships are blocked
✓ Company deletion preserves Contacts
✓ full test suite passes
✓ Quorentra reports version 0.0.10

Most importantly:

Quorentra now safely manages relationships between tenant-owned CRM entities.


105. Architecture After Part 12

The business architecture now becomes:

                 ┌──────────────────┐
                 │   Organization   │
                 └────────┬─────────┘
                          │
                Tenant Ownership
                          │
             ┌────────────┴────────────┐
             │                         │
             ▼                         ▼
      ┌─────────────┐           ┌─────────────┐
      │   Company   │◄──────────│   Contact   │
      └─────────────┘ optional  └─────────────┘

The runtime architecture remains:

Client
FastAPI
Authentication
TenantContext
RBAC
ContactService
├── ContactRepository
└── CompanyRepository
PostgreSQL

The important new behavior occurs here:

ContactService
Validate referenced Company
inside current TenantContext
Create relationship

106. The MVO Is Becoming a CRM

Our working application can now support:

User Registration
Authentication
Organization Context
RBAC
Company Management
Contact Management

A user can:

Create Contoso
Add Alice Johnson
Add Robert Smith
Manage those relationships

This is no longer just infrastructure.

It is a small but functional CRM.


107. Next Article

In Part 13, we will build:

Opportunity Management — Building the First Sales Pipeline

We will introduce:

  • Opportunity domain model;
  • tenant-owned Opportunities;
  • Company-to-Opportunity relationships;
  • tenant-safe Company validation;
  • Opportunity stages;
  • opportunity amount;
  • currency;
  • probability;
  • expected close date;
  • won/lost states;
  • Opportunity SQLAlchemy model;
  • Alembic migration;
  • Opportunity repository;
  • Opportunity service;
  • Opportunity schemas;
  • Opportunity CRUD;
  • company-specific Opportunity listing;
  • stage filtering;
  • open Opportunity filtering;
  • RBAC;
  • tenant isolation;
  • cross-tenant relationship protection;
  • pipeline value calculations;
  • weighted pipeline calculations;
  • Opportunity API tests;
  • preparation for the minimal CRM integration layer;
  • preparation for MCP;
  • preparation for ChatGPT-native sales workflows.

Our CRM model will evolve from:

Organization
├── Companies
│ └── Contacts
└── Standalone Contacts

into:

Organization
├── Companies
│ ├── Contacts
│ └── Opportunities
└── Standalone Contacts

And Quorentra will move from answering:

Who are our customers and contacts?

toward:

What business are we trying to win?

That is the next major step toward a genuinely useful CRM—and toward the point where the ChatGPT-native layer becomes valuable rather than merely demonstrative.

Quorentra

Contact

Menu

Designed with WordPress

Discover more from Quorentra

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

Continue reading