"""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 ADDRESS_FIELDS = ("address_street", "address_city", "address_zip", "address_country", "address_state") 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, "address_street": c.address_street, "address_city": c.address_city, "address_zip": c.address_zip, "address_country": c.address_country, "address_state": c.address_state, "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"), address_street=data.get("address_street"), address_city=data.get("address_city"), address_zip=data.get("address_zip"), address_country=data.get("address_country"), address_state=data.get("address_state"), 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", ) + ADDRESS_FIELDS 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