""" Service per la gestione della rubrica indirizzi PEC (Feature 6). """ import csv import io import uuid from sqlalchemy import func, or_, select from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import ConflictError, NotFoundError from app.models.pec_contact import PecContact from app.schemas.pec_contact import ( PecContactCreate, PecContactImportResult, PecContactUpdate, ) class PecContactService: def __init__(self, db: AsyncSession): self.db = db async def list_contacts( self, tenant_id: uuid.UUID, q: str | None = None, page: int = 1, page_size: int = 50, ) -> tuple[list[PecContact], int]: query = select(PecContact).where(PecContact.tenant_id == tenant_id) if q: pattern = f"%{q.lower()}%" query = query.where( or_( PecContact.email.ilike(pattern), PecContact.name.ilike(pattern), PecContact.organization.ilike(pattern), ) ) query = query.order_by(PecContact.is_favorite.desc(), PecContact.name, PecContact.email) count_q = select(func.count()).select_from(query.subquery()) total = (await self.db.execute(count_q)).scalar_one() offset = (page - 1) * page_size query = query.offset(offset).limit(page_size) items = list((await self.db.execute(query)).scalars().all()) return items, total async def get_contact( self, tenant_id: uuid.UUID, contact_id: uuid.UUID ) -> PecContact: result = await self.db.execute( select(PecContact).where( PecContact.id == contact_id, PecContact.tenant_id == tenant_id, ) ) contact = result.scalar_one_or_none() if not contact: raise NotFoundError(f"Contatto {contact_id} non trovato") return contact async def create_contact( self, tenant_id: uuid.UUID, data: PecContactCreate, created_by: uuid.UUID | None = None, ) -> PecContact: email = data.email.lower().strip() # Verifica unicita' existing = await self.db.execute( select(PecContact).where( PecContact.tenant_id == tenant_id, PecContact.email == email, ) ) if existing.scalar_one_or_none(): raise ConflictError(f"Contatto '{email}' gia' esistente") contact = PecContact( tenant_id=tenant_id, email=email, name=data.name, organization=data.organization, notes=data.notes, is_favorite=data.is_favorite, auto_saved=False, created_by=created_by, ) self.db.add(contact) await self.db.commit() await self.db.refresh(contact) return contact async def update_contact( self, tenant_id: uuid.UUID, contact_id: uuid.UUID, data: PecContactUpdate, ) -> PecContact: contact = await self.get_contact(tenant_id, contact_id) if data.name is not None: contact.name = data.name if data.organization is not None: contact.organization = data.organization if data.notes is not None: contact.notes = data.notes if data.is_favorite is not None: contact.is_favorite = data.is_favorite await self.db.commit() await self.db.refresh(contact) return contact async def delete_contact( self, tenant_id: uuid.UUID, contact_id: uuid.UUID ) -> None: contact = await self.get_contact(tenant_id, contact_id) await self.db.delete(contact) await self.db.commit() async def auto_save_sender( self, tenant_id: uuid.UUID, email: str, ) -> None: """ Salva automaticamente il mittente nella rubrica se non esiste ancora. Operazione non bloccante: gli errori vengono ignorati silenziosamente. Usata dal worker IMAP durante la sincronizzazione dei messaggi inbound. """ if not email: return email = email.lower().strip() try: # Upsert: inserisce solo se non esiste stmt = insert(PecContact).values( id=uuid.uuid4(), tenant_id=tenant_id, email=email, auto_saved=True, ).on_conflict_do_nothing( constraint="uq_pec_contact_email_tenant" ) await self.db.execute(stmt) await self.db.commit() except Exception: await self.db.rollback() async def import_csv( self, tenant_id: uuid.UUID, csv_content: str, created_by: uuid.UUID | None = None, ) -> PecContactImportResult: """ Importa contatti da un CSV con colonne: email, name, organization. Aggiorna i record esistenti con name/organization se forniti. """ result = PecContactImportResult(created=0, updated=0, skipped=0) reader = csv.DictReader(io.StringIO(csv_content)) for row_num, row in enumerate(reader, start=2): email = row.get("email", "").strip().lower() if not email or "@" not in email: result.skipped += 1 result.errors.append(f"Riga {row_num}: email non valida '{email}'") continue name = row.get("name", "").strip() or None organization = row.get("organization", "").strip() or None try: existing = await self.db.execute( select(PecContact).where( PecContact.tenant_id == tenant_id, PecContact.email == email, ) ) contact = existing.scalar_one_or_none() if contact: # Aggiorna solo se i campi erano vuoti (non sovrascrive dati manuali) updated = False if name and not contact.name: contact.name = name updated = True if organization and not contact.organization: contact.organization = organization updated = True if updated: result.updated += 1 else: result.skipped += 1 else: contact = PecContact( tenant_id=tenant_id, email=email, name=name, organization=organization, auto_saved=False, created_by=created_by, ) self.db.add(contact) result.created += 1 except Exception as e: result.errors.append(f"Riga {row_num} ({email}): {e}") result.skipped += 1 await self.db.commit() return result async def search_for_autocomplete( self, tenant_id: uuid.UUID, q: str, limit: int = 10, ) -> list[PecContact]: """Ricerca veloce per autocomplete nel compose.""" if not q or len(q) < 2: return [] pattern = f"%{q.lower()}%" result = await self.db.execute( select(PecContact) .where( PecContact.tenant_id == tenant_id, or_( PecContact.email.ilike(pattern), PecContact.name.ilike(pattern), ) ) .order_by(PecContact.is_favorite.desc(), PecContact.email) .limit(limit) ) return list(result.scalars().all())