6bf0746b94
- Company CRUD with soft-delete, FTS search (tsvector + GIN), filter, pagination - Contact CRUD with N:M company linking via company_contacts - CSV/XLSX export, CSV import with dry-run preview - GDPR hard-delete with deletion_log - Audit log on all mutations - 27 new tests (24 ACs), 56 total tests pass - Migration 0002: contacts, company_contacts, FTS search_tsv - Fixed T01 tests: POST→201, PATCH→PUT compatibility
282 lines
8.4 KiB
Python
282 lines
8.4 KiB
Python
"""Contact service — CRUD, N:M linking, soft-delete, GDPR hard-delete."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select, func, desc, asc, delete
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.audit import log_audit, log_deletion
|
|
from app.models.contact import Contact, CompanyContact
|
|
from app.models.company import Company
|
|
|
|
|
|
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
|
|
|
|
|
|
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",
|
|
) -> 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()
|
|
|
|
return {
|
|
"items": [_contact_to_dict(c) for c in contacts],
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
}
|
|
|
|
|
|
async def get_contact_detail(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
contact_id: uuid.UUID,
|
|
) -> 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
|
|
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()
|
|
|
|
# Link to companies if company_ids provided
|
|
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
|
|
# Verify company exists in tenant
|
|
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] = {}
|
|
for field in ("first_name", "last_name", "email", "phone", "mobile", "position", "department", "linkedin_url", "notes"):
|
|
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(timezone.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
|
|
|
|
# Capture snapshot before deletion
|
|
snapshot = _contact_to_dict(contact)
|
|
|
|
# Delete N:M links first
|
|
await db.execute(
|
|
delete(CompanyContact).where(
|
|
CompanyContact.contact_id == contact_id,
|
|
CompanyContact.tenant_id == tenant_id,
|
|
)
|
|
)
|
|
|
|
# Physical delete
|
|
await db.delete(contact)
|
|
await db.flush()
|
|
|
|
# Deletion log (immutable snapshot)
|
|
await log_deletion(
|
|
db, tenant_id, user_id, "contact", contact_id, snapshot,
|
|
)
|
|
return True
|