Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions lead_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import asyncio
import json
import logging
import re
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta

Expand All @@ -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}"
Comment on lines +25 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve non-US international numbers before adding +1

For international inputs that already include a non-US country code but happen to contain exactly 10 digits, this strips the + and rewrites the number as a US number. For example, +64 9 123 4567 becomes +16491234567 instead of being left unchanged as promised, so a valid lead phone can be corrupted and become uncallable; check for an explicit +/non-1 country code before the 10-digit US fallback.

Useful? React with 👍 / 👎.

if len(digits) == 11 and digits.startswith("1"):
return f"+{digits}"
return phone


@dataclass
class Lead:
"""Lead information captured during conversation"""
Expand Down Expand Up @@ -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():
Expand All @@ -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)

Expand Down
79 changes: 79 additions & 0 deletions tests/test_lead_phone_normalize.py
Original file line number Diff line number Diff line change
@@ -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"
Loading