Add the Sales CRM service through collaboration, sync architecture/registry docs, expose crm.torbatyar.ir, and include a production deploy script. Co-authored-by: Cursor <cursoragent@cursor.com>
408 lines
14 KiB
Python
408 lines
14 KiB
Python
"""Phase 6.1 supporting CRM APIs — lookups, tags, addresses, notes, attachments, custom fields, audit."""
|
|
from __future__ import annotations
|
|
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, Query, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_db, get_pagination, require_tenant
|
|
from app.core.security import get_current_user
|
|
from app.models.types import CrmEntityType
|
|
from app.schemas.foundation import (
|
|
AddressCreate,
|
|
AddressRead,
|
|
AddressUpdate,
|
|
AttachmentCreate,
|
|
AttachmentRead,
|
|
AuditRead,
|
|
CustomFieldCreate,
|
|
CustomFieldRead,
|
|
CustomFieldValueRead,
|
|
CustomFieldValueUpsert,
|
|
LeadSourceRead,
|
|
LeadStatusCreate,
|
|
LeadStatusRead,
|
|
LookupCreate,
|
|
LookupRead,
|
|
NoteCreate,
|
|
NoteRead,
|
|
NoteUpdate,
|
|
TagAssignRequest,
|
|
TagCreate,
|
|
TagRead,
|
|
)
|
|
from app.services.audit_service import AuditService
|
|
from app.services.supporting_services import (
|
|
AddressService,
|
|
AttachmentService,
|
|
CustomFieldService,
|
|
LookupService,
|
|
NoteService,
|
|
TagService,
|
|
)
|
|
from shared.pagination import PaginationParams
|
|
from shared.security import CurrentUser
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── Lookups ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post(
|
|
"/lookups/lead-sources",
|
|
response_model=LeadSourceRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_lead_source(
|
|
body: LookupCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await LookupService(db).create_lead_source(tenant_id, body)
|
|
|
|
|
|
@router.get("/lookups/lead-sources", response_model=list[LeadSourceRead])
|
|
async def list_lead_sources(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
pagination: PaginationParams = Depends(get_pagination),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await LookupService(db).list_lead_sources(
|
|
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/lookups/lead-statuses",
|
|
response_model=LeadStatusRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_lead_status(
|
|
body: LeadStatusCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await LookupService(db).create_lead_status(tenant_id, body)
|
|
|
|
|
|
@router.get("/lookups/lead-statuses", response_model=list[LeadStatusRead])
|
|
async def list_lead_statuses(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
pagination: PaginationParams = Depends(get_pagination),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await LookupService(db).list_lead_statuses(
|
|
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/lookups/contact-types",
|
|
response_model=LookupRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_contact_type(
|
|
body: LookupCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await LookupService(db).create_contact_type(tenant_id, body)
|
|
|
|
|
|
@router.get("/lookups/contact-types", response_model=list[LookupRead])
|
|
async def list_contact_types(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
pagination: PaginationParams = Depends(get_pagination),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await LookupService(db).list_contact_types(
|
|
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/lookups/organization-types",
|
|
response_model=LookupRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_organization_type(
|
|
body: LookupCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await LookupService(db).create_organization_type(tenant_id, body)
|
|
|
|
|
|
@router.get("/lookups/organization-types", response_model=list[LookupRead])
|
|
async def list_organization_types(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
pagination: PaginationParams = Depends(get_pagination),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await LookupService(db).list_organization_types(
|
|
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/lookups/organization-industries",
|
|
response_model=LookupRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_organization_industry(
|
|
body: LookupCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await LookupService(db).create_organization_industry(tenant_id, body)
|
|
|
|
|
|
@router.get("/lookups/organization-industries", response_model=list[LookupRead])
|
|
async def list_organization_industries(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
pagination: PaginationParams = Depends(get_pagination),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await LookupService(db).list_organization_industries(
|
|
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
|
)
|
|
|
|
|
|
# ── Tags ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/tags", response_model=TagRead, status_code=status.HTTP_201_CREATED)
|
|
async def create_tag(
|
|
body: TagCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await TagService(db).create(tenant_id, body)
|
|
|
|
|
|
@router.get("/tags", response_model=list[TagRead])
|
|
async def list_tags(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
pagination: PaginationParams = Depends(get_pagination),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await TagService(db).list(
|
|
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
|
)
|
|
|
|
|
|
@router.post("/tags/leads/{lead_id}")
|
|
async def assign_lead_tag(
|
|
lead_id: UUID,
|
|
body: TagAssignRequest,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
row = await TagService(db).assign_to_lead(tenant_id, lead_id, body.tag_id)
|
|
return {"id": str(row.id), "lead_id": str(row.lead_id), "tag_id": str(row.tag_id)}
|
|
|
|
|
|
@router.post("/tags/contacts/{contact_id}")
|
|
async def assign_contact_tag(
|
|
contact_id: UUID,
|
|
body: TagAssignRequest,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
row = await TagService(db).assign_to_contact(tenant_id, contact_id, body.tag_id)
|
|
return {
|
|
"id": str(row.id),
|
|
"contact_id": str(row.contact_id),
|
|
"tag_id": str(row.tag_id),
|
|
}
|
|
|
|
|
|
@router.post("/tags/organizations/{organization_id}")
|
|
async def assign_organization_tag(
|
|
organization_id: UUID,
|
|
body: TagAssignRequest,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
row = await TagService(db).assign_to_organization(
|
|
tenant_id, organization_id, body.tag_id
|
|
)
|
|
return {
|
|
"id": str(row.id),
|
|
"organization_id": str(row.organization_id),
|
|
"tag_id": str(row.tag_id),
|
|
}
|
|
|
|
|
|
# ── Addresses ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post(
|
|
"/addresses", response_model=AddressRead, status_code=status.HTTP_201_CREATED
|
|
)
|
|
async def create_address(
|
|
body: AddressCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await AddressService(db).create(tenant_id, body, actor=user)
|
|
|
|
|
|
@router.get("/addresses", response_model=list[AddressRead])
|
|
async def list_addresses(
|
|
entity_type: CrmEntityType = Query(...),
|
|
entity_id: UUID = Query(...),
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await AddressService(db).list_for_entity(tenant_id, entity_type, entity_id)
|
|
|
|
|
|
@router.patch("/addresses/{address_id}", response_model=AddressRead)
|
|
async def update_address(
|
|
address_id: UUID,
|
|
body: AddressUpdate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await AddressService(db).update(tenant_id, address_id, body, actor=user)
|
|
|
|
|
|
# ── Notes ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/notes", response_model=NoteRead, status_code=status.HTTP_201_CREATED)
|
|
async def create_note(
|
|
body: NoteCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await NoteService(db).create(tenant_id, body, actor=user)
|
|
|
|
|
|
@router.get("/notes", response_model=list[NoteRead])
|
|
async def list_notes(
|
|
entity_type: CrmEntityType = Query(...),
|
|
entity_id: UUID = Query(...),
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await NoteService(db).list_for_entity(tenant_id, entity_type, entity_id)
|
|
|
|
|
|
@router.patch("/notes/{note_id}", response_model=NoteRead)
|
|
async def update_note(
|
|
note_id: UUID,
|
|
body: NoteUpdate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await NoteService(db).update(tenant_id, note_id, body, actor=user)
|
|
|
|
|
|
# ── Attachments ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post(
|
|
"/attachments",
|
|
response_model=AttachmentRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_attachment(
|
|
body: AttachmentCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await AttachmentService(db).create(tenant_id, body, actor=user)
|
|
|
|
|
|
@router.get("/attachments", response_model=list[AttachmentRead])
|
|
async def list_attachments(
|
|
entity_type: CrmEntityType = Query(...),
|
|
entity_id: UUID = Query(...),
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await AttachmentService(db).list_for_entity(
|
|
tenant_id, entity_type, entity_id
|
|
)
|
|
|
|
|
|
# ── Custom fields ────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post(
|
|
"/custom-fields",
|
|
response_model=CustomFieldRead,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
async def create_custom_field(
|
|
body: CustomFieldCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await CustomFieldService(db).create(tenant_id, body)
|
|
|
|
|
|
@router.get("/custom-fields", response_model=list[CustomFieldRead])
|
|
async def list_custom_fields(
|
|
entity_type: CrmEntityType = Query(...),
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await CustomFieldService(db).list_definitions(tenant_id, entity_type)
|
|
|
|
|
|
@router.put(
|
|
"/custom-fields/values",
|
|
response_model=CustomFieldValueRead,
|
|
)
|
|
async def upsert_custom_field_value(
|
|
body: CustomFieldValueUpsert,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await CustomFieldService(db).upsert_value(tenant_id, body)
|
|
|
|
|
|
# ── Audit ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/audit", response_model=list[AuditRead])
|
|
async def list_audit(
|
|
entity_type: str = Query(...),
|
|
entity_id: UUID = Query(...),
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
return await AuditService(db).list_for_entity(tenant_id, entity_type, entity_id)
|