""" Copyright 2024-2026 ReplyPier Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from typing import Optional from uuid import UUID from app.core.logger import get_logger from app.core.redis import get_redis from app.database import get_db from app.core.auth import ( PEOPLE_READ_PERMISSIONS, PEOPLE_WRITE_PERMISSIONS, get_current_user, has_any_permission, ) from app.models.user import User from app.repositories.people import PeopleRepository from app.repositories.crm import CrmConnectionRepository, CrmCustomerSyncRepository from app.models.crm import CrmConnectionStatus from app.services.crm_sync import sync_customer_to_crm, CrmManualSyncError from app.models.schemas.people import ( PeopleListResponse, PeopleStats, PersonDetail, PersonListItem, PersonUpdateRequest, ) from app.models.schemas.crm import CrmCustomerSyncOut, PersonCrmStatus from app.core.params import PageNumber # Enterprise gating — People is part of Lead Management, a Pro-plan feature where # the enterprise module is installed; OSS/community deployments are unrestricted. try: from app.enterprise.repositories.plan import PlanRepository from app.enterprise.services.feature_access import require_accessible_subscription HAS_ENTERPRISE = True except ImportError: HAS_ENTERPRISE = False router = APIRouter() logger = get_logger(__name__) # Ignore a repeat manual "Sync now" for the same person within this window. MANUAL_SYNC_DEBOUNCE_SECONDS = 10 def _require_people_access( current_user: User, db: Session, permissions: tuple = PEOPLE_READ_PERMISSIONS ) -> None: # Reading the directory is its own grant (view_people): an agent handling a # conversation should be able to look up who they are talking to without # also gaining the right to read every other agent's chats. The org-wide # inbox permissions imply it, so admins are unaffected. Writes pass # PEOPLE_WRITE_PERMISSIONS instead. has_any_permission honours the # super_admin bypass this check used to miss (a super_admin could send # templates but got 403 here). if not has_any_permission(current_user, permissions): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not enough permissions") # Pro-plan gate (Lead Management) where the enterprise module is present. if HAS_ENTERPRISE: subscription = require_accessible_subscription(db, current_user.organization_id) if not PlanRepository(db).check_feature_availability(str(subscription.plan_id), 'lead_capture'): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="People / Lead Management is not available in your current plan. Please upgrade to Pro.", ) @router.get("", response_model=PeopleListResponse) async def list_people( stage: Optional[str] = Query(None), search: Optional[str] = Query(None), page: PageNumber = 1, page_size: int = Query(20, ge=1, le=100), view: str = Query("identified", pattern="^(identified|anonymous)$"), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """Paginated, filterable list of the org's people. The default view is identified people (real email, phone, or a qualifying capture); anonymous browser sessions live behind view=anonymous — they are a lead-capture funnel signal, not directory content.""" _require_people_access(current_user, db) items, total = PeopleRepository(db).list_people( current_user.organization_id, stage=stage, search=search, page=page, page_size=page_size, view=view, ) return PeopleListResponse( items=[PersonListItem(**i) for i in items], total=total, page=page, page_size=page_size, ) @router.get("/stats", response_model=PeopleStats) async def people_stats( current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """KPI counters for the People page header.""" _require_people_access(current_user, db) return PeopleStats(**PeopleRepository(db).get_stats(current_user.organization_id)) @router.get("/{customer_id}", response_model=PersonDetail) async def get_person( customer_id: UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """Full profile: captured attributes, lifecycle timeline, conversations.""" _require_people_access(current_user, db) detail = PeopleRepository(db).get_detail(current_user.organization_id, customer_id) if not detail: raise HTTPException(status_code=404, detail="Person not found") return PersonDetail(**detail) @router.post("/{customer_id}/mark-customer", response_model=PersonDetail) async def mark_as_customer( customer_id: UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """Manually promote a person to the Customer stage (phase 1: no automated signal).""" _require_people_access(current_user, db, PEOPLE_WRITE_PERMISSIONS) repo = PeopleRepository(db) customer = repo.get_customer(current_user.organization_id, customer_id) if not customer: raise HTTPException(status_code=404, detail="Person not found") # An anonymous browser session cannot BE a customer — there is no one to # recognize. Identify them first (the drawer's edit adds a phone/name). if not repo.is_identified(customer, repo._has_qualified_capture(customer.id)): raise HTTPException(status_code=400, detail="Add an email or phone first — this person is anonymous") repo.mark_customer(current_user.organization_id, customer_id) detail = repo.get_detail(current_user.organization_id, customer_id) return PersonDetail(**detail) @router.patch("/{customer_id}", response_model=PersonDetail) async def update_person( customer_id: UUID, request: PersonUpdateRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """Explicit human edit of a person's name/phone — the identification tool. This is the one path allowed to CORRECT a phone (the automatic capture paths are set-if-absent): a mistyped outbound number must be fixable.""" _require_people_access(current_user, db, PEOPLE_WRITE_PERMISSIONS) repo = PeopleRepository(db) customer, error = repo.update_person( current_user.organization_id, customer_id, full_name=request.full_name, phone=request.phone, ) if customer is None: raise HTTPException(status_code=404, detail="Person not found") if error: raise HTTPException(status_code=400, detail=error) detail = repo.get_detail(current_user.organization_id, customer.id) return PersonDetail(**detail) def _person_crm_status(db: Session, organization_id, customer_id) -> PersonCrmStatus: connected = [ c.provider for c in CrmConnectionRepository(db).list_by_org(organization_id) if c.status == CrmConnectionStatus.ACTIVE.value ] synced = CrmCustomerSyncRepository(db).list_for_customer(customer_id) return PersonCrmStatus( connected_providers=connected, synced=[CrmCustomerSyncOut.model_validate(s) for s in synced], ) @router.get("/{customer_id}/crm", response_model=PersonCrmStatus) async def get_person_crm( customer_id: UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """CRM state for the drawer: which CRMs the org has connected + where this person is already synced.""" _require_people_access(current_user, db) repo = PeopleRepository(db) customer = repo.get_customer(current_user.organization_id, customer_id) if not customer: raise HTTPException(status_code=404, detail="Person not found") return _person_crm_status(db, current_user.organization_id, customer_id) @router.post("/{customer_id}/crm-sync", response_model=PersonCrmStatus) async def sync_person_crm( customer_id: UUID, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): """Manual "Sync now": push this person to every connected CRM, synchronously.""" _require_people_access(current_user, db, PEOPLE_WRITE_PERMISSIONS) repo = PeopleRepository(db) customer = repo.get_customer(current_user.organization_id, customer_id) if not customer: raise HTTPException(status_code=404, detail="Person not found") # Debounce: a double-click shouldn't fire a second fan-out of CRM writes # while the first is still in flight. Best-effort (Redis only). redis_client = get_redis() if redis_client is not None and not redis_client.set( f"crm_manual_sync:{customer_id}", "1", nx=True, ex=MANUAL_SYNC_DEBOUNCE_SECONDS): return _person_crm_status(db, current_user.organization_id, customer_id) try: await sync_customer_to_crm(db, customer) except CrmManualSyncError as e: raise HTTPException(status_code=400, detail=str(e)) return _person_crm_status(db, current_user.organization_id, customer_id)