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.

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 ContactRead ContactList ContactsUpdate ContactDelete ContactAssociate Contact with CompanyList 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 LeadIndependent ConsultantFreelancerIndividual CustomerUnclassified 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 contactsWHERE 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 AOrganization 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\backendpython -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\contactsNew-Item app\modules\contacts\__init__.py -ItemType FileNew-Item app\modules\contacts\model.py -ItemType FileNew-Item app\modules\contacts\repository.py -ItemType FileNew-Item app\modules\contacts\service.py -ItemType FileNew-Item app\modules\contacts\schemas.py -ItemType FileNew-Item app\modules\contacts\router.py -ItemType FileNew-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, uuid4from sqlalchemy import ForeignKey, String, Textfrom sqlalchemy.orm import Mapped, mapped_columnfrom 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_atupdated_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 │ └── contactsContact │ └── 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:
CompanyContact
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_idcompany_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:
ColumnsConstraintsForeign KeysIndexes
Quorentra now has its second CRM entity.
23. Create Contact Schemas
Open:
app/modules/contacts/schemas.py
Add:
from datetime import datetimefrom uuid import UUIDfrom pydantic import ( BaseModel, ConfigDict, EmailStr, Field,)
We need:
ContactCreateContactUpdateContactResponse
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): passclass ContactNotFoundError( ContactError): passclass ContactAlreadyExistsError( ContactError): passclass 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 UUIDfrom sqlalchemy import func, selectfrom sqlalchemy.orm import Sessionfrom 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 CRMTenant 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:
namephonemeetingcompany
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 UUIDfrom sqlalchemy.orm import Sessionfrom app.modules.companies.repository import ( CompanyRepository,)from app.modules.contacts.exceptions import ( ContactAlreadyExistsError, ContactNotFoundError, InvalidContactCompanyError,)from app.modules.contacts.model import Contactfrom 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 tenantcompany_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 AOrganization B └── User B
User B sends:
{ "company_id": "company-a-id", "first_name": "Alice", "last_name": "Johnson"}
The service asks:
Does Company A existinside 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 contactsinside current tenant
including both:
company contactsstandalone 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 UUIDfrom fastapi import ( APIRouter, Depends, HTTPException, Response, status,)from sqlalchemy.orm import Sessionfrom app.db.dependencies import get_dbfrom 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/contactsGET /api/v1/contactsGET /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 organizationcompany_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, emailFROM 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 ATenant 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 /contactsGET /contacts/{id}GET /contacts/company/{company_id}
Expected:
200 OK
But:
POSTPATCHDELETE
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:
createstandalone contactcompany contactlistretrieveupdateremove companymove companydeleteduplicate emailRBACtenant isolationcross-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 == 201assert ( response.json()["company_id"] == company_id)
This verifies a valid tenant-safe relationship.
81. Test Cross-Tenant Company Rejection
Create:
Tenant A → Company ATenant 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└── RobertTenant B└── Sarah
As Tenant A:
GET /contacts
must return:
AliceRobert
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 existscompany_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:
PlatformIdentityAuthenticationMulti-TenancyAuthorizationCompanies├── CRUD├── RBAC└── Tenant IsolationContacts├── 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 → CompanyOpportunity → ContactTask → CompanyTask → ContactTask → OpportunityActivity → CompanyActivity → ContactActivity → 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_idcontact_idopportunity_idtask_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:
qualificationdiscoveryproposalnegotiationwonlost
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:
stageamountprobabilityexpected close datewon/lost statepipeline 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_companyContacts├── list_contacts├── get_contact└── create_contactOpportunities├── 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:
CompaniesContacts
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 11Company Management ✓ │ ▼Part 12Contact Management ✓ │ ▼Part 13Opportunity Management │ ▼Part 14Minimal CRM Integration │ ▼Part 15MCP Foundation │ ▼Part 16ChatGPT-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 Companyinside 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
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.