diff --git a/lead_management.py b/lead_management.py index a137e83..c0d4050 100644 --- a/lead_management.py +++ b/lead_management.py @@ -6,6 +6,7 @@ import asyncio import json import logging +import re from dataclasses import asdict, dataclass from datetime import datetime, timedelta @@ -14,6 +15,21 @@ logger = logging.getLogger(__name__) +def normalize_phone(phone: str | None) -> str | None: + """Best-effort E.164 normalization for US numbers. Never raises; returns the + original string when it can't confidently normalize, so a lead is never lost + or mangled. Keeps the CRM consistent and makes click-to-call / SMS / dedup + reliable (e.g. "(281) 324-3020" and "281-324-3020" both -> "+12813243020").""" + if not phone: + return phone + digits = re.sub(r"\D", "", phone) + if len(digits) == 10: + return f"+1{digits}" + if len(digits) == 11 and digits.startswith("1"): + return f"+{digits}" + return phone + + @dataclass class Lead: """Lead information captured during conversation""" @@ -103,6 +119,7 @@ def __init__(self, project_id: str = None): async def create_lead(self, lead: Lead) -> Lead: """Create a new lead""" + lead.phone = normalize_phone(lead.phone) doc_ref = self.db.collection(self.collection_name).document(lead.lead_id) def _save(): @@ -113,6 +130,7 @@ def _save(): async def update_lead(self, lead: Lead) -> Lead: """Update existing lead""" + lead.phone = normalize_phone(lead.phone) lead.updated_at = datetime.utcnow().isoformat() doc_ref = self.db.collection(self.collection_name).document(lead.lead_id) diff --git a/tests/test_lead_phone_normalize.py b/tests/test_lead_phone_normalize.py new file mode 100644 index 0000000..e04fe91 --- /dev/null +++ b/tests/test_lead_phone_normalize.py @@ -0,0 +1,79 @@ +"""Lead phone numbers are normalized to E.164 on create, so the CRM is +consistent and click-to-call / SMS / dedup-grouping work. Normalization is +best-effort and never drops data (unrecognized formats pass through unchanged). +""" + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from lead_management import Lead, LeadManager, normalize_phone + + +def test_normalize_phone_us_formats(): + assert normalize_phone("(281) 324-3020") == "+12813243020" + assert normalize_phone("281-324-3020") == "+12813243020" + assert normalize_phone("2813243020") == "+12813243020" + assert normalize_phone("1 (281) 324-3020") == "+12813243020" + assert normalize_phone("+12813243020") == "+12813243020" # idempotent + + +def test_normalize_phone_never_loses_data(): + assert normalize_phone(None) is None + assert normalize_phone("") == "" + assert normalize_phone("123") == "123" # too short -> unchanged + assert normalize_phone("+44 20 7946 0958") == "+44 20 7946 0958" # intl -> unchanged + + +def test_create_lead_normalizes_phone(): + saved = {} + + class _Doc: + def set(self, data, **k): + saved.update(data) + + class _Coll: + def document(self, _id): + return _Doc() + + class _DB: + def collection(self, _n): + return _Coll() + + lm = LeadManager.__new__(LeadManager) # bypass firestore.Client() + lm.db = _DB() + lm.collection_name = "leads" + lead = Lead(lead_id="x", user_id="u", session_id="s", phone="(281) 324-3020") + asyncio.run(lm.create_lead(lead)) + assert lead.phone == "+12813243020" + # Firestore stores via Lead.to_dict() -> asdict(), i.e. snake_case field + # names. (The capitalized "Phone" key only exists in to_csv_row().) + assert saved.get("phone") == "+12813243020" + + +def test_update_lead_normalizes_phone(): + """The 'stored phones are E.164' invariant must survive edits, not just + creation -- otherwise an admin CRM edit re-introduces a raw number.""" + saved = {} + + class _Doc: + def set(self, data, **k): + saved.update(data) + + class _Coll: + def document(self, _id): + return _Doc() + + class _DB: + def collection(self, _n): + return _Coll() + + lm = LeadManager.__new__(LeadManager) # bypass firestore.Client() + lm.db = _DB() + lm.collection_name = "leads" + lead = Lead(lead_id="x", user_id="u", session_id="s", phone="281-324-3020") + asyncio.run(lm.update_lead(lead)) + assert lead.phone == "+12813243020" + assert saved.get("phone") == "+12813243020"