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>
176 lines
6.5 KiB
Python
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)
|