""" 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. """ import re from typing import Optional, Dict, Any from uuid import UUID from datetime import datetime, timezone from sqlalchemy.orm import Session from app.models.lead_capture import LeadCaptureConfig, LeadCaptureResponse from app.repositories.customer import CustomerRepository from app.services.widget_identity import can_act_as from app.core.logger import get_logger logger = get_logger(__name__) _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") def has_captured_lead(db: Session, customer_id, agent_id) -> bool: """True once a lead has been recorded for this customer + agent (capture once).""" return db.query(LeadCaptureResponse.id).filter( LeadCaptureResponse.customer_id == customer_id, LeadCaptureResponse.agent_id == agent_id, ).first() is not None def _valid_email(email: Optional[str]) -> bool: return bool(email and _EMAIL_RE.match(email.strip())) def record_lead_capture( db: Session, config: LeadCaptureConfig, organization_id: UUID, agent_id, customer_id, session_id, lead_data: Optional[Dict[str, Any]], summary: Optional[str], consent: bool, page_url: Optional[str] = None, channel: str = "widget", *, scope: Optional[str], ) -> Optional[LeadCaptureResponse]: """Persist an AI-captured lead (fields the agent extracted conversationally + an AI qualification summary). Returns None — recording nothing — when there is no valid email, or consent is required but not given (GDPR). Never records when lead capture is disabled for the agent, or when the customer is integration-authenticated (identified via generate-token) — those are the business's existing customers, not leads. If the captured email already belongs to ANOTHER customer in the org, the anonymous visitor is MERGED into that existing customer (history/sessions reassigned, visitor row marked merged) so People shows one entry. The returned response's customer_id is the final (possibly merged-into) customer. `scope` is the conversation-token scope of the visitor (VERIFIED_SCOPE for channels, whose identity the platform vouches for). A widget visitor may be bound to a customer by an email they only typed, so a lead is only recorded on a customer that visitor created (widget_identity.can_act_as), and only merged into one it may also act as. Otherwise nothing is recorded. A recorded lead is queued for CRM push (post-commit, best-effort) when the agent has a crm_sync_target configured. Slack/assignment routing remains stored-only. """ from app.models.customer import Customer # Never capture when lead capture is off for this agent (authoritative guard, # independent of any caller-side check). if not (config and config.enabled): return None # Never capture integration-authenticated people (identified via generate-token) — # they're already the business's customers, not leads, regardless of the agent's # toggle. Uses the explicit flag, not meta_data (see Customer.is_authenticated). customer = db.query(Customer).filter(Customer.id == customer_id).first() if customer is not None and customer.is_authenticated: return None lead_data = lead_data or {} # Keep only configured field keys (defensive: the model shouldn't inject others). field_keys = {f.get("key") for f in (config.fields or []) if f.get("key")} if field_keys: field_values = {k: v for k, v in lead_data.items() if k in field_keys and v} else: field_values = {k: v for k, v in lead_data.items() if v} email = (str(field_values.get("email") or "")).strip() if not _valid_email(email): return None # need at least a valid email to have a usable lead if config.require_consent and not consent: return None # GDPR: never record without explicit consent name = (str(field_values.get("name") or "")).strip() or None # If this email already belongs to a different customer in the org, merge the # current (anonymous) visitor into that customer and record the lead there. existing = ( db.query(Customer) .filter( Customer.organization_id == organization_id, Customer.email == email, Customer.id != customer_id, Customer.merged_into_customer_id.is_(None), ) .first() ) # A visitor bound to this customer by an email they only typed records # nothing against them: no lead, no contact details, no CRM push - and the # real customer is still asked for their details next time. if not can_act_as(db, customer, scope): logger.info(f"Lead capture: not recording for customer {customer_id}; the visitor does not own it") return None # Merging moves everything of one customer onto another, so the visitor must # be able to act as both. For a widget visitor that rules out merging into # anyone who already existed - an email they typed proves nothing - and the # lead stays on their own row. if existing is not None and not can_act_as(db, existing, scope): logger.info(f"Lead capture: not merging customer {customer_id}; the visitor does not own both") existing = None # Merge (reassigning history across rows), the new response row, the contact # update, and the promotion must all commit together — roll back on any failure # so a mid-merge error can never leave a half-merged customer. try: target_customer_id = customer_id if existing is not None: _merge_customer(db, source_id=customer_id, target=existing) target_customer_id = existing.id response = LeadCaptureResponse( organization_id=organization_id, agent_id=agent_id, customer_id=target_customer_id, session_id=session_id, field_values=field_values or None, summary=(summary or None), consent=bool(consent), qualified=True, ) db.add(response) # Update the customer's contact details so the People page / inbox show a real # email/name (reuses the handoff-capture logic + unique-constraint handling). # Phone included: an AI-captured number makes a widget lead WhatsApp-addressable. # Deliberately strict: a captured number without a '+' country code is a # national number we cannot resolve to one person worldwide, so it is # dropped (logged) rather than guessed at — a wrong-country identity key # is worse than an unset one. CustomerRepository(db).update_contact( target_customer_id, email=email or None, full_name=name, phone=(field_values or {}).get('phone')) # Promote Visitor -> Lead; never downgrade an existing Customer. _promote_to_lead(db, target_customer_id, session_id, page_url=page_url, channel=channel) db.commit() except Exception: db.rollback() raise db.refresh(response) # Post-commit so a job can never reference a rolled-back lead; wrapped so # CRM problems can never break the conversation flow. try: from app.services.crm_sync import enqueue_crm_sync enqueue_crm_sync(db, config, response) except Exception as e: logger.error(f"CRM sync enqueue failed (lead still recorded): {e}") return response def _merge_customer(db: Session, source_id, target) -> None: """Merge the (anonymous) source customer into the identified target: reassign conversation history / sessions / lead responses, then mark the source row as merged so it disappears from People and stale device tokens resolve to target.""" from app.models.customer import Customer from app.models.chat_history import ChatHistory from app.models.session_to_agent import SessionToAgent from app.models.rating import Rating source = db.query(Customer).filter(Customer.id == source_id).first() if not source or source.id == target.id: return db.query(ChatHistory).filter(ChatHistory.customer_id == source_id).update( {ChatHistory.customer_id: target.id}, synchronize_session=False) db.query(SessionToAgent).filter(SessionToAgent.customer_id == source_id).update( {SessionToAgent.customer_id: target.id}, synchronize_session=False) db.query(LeadCaptureResponse).filter(LeadCaptureResponse.customer_id == source_id).update( {LeadCaptureResponse.customer_id: target.id}, synchronize_session=False) db.query(Rating).filter(Rating.customer_id == source_id).update( {Rating.customer_id: target.id}, synchronize_session=False) # Carry over a name the target lacks; keep the row for token resolution. if not (target.full_name or "").strip() and (source.full_name or "").strip(): target.full_name = source.full_name # Identifiers union on merge: the phone follows the surviving person. The # source is cleared and FLUSHED before the target is set — the unique index # on (organization_id, phone) is checked per-UPDATE, and SQLAlchemy orders # same-mapper UPDATEs by primary key (random UUIDs), so without the flush # the target could gain the phone while the source still holds it in the # database, failing the whole merge intermittently. # # The clear is UNCONDITIONAL, even when the target already has a number and # there is nowhere to move it to. A merged row is invisible in People and # resolves to the target everywhere a human looks — but phone lookup is the # FIRST thing get_or_create_customer tries, so a tombstone left holding a # live number would silently capture that person's next WhatsApp message # onto a row nobody can see or edit. if source.phone: moved_phone = source.phone source.phone = None db.flush() if not target.phone: target.phone = moved_phone elif target.phone != moved_phone: # Two real numbers, one column: the survivor keeps theirs. Logged # because it is the one thing this merge cannot preserve — a # customer_identifiers table is what would (see the plan's # evolution path), not a second column. logger.info( f"Merge {source.id} -> {target.id}: dropped second phone " f"{moved_phone} (target keeps {target.phone})" ) source.merged_into_customer_id = target.id source.is_active = False def _promote_to_lead(db: Session, customer_id, session_id, page_url: Optional[str] = None, channel: str = "widget") -> None: from app.models.customer import Customer, LeadStage customer = db.query(Customer).filter(Customer.id == customer_id).first() if not customer: return if customer.lead_stage == LeadStage.VISITOR: customer.lead_stage = LeadStage.LEAD customer.lead_qualified_at = datetime.now(timezone.utc) # Always keep the capture context fresh (also for repeat captures on a Lead). source = dict(customer.lead_source or {}) source.setdefault("channel", channel) source["captured_at"] = datetime.now(timezone.utc).isoformat() source["session_id"] = str(session_id) if session_id else None if page_url: source["page_url"] = page_url[:500] customer.lead_source = source def record_lead_from_response( db: Session, response, organization_id, agent_id, customer_id, session_id, page_url: Optional[str] = None, channel: str = "widget", *, scope: Optional[str], ) -> Optional[LeadCaptureResponse]: """Persist a lead from a ChatResponse whose request_lead_capture is set. Shared by the widget socket handler and external channel processors so every channel records leads identically: loads the agent's lead-capture config, assembles lead_data from the explicit scalar fields (reliable under strict structured outputs) plus any free-form lead_data dict, and delegates validation/merge/promotion to record_lead_capture. Returns the LeadCaptureResponse (whose customer_id may differ from the input when the visitor was merged into an existing customer), or None if nothing was recorded. Never raises. """ try: if not getattr(response, 'request_lead_capture', False): return None from app.repositories.agent import AgentRepository agent = AgentRepository(db).get_agent(agent_id) config = getattr(agent, 'lead_capture_config', None) if agent else None if not (config and config.enabled) or has_captured_lead(db, customer_id, agent_id): return None lead_data = dict(getattr(response, 'lead_data', None) or {}) for key, attr in (('email', 'lead_email'), ('name', 'lead_name'), ('company', 'lead_company'), ('phone', 'lead_phone')): val = getattr(response, attr, None) if val and not lead_data.get(key): lead_data[key] = val return record_lead_capture( db, config, organization_id=organization_id, agent_id=agent_id, customer_id=customer_id, session_id=session_id, lead_data=lead_data, summary=getattr(response, 'lead_summary', None), consent=getattr(response, 'lead_consent', False), page_url=page_url, channel=channel, scope=scope, ) except Exception as e: # Lead capture must never break the conversation flow. logger.error(f"Lead-capture record error: {e}") return None