Files
replypier/backend/app/services/crm_sync.py
T

241 lines
10 KiB
Python
Raw Normal View History

"""
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()