Files
leocrm/app/services/contact_service.py
T

310 lines
8.9 KiB
Python

"""Contact service — CRUD, N:M linking, soft-delete, GDPR hard-delete."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import asc, delete, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit, log_deletion
from app.models.company import Company
from app.models.contact import CompanyContact, Contact
def _contact_to_dict(c: Contact, include_companies: bool = False) -> dict[str, Any]:
"""Serialize a Contact ORM object to dict."""
data: dict[str, Any] = {
"id": str(c.id),
"first_name": c.first_name,
"last_name": c.last_name,
"email": c.email,
"phone": c.phone,
"mobile": c.mobile,
"position": c.position,
"department": c.department,
"linkedin_url": c.linkedin_url,
"notes": c.notes,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
if include_companies:
data["companies"] = []
return data
def _filter_contact_fields(data: dict, resolved_perms: dict) -> dict:
"""Filter contact fields based on field-level permissions."""
from app.core.permissions import filter_fields_by_permission
return filter_fields_by_permission(data, resolved_perms, "contacts")
async def list_contacts(
db: AsyncSession,
tenant_id: uuid.UUID,
page: int = 1,
page_size: int = 20,
search: str | None = None,
sort_by: str = "last_name",
sort_order: str = "asc",
resolved_perms: dict | None = None,
) -> dict[str, Any]:
"""List contacts with pagination and optional search."""
page = max(1, page)
page_size = max(1, min(100, page_size))
base = select(Contact).where(
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
if search:
pattern = f"%{search}%"
base = base.where(
(Contact.first_name.ilike(pattern))
| (Contact.last_name.ilike(pattern))
| (Contact.email.ilike(pattern))
)
sort_col = getattr(Contact, sort_by, Contact.last_name)
if sort_order == "desc":
base = base.order_by(desc(sort_col))
else:
base = base.order_by(asc(sort_col))
count_q = select(func.count()).select_from(base.subquery())
total_result = await db.execute(count_q)
total = total_result.scalar_one()
offset = (page - 1) * page_size
paginated = base.offset(offset).limit(page_size)
result = await db.execute(paginated)
contacts = result.scalars().all()
items = [_contact_to_dict(c) for c in contacts]
if resolved_perms is not None:
items = [_filter_contact_fields(item, resolved_perms) for item in items]
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
}
async def get_contact_detail(
db: AsyncSession,
tenant_id: uuid.UUID,
contact_id: uuid.UUID,
resolved_perms: dict | None = None,
) -> dict[str, Any] | None:
"""Get a single contact with its linked companies array."""
q = select(Contact).where(
Contact.id == contact_id,
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if contact is None:
return None
data = _contact_to_dict(contact, include_companies=True)
companies_q = (
select(Company, CompanyContact)
.join(CompanyContact, CompanyContact.company_id == Company.id)
.where(
CompanyContact.contact_id == contact_id,
CompanyContact.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
)
companies_result = await db.execute(companies_q)
companies_list = []
for company, link in companies_result.all():
companies_list.append(
{
"id": str(company.id),
"name": company.name,
"industry": company.industry,
"role_at_company": link.role_at_company,
"is_primary": link.is_primary,
}
)
data["companies"] = companies_list
if resolved_perms is not None:
data = _filter_contact_fields(data, resolved_perms)
return data
async def create_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new contact, optionally linking to companies via company_ids."""
contact = Contact(
tenant_id=tenant_id,
first_name=data["first_name"],
last_name=data["last_name"],
email=data.get("email"),
phone=data.get("phone"),
mobile=data.get("mobile"),
position=data.get("position"),
department=data.get("department"),
linkedin_url=data.get("linkedin_url"),
notes=data.get("notes"),
created_by=user_id,
updated_by=user_id,
)
db.add(contact)
await db.flush()
company_ids = data.get("company_ids")
linked_companies = []
if company_ids:
for cid_str in company_ids:
try:
cid = uuid.UUID(cid_str) if isinstance(cid_str, str) else cid_str
except (ValueError, TypeError):
continue
comp_q = select(Company).where(
Company.id == cid,
Company.tenant_id == tenant_id,
Company.deleted_at.is_(None),
)
comp_result = await db.execute(comp_q)
if comp_result.scalar_one_or_none() is None:
continue
link = CompanyContact(
tenant_id=tenant_id,
company_id=cid,
contact_id=contact.id,
)
db.add(link)
linked_companies.append(str(cid))
await db.flush()
await db.refresh(contact)
await log_audit(
db,
tenant_id,
user_id,
"create",
"contact",
contact.id,
changes={
"first_name": data["first_name"],
"last_name": data["last_name"],
"linked_companies": linked_companies,
},
)
return _contact_to_dict(contact)
async def update_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
contact_id: uuid.UUID,
data: dict[str, Any],
) -> dict[str, Any] | None:
"""Update a contact (partial update) and audit-log changes."""
q = select(Contact).where(
Contact.id == contact_id,
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if contact is None:
return None
changes: dict[str, Any] = {}
all_fields = (
"first_name", "last_name", "email", "phone", "mobile",
"position", "department", "linkedin_url", "notes",
)
for field in all_fields:
if field in data and data[field] is not None:
old_val = getattr(contact, field)
changes[field] = {"old": old_val, "new": data[field]}
setattr(contact, field, data[field])
contact.updated_by = user_id
await db.flush()
await db.refresh(contact)
await log_audit(db, tenant_id, user_id, "update", "contact", contact_id, changes=changes)
return _contact_to_dict(contact)
async def soft_delete_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
contact_id: uuid.UUID,
) -> bool:
"""Soft-delete a contact (set deleted_at)."""
q = select(Contact).where(
Contact.id == contact_id,
Contact.tenant_id == tenant_id,
Contact.deleted_at.is_(None),
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if contact is None:
return False
contact.deleted_at = datetime.now(UTC)
contact.updated_by = user_id
await db.flush()
await log_audit(
db, tenant_id, user_id, "delete", "contact", contact_id,
changes={"first_name": contact.first_name, "last_name": contact.last_name},
)
return True
async def gdpr_hard_delete_contact(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
contact_id: uuid.UUID,
) -> bool:
"""GDPR hard-delete: physical delete + deletion_log entry with snapshot."""
q = select(Contact).where(
Contact.id == contact_id,
Contact.tenant_id == tenant_id,
)
result = await db.execute(q)
contact = result.scalar_one_or_none()
if contact is None:
return False
snapshot = _contact_to_dict(contact)
await db.execute(
delete(CompanyContact).where(
CompanyContact.contact_id == contact_id,
CompanyContact.tenant_id == tenant_id,
)
)
await db.delete(contact)
await db.flush()
await log_deletion(
db,
tenant_id,
user_id,
"contact",
contact_id,
snapshot,
)
return True