TorbatYar/backend/services/crm/app/services/contact_service.py
Mortezakoohjani 064d67f099 Ship CRM Core Platform (phases 6.0–6.3) with docs and prod deploy.
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>
2026-07-24 20:32:00 +03:30

176 lines
6.5 KiB
Python

"""Contact application service — Phase 6.1."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.events.publisher import InMemoryEventPublisher, get_event_publisher
from app.events.types import CrmEventType
from app.models.foundation import Contact
from app.models.types import AuditAction
from app.repositories.foundation import ContactRepository
from app.schemas.foundation import ContactCreate, ContactUpdate
from app.services.audit_service import AuditService
from app.validators import validate_email, validate_non_empty, validate_phone
from shared.exceptions import NotFoundError
from shared.security import CurrentUser
def _apply_update(entity, data: dict) -> None:
for key, value in data.items():
setattr(entity, key, value)
class ContactService:
def __init__(
self,
session: AsyncSession,
publisher: InMemoryEventPublisher | None = None,
) -> None:
self.session = session
self.repo = ContactRepository(session)
self.audit = AuditService(session)
self.publisher = publisher or get_event_publisher()
async def create(
self,
tenant_id: UUID,
body: ContactCreate,
*,
actor: CurrentUser | None = None,
) -> Contact:
actor_id = actor.user_id if actor else None
first = validate_non_empty(body.first_name, field="first_name")
last = validate_non_empty(body.last_name, field="last_name")
email = validate_email(body.email)
phone = validate_phone(body.phone)
emails = None
if body.emails:
emails = [validate_email(e, required=True) for e in body.emails]
phones = None
if body.phones:
phones = [validate_phone(p, field="phone") for p in body.phones]
phones = [p for p in phones if p]
entity = Contact(
tenant_id=tenant_id,
first_name=first,
last_name=last,
full_name=f"{first} {last}".strip(),
kind=body.kind,
contact_type_id=body.contact_type_id,
email=email,
phone=phone,
emails=emails,
phones=phones,
default_contact_method=body.default_contact_method,
social_links=body.social_links,
birthday=body.birthday,
job_title=body.job_title,
job_position=body.job_position or body.job_title,
status=body.status,
organization_id=body.organization_id,
owner_user_id=body.owner_user_id,
notes_summary=body.notes_summary,
created_by=actor_id,
updated_by=actor_id,
)
await self.repo.add(entity)
await self.audit.record(
tenant_id=tenant_id,
entity_type="contact",
entity_id=entity.id,
action=AuditAction.CREATE,
actor_user_id=actor_id,
)
await self.session.commit()
await self.session.refresh(entity)
self.publisher.publish(
event_type=CrmEventType.CONTACT_CREATED,
aggregate_type="contact",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"email": entity.email, "full_name": entity.full_name},
)
return entity
async def update(
self,
tenant_id: UUID,
contact_id: UUID,
body: ContactUpdate,
*,
actor: CurrentUser | None = None,
) -> Contact:
entity = await self.repo.get(tenant_id, contact_id)
if entity is None:
raise NotFoundError("مخاطب یافت نشد", error_code="contact_not_found")
actor_id = actor.user_id if actor else None
data = body.model_dump(exclude_unset=True)
if "first_name" in data:
data["first_name"] = validate_non_empty(data["first_name"], field="first_name")
if "last_name" in data:
data["last_name"] = validate_non_empty(data["last_name"], field="last_name")
if "email" in data:
data["email"] = validate_email(data["email"])
if "phone" in data:
data["phone"] = validate_phone(data["phone"])
if "emails" in data and data["emails"] is not None:
data["emails"] = [
validate_email(e, required=True) for e in data["emails"]
]
if "phones" in data and data["phones"] is not None:
data["phones"] = [
p
for p in (validate_phone(p, field="phone") for p in data["phones"])
if p
]
first = data.get("first_name", entity.first_name)
last = data.get("last_name", entity.last_name)
data["full_name"] = f"{first} {last}".strip()
_apply_update(entity, data)
entity.updated_by = actor_id
await self.audit.record(
tenant_id=tenant_id,
entity_type="contact",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=actor_id,
changes=data,
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def soft_delete(
self,
tenant_id: UUID,
contact_id: UUID,
*,
actor: CurrentUser | None = None,
) -> Contact:
entity = await self.repo.get(tenant_id, contact_id)
if entity is None:
raise NotFoundError("مخاطب یافت نشد", error_code="contact_not_found")
actor_id = actor.user_id if actor else None
await self.repo.soft_delete(entity, deleted_by=actor_id)
await self.audit.record(
tenant_id=tenant_id,
entity_type="contact",
entity_id=entity.id,
action=AuditAction.DELETE,
actor_user_id=actor_id,
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def get(self, tenant_id: UUID, contact_id: UUID) -> Contact:
entity = await self.repo.get(tenant_id, contact_id)
if entity is None:
raise NotFoundError("مخاطب یافت نشد", error_code="contact_not_found")
return entity
async def list(self, tenant_id: UUID, *, offset: int, limit: int):
return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)