"""Contact folder service — CRUD, tree building, move contacts.""" from __future__ import annotations import uuid from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.models.contact import Contact from app.models.contact_folder import ContactFolder def _serialize_folder(f: ContactFolder, contact_count: int = 0) -> dict: return { "id": str(f.id), "name": f.name, "parent_id": str(f.parent_id) if f.parent_id else None, "user_id": str(f.user_id), "sort_order": f.sort_order, "contact_count": contact_count, } async def list_folders( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID ) -> list[dict]: """List all folders visible to a user (owned + shared via ACLs). Returns a flat list; the frontend builds the tree. Each folder includes ``access_level`` (owner|admin|write|read) and ``is_shared`` flag. """ from app.services.contact_folder_permission_service import get_visible_folder_ids # Get visible folder IDs and access map visible_ids, access_map = await get_visible_folder_ids(db, tenant_id, user_id) if not visible_ids: return [] result = await db.execute( select(ContactFolder) .where(ContactFolder.tenant_id == tenant_id) .where(ContactFolder.id.in_(visible_ids)) .where(ContactFolder.deleted_at.is_(None)) .order_by(ContactFolder.parent_id, ContactFolder.sort_order, ContactFolder.name) ) folders = result.scalars().all() # Count contacts per folder folder_ids = [f.id for f in folders] counts: dict[uuid.UUID, int] = {} if folder_ids: count_result = await db.execute( select(Contact.folder_id, func.count(Contact.id)) .where(Contact.folder_id.in_(folder_ids)) .where(Contact.deleted_at.is_(None)) .group_by(Contact.folder_id) ) for fid, cnt in count_result: counts[fid] = cnt # Check which folders have permissions (are shared) from app.models.entity_permission import EntityPermission shared_q = await db.execute( select(EntityPermission.entity_id) .where(EntityPermission.entity_type == "contact_folder") .where(EntityPermission.entity_id.in_(folder_ids)) .where(EntityPermission.tenant_id == tenant_id) .group_by(EntityPermission.entity_id) ) shared_ids = {row[0] for row in shared_q} return [ { **_serialize_folder(f), "contact_count": counts.get(f.id, 0), "access_level": access_map.get(f.id, "none"), "is_shared": f.id in shared_ids, } for f in folders ] async def create_folder( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, name: str, parent_id: str | None = None, ) -> dict: """Create a new contact folder.""" parent_uuid = uuid.UUID(parent_id) if parent_id else None # Determine sort_order: max + 1 among siblings max_order_result = await db.execute( select(func.max(ContactFolder.sort_order)) .where(ContactFolder.tenant_id == tenant_id) .where(ContactFolder.user_id == user_id) .where(ContactFolder.parent_id == parent_uuid if parent_uuid else ContactFolder.parent_id.is_(None)) ) max_order = max_order_result.scalar() or 0 folder = ContactFolder( tenant_id=tenant_id, user_id=user_id, name=name, parent_id=parent_uuid, sort_order=max_order + 1, ) db.add(folder) await db.flush() result = _serialize_folder(folder) await db.commit() return result async def update_folder( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, folder_id: str, data: dict, ) -> dict: """Update a contact folder (name, parent_id, sort_order).""" result = await db.execute( select(ContactFolder) .where(ContactFolder.id == uuid.UUID(folder_id)) .where(ContactFolder.tenant_id == tenant_id) .where(ContactFolder.user_id == user_id) .where(ContactFolder.deleted_at.is_(None)) ) folder = result.scalar_one_or_none() if not folder: raise ValueError("Folder not found") if "name" in data and data["name"]: folder.name = data["name"] if "parent_id" in data: new_parent = data["parent_id"] folder.parent_id = uuid.UUID(new_parent) if new_parent else None if "sort_order" in data and data["sort_order"] is not None: folder.sort_order = data["sort_order"] result = _serialize_folder(folder) await db.commit() return result async def delete_folder( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, folder_id: str, ) -> None: """Delete a contact folder. Contacts are unassigned (folder_id SET NULL).""" result = await db.execute( select(ContactFolder) .where(ContactFolder.id == uuid.UUID(folder_id)) .where(ContactFolder.tenant_id == tenant_id) .where(ContactFolder.user_id == user_id) .where(ContactFolder.deleted_at.is_(None)) ) folder = result.scalar_one_or_none() if not folder: raise ValueError("Folder not found") # Unassign contacts in this folder (and subfolders via CASCADE on folder delete) await db.execute( update(Contact) .where(Contact.folder_id == folder.id) .values(folder_id=None) ) await db.delete(folder) await db.commit() async def move_contact( db: AsyncSession, tenant_id: uuid.UUID, contact_id: str, folder_id: str | None, ) -> dict: """Move a contact to a folder (or unassign with folder_id=None).""" result = await db.execute( select(Contact) .where(Contact.id == uuid.UUID(contact_id)) .where(Contact.tenant_id == tenant_id) .where(Contact.deleted_at.is_(None)) ) contact = result.scalar_one_or_none() if not contact: raise ValueError("Contact not found") contact.folder_id = uuid.UUID(folder_id) if folder_id else None result = {"id": str(contact.id), "folder_id": str(contact.folder_id) if contact.folder_id else None} await db.commit() return result async def reorder_folders( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, folder_orders: list[dict], ) -> None: """Batch update sort_order for folders. Each item: {id, sort_order, parent_id?}.""" for item in folder_orders: fid = uuid.UUID(item["id"]) await db.execute( update(ContactFolder) .where(ContactFolder.id == fid) .where(ContactFolder.tenant_id == tenant_id) .where(ContactFolder.user_id == user_id) .values( sort_order=item.get("sort_order", 0), parent_id=uuid.UUID(item["parent_id"]) if item.get("parent_id") else None, ) ) await db.commit()