""" 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 typing import Optional from sqlalchemy.orm import Session from app.core.logger import get_logger from app.crm.base import CrmAuthError, CrmPushResult, CrmTransientError from app.crm.mapping import build_customer_payload, build_lead_payload from app.crm.registry import get_adapter from app.models.crm import CrmConnectionStatus, CrmSyncJob from app.models.customer import Customer from app.models.lead_capture import CrmSyncTarget, LeadCaptureConfig, LeadCaptureResponse from app.repositories.crm import ( CrmConnectionRepository, CrmSyncJobRepository, CrmCustomerSyncRepository, ) from app.services.crm_tokens import get_valid_tokens from app.services.feature_gate import feature_allowed logger = get_logger(__name__) def enqueue_crm_sync(db: Session, config: LeadCaptureConfig, response: LeadCaptureResponse) -> Optional[CrmSyncJob]: """Queue a captured lead for CRM push, or quietly decide not to. All the "should this sync at all" logic lives here so the capture path stays dumb: no target configured, plan lacks the feature (fails closed), or no active connection → no job. Idempotent per (lead, provider).""" provider = str(config.crm_sync_target or CrmSyncTarget.NONE.value) if provider == CrmSyncTarget.NONE.value: return None if get_adapter(provider) is None: logger.warning(f"Lead {response.id}: unknown CRM target '{provider}', not syncing") return None if not feature_allowed(db, response.organization_id, "crm_sync"): logger.info(f"Lead {response.id}: plan lacks crm_sync, not syncing") return None connection = CrmConnectionRepository(db).get_active(response.organization_id, provider) if connection is None: logger.info(f"Lead {response.id}: no active {provider} connection, not syncing") return None job = CrmSyncJobRepository(db).enqueue( organization_id=response.organization_id, lead_response_id=response.id, provider=provider, agent_id=response.agent_id, ) if job is not None: logger.info(f"Queued {provider} sync for lead {response.id}") return job async def process_job(db: Session, job: CrmSyncJob) -> CrmSyncJob: """Run one claimed sync job to a terminal or retry state. Safe to re-run: the adapters' push sequences converge (HubSpot upsert, Pipedrive search-first + open-lead check).""" jobs = CrmSyncJobRepository(db) # Re-checked at run time: the org may have downgraded since enqueue. if not feature_allowed(db, job.organization_id, "crm_sync"): return jobs.skip(job, "plan no longer includes crm_sync") connections = CrmConnectionRepository(db) connection = connections.get_active(job.organization_id, job.provider) if connection is None: # Terminal: retrying cannot help until a human reconnects, and the # reconnect path enqueues nothing retroactively — the UI surfaces it. return jobs.fail(job, "no_connection") response = db.get(LeadCaptureResponse, job.lead_response_id) if response is None: return jobs.skip(job, "lead response deleted") payload = build_lead_payload( response, _config_for_agent(db, response.agent_id), db.get(Customer, response.customer_id) if response.customer_id else None, ) if not payload.email: return jobs.skip(job, "lead has no email") adapter = get_adapter(job.provider) try: tokens = await get_valid_tokens(db, connection) except CrmAuthError as e: return jobs.fail(job, f"auth: {e}", connection_id=connection.id) except CrmTransientError as e: return jobs.schedule_retry(job, f"token refresh failed: {e}") result = await adapter.push_lead(tokens, payload) if result.auth_failed: # The stored expiry looked fine but the provider said no — force one # refresh and retry before declaring the connection dead. try: tokens = await get_valid_tokens(db, connection, force_refresh=True) except (CrmAuthError, CrmTransientError) as e: return jobs.fail(job, f"auth: {e}", connection_id=connection.id) result = await adapter.push_lead(tokens, payload) if result.auth_failed: connections.set_status(connection, CrmConnectionStatus.EXPIRED.value, last_error=result.error) return jobs.fail(job, f"auth: {result.error}", connection_id=connection.id) if result.ok: logger.info(f"Pushed lead {response.id} to {job.provider} " f"({result.action} {result.contact_id})") # Record the per-person link so the People drawer can show it. if response.customer_id: _record_customer_sync(db, job.organization_id, response.customer_id, job.provider, result) return jobs.complete(job, { "action": result.action, "contact_id": result.contact_id, "secondary_id": result.secondary_id, "record_url": result.record_url, }, connection_id=connection.id) if result.retryable: return jobs.schedule_retry(job, result.error or "retryable failure", retry_after_seconds=result.retry_after_seconds) return jobs.fail(job, result.error or "push failed", connection_id=connection.id) class CrmManualSyncError(Exception): """A manual "Sync now" could not be completed (surfaced to the user).""" async def sync_customer_to_crm(db: Session, customer: Customer) -> list: """Manual "Sync now" from the People drawer: push this person to every active CRM connection in their org and record each link. Synchronous so the UI gets an immediate result. Returns the list of recorded links. Raises CrmManualSyncError for user-facing failures (no email, no connection, plan gate, or a definitive provider rejection).""" from app.repositories.customer import CustomerRepository # Anonymous visitors carry a placeholder stand-in (…@noemail.com / .channel) # that can't receive mail — never push it to a CRM. if CustomerRepository.is_placeholder_email(customer.email): raise CrmManualSyncError("Add an email to this person before syncing to a CRM.") if not feature_allowed(db, customer.organization_id, "crm_sync"): raise CrmManualSyncError("CRM sync is not available in your current plan.") connections = CrmConnectionRepository(db).list_by_org(customer.organization_id) active = [c for c in connections if c.status == CrmConnectionStatus.ACTIVE.value] if not active: raise CrmManualSyncError("Connect a CRM in Settings → Integrations first.") attributes, summary, config = _lead_capture_context(db, customer) payload = build_customer_payload(customer, attributes=attributes, summary=summary, config=config) records, errors = [], [] for connection in active: adapter = get_adapter(connection.provider) if adapter is None: continue try: tokens = await get_valid_tokens(db, connection) result = await adapter.push_lead(tokens, payload) if result.auth_failed: tokens = await get_valid_tokens(db, connection, force_refresh=True) result = await adapter.push_lead(tokens, payload) except CrmAuthError: errors.append(f"{connection.provider}: reconnect required") continue except CrmTransientError: errors.append(f"{connection.provider}: temporary error, try again") continue if result.ok: records.append(_record_customer_sync( db, customer.organization_id, customer.id, connection.provider, result)) elif result.auth_failed: connections_repo = CrmConnectionRepository(db) connections_repo.set_status(connection, CrmConnectionStatus.EXPIRED.value, last_error=result.error) errors.append(f"{connection.provider}: reconnect required") else: errors.append(f"{connection.provider}: {result.error or 'push failed'}") if not records and errors: raise CrmManualSyncError("; ".join(errors)) return records def _record_customer_sync(db: Session, organization_id, customer_id, provider: str, result: CrmPushResult): return CrmCustomerSyncRepository(db).record( organization_id=organization_id, customer_id=customer_id, provider=provider, contact_id=result.contact_id, secondary_id=result.secondary_id, record_url=result.record_url, ) def _lead_capture_context(db: Session, customer: Customer): """Merge everything the person's lead captures produced — attributes (latest value wins per key), the most recent AI summary, and the latest response's agent config for custom-field labels — mirroring what the People drawer shows, so a manual push sends the full picture, not just email. Returns (attributes, summary, config).""" responses = ( db.query(LeadCaptureResponse) .filter(LeadCaptureResponse.customer_id == customer.id) .order_by(LeadCaptureResponse.created_at) .all() ) attributes: dict = {} summary = None latest_agent_id = None for r in responses: for key, value in (r.field_values or {}).items(): if value not in (None, ""): attributes[key] = value if r.summary: summary = r.summary if r.agent_id: latest_agent_id = r.agent_id config = _config_for_agent(db, latest_agent_id) if latest_agent_id else None return attributes, summary, config def _config_for_agent(db: Session, agent_id) -> Optional[LeadCaptureConfig]: if agent_id is None: return None return db.query(LeadCaptureConfig).filter( LeadCaptureConfig.agent_id == agent_id).first()