Seth commited on
Commit
08b05b2
·
1 Parent(s): 7fe6a68
backend/app/crm_lead_contacts.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Link CRM leads to Contacts rows (campaign replies, CTA forms, manual move)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from sqlalchemy import func
8
+ from sqlalchemy.orm import Session
9
+
10
+ from .database import Contact, CrmLead
11
+
12
+ SMARTLEAD_IMPORT_FILE_ID = "smartlead-imports"
13
+ MANUAL_CONTACT_FILE_ID = "manual-contacts"
14
+ CTA_FORM_SOURCE = "cta_form"
15
+
16
+
17
+ def is_cta_form_lead(lead: CrmLead) -> bool:
18
+ rw = lead.raw_webhook if isinstance(lead.raw_webhook, dict) else {}
19
+ if rw.get("source") == CTA_FORM_SOURCE:
20
+ return True
21
+ return (lead.smartlead_lead_id or "").startswith("cta-")
22
+
23
+
24
+ def _merge_lead_fields_into_contact(contact: Contact, lead: CrmLead) -> None:
25
+ if lead.first_name:
26
+ contact.first_name = lead.first_name
27
+ if lead.last_name:
28
+ contact.last_name = lead.last_name
29
+ if lead.company_name:
30
+ contact.company = lead.company_name
31
+ if lead.title:
32
+ contact.title = lead.title
33
+ rd: Dict[str, Any] = dict(contact.raw_data or {})
34
+ rd["First Name"] = contact.first_name or rd.get("First Name", "")
35
+ rd["Last Name"] = contact.last_name or rd.get("Last Name", "")
36
+ rd["Email"] = (contact.email or lead.email or rd.get("Email", "")).strip()
37
+ rd["Company Name"] = contact.company or rd.get("Company Name", "")
38
+ rd["Title"] = contact.title or rd.get("Title", "")
39
+ if lead.last_reply_subject:
40
+ rd["last_reply_subject"] = lead.last_reply_subject
41
+ if lead.last_reply_body:
42
+ rd["last_reply_body"] = lead.last_reply_body
43
+ contact.raw_data = rd
44
+
45
+
46
+ def _contact_raw_for_lead(lead: CrmLead, *, is_cta: bool) -> dict:
47
+ if is_cta:
48
+ rw = lead.raw_webhook if isinstance(lead.raw_webhook, dict) else {}
49
+ return {
50
+ "First Name": lead.first_name or "",
51
+ "Last Name": lead.last_name or "",
52
+ "Email": (lead.email or "").strip(),
53
+ "Company Name": lead.company_name or "",
54
+ "Title": lead.title or "",
55
+ "source": CTA_FORM_SOURCE,
56
+ "form_id": rw.get("form_id"),
57
+ "form_public_id": rw.get("form_public_id"),
58
+ "form_name": rw.get("form_name"),
59
+ "last_reply_subject": lead.last_reply_subject or "",
60
+ "last_reply_body": lead.last_reply_body or "",
61
+ "cta_form_submission": rw,
62
+ }
63
+ return {
64
+ "Company Name": lead.company_name or "",
65
+ "Title": lead.title or "",
66
+ "source": "smartlead_reply",
67
+ "smartlead_lead_id": lead.smartlead_lead_id,
68
+ "campaign_id": lead.campaign_id,
69
+ "last_reply_subject": lead.last_reply_subject,
70
+ "last_reply_body": lead.last_reply_body,
71
+ "smartlead_webhook": lead.raw_webhook,
72
+ }
73
+
74
+
75
+ def link_lead_to_contact(db: Session, lead: CrmLead) -> dict:
76
+ """Create or link a Contact for this lead. CTA leads use manual-style contacts (enrichable)."""
77
+ if lead.contact_id:
78
+ return {"ok": True, "contact_id": lead.contact_id, "message": "Already linked to Contacts"}
79
+
80
+ email = (lead.email or "").strip()
81
+ if not email:
82
+ return {"ok": False, "error": "Lead has no email"}
83
+
84
+ is_cta = is_cta_form_lead(lead)
85
+ existing = (
86
+ db.query(Contact)
87
+ .filter(
88
+ Contact.tenant_id == lead.tenant_id,
89
+ func.lower(Contact.email) == email.lower(),
90
+ )
91
+ .first()
92
+ )
93
+ if existing:
94
+ lead.contact_id = existing.id
95
+ if is_cta:
96
+ _merge_lead_fields_into_contact(existing, lead)
97
+ return {"ok": True, "contact_id": existing.id, "message": "Linked to existing contact"}
98
+
99
+ contact = Contact(
100
+ tenant_id=lead.tenant_id,
101
+ file_id=MANUAL_CONTACT_FILE_ID if is_cta else SMARTLEAD_IMPORT_FILE_ID,
102
+ row_index=lead.id,
103
+ first_name=lead.first_name or "",
104
+ last_name=lead.last_name or "",
105
+ email=email,
106
+ company=lead.company_name or "",
107
+ title=lead.title or "",
108
+ source=CTA_FORM_SOURCE if is_cta else "smartlead",
109
+ raw_data=_contact_raw_for_lead(lead, is_cta=is_cta),
110
+ )
111
+ db.add(contact)
112
+ db.flush()
113
+ lead.contact_id = contact.id
114
+ return {"ok": True, "contact_id": contact.id, "message": "Contact created"}
backend/app/cta_forms_routes.py CHANGED
@@ -15,6 +15,7 @@ from fastapi.responses import JSONResponse, PlainTextResponse, Response
15
  from sqlalchemy.orm import Session
16
 
17
  from .database import CrmLead, CtaForm, db_commit_with_retry, get_db
 
18
  from .models import CtaFormCreateRequest, CtaFormPatchRequest, CtaFormSubmitRequest
19
  from .tenant_deps import TenantContext, get_tenant_context
20
 
@@ -269,6 +270,7 @@ def _create_lead_from_submission(
269
  )
270
  db.add(row)
271
  db.flush()
 
272
  return row
273
 
274
 
 
15
  from sqlalchemy.orm import Session
16
 
17
  from .database import CrmLead, CtaForm, db_commit_with_retry, get_db
18
+ from .crm_lead_contacts import link_lead_to_contact
19
  from .models import CtaFormCreateRequest, CtaFormPatchRequest, CtaFormSubmitRequest
20
  from .tenant_deps import TenantContext, get_tenant_context
21
 
 
270
  )
271
  db.add(row)
272
  db.flush()
273
+ link_lead_to_contact(db, row)
274
  return row
275
 
276
 
backend/app/main.py CHANGED
@@ -83,6 +83,11 @@ from .tenant_deps import TenantContext, get_tenant_context
83
  from .tenant_routes import router as tenant_router
84
  from .outreach_routes import router as outreach_router
85
  from .cta_forms_routes import router as cta_forms_router, lead_is_cta_form
 
 
 
 
 
86
  from .outreach_routes import (
87
  _format_wait_step_label,
88
  parse_tracking_label,
@@ -741,8 +746,6 @@ DEAL_LOST_REASON_ALLOWED = frozenset({
741
  "unknown",
742
  })
743
  DEAL_REVENUE_TYPE_ALLOWED = frozenset({"arr", "qrr", "mrr", "one_time"})
744
- SMARTLEAD_IMPORT_FILE_ID = "smartlead-imports"
745
- MANUAL_CONTACT_FILE_ID = "manual-contacts"
746
  DEMO_CONTACTS_FILE_ID = "demo-contacts-crm"
747
 
748
 
@@ -939,48 +942,7 @@ def _crm_lead_to_dict(row: CrmLead) -> dict:
939
 
940
 
941
  def _move_lead_to_contacts_core(db: Session, lead: CrmLead) -> dict:
942
- if lead.contact_id:
943
- return {"ok": True, "contact_id": lead.contact_id, "message": "Already linked to Contacts"}
944
- email = (lead.email or "").strip()
945
- if not email:
946
- return {"ok": False, "error": "Lead has no email"}
947
- existing = (
948
- db.query(Contact)
949
- .filter(
950
- Contact.tenant_id == lead.tenant_id,
951
- func.lower(Contact.email) == email.lower(),
952
- )
953
- .first()
954
- )
955
- if existing:
956
- lead.contact_id = existing.id
957
- return {"ok": True, "contact_id": existing.id, "message": "Linked to existing contact"}
958
- raw = {
959
- "Company Name": lead.company_name or "",
960
- "Title": lead.title or "",
961
- "source": "smartlead_reply",
962
- "smartlead_lead_id": lead.smartlead_lead_id,
963
- "campaign_id": lead.campaign_id,
964
- "last_reply_subject": lead.last_reply_subject,
965
- "last_reply_body": lead.last_reply_body,
966
- "smartlead_webhook": lead.raw_webhook,
967
- }
968
- contact = Contact(
969
- tenant_id=lead.tenant_id,
970
- file_id=SMARTLEAD_IMPORT_FILE_ID,
971
- row_index=lead.id,
972
- first_name=lead.first_name or "",
973
- last_name=lead.last_name or "",
974
- email=email,
975
- company=lead.company_name or "",
976
- title=lead.title or "",
977
- source="smartlead",
978
- raw_data=raw,
979
- )
980
- db.add(contact)
981
- db.flush()
982
- lead.contact_id = contact.id
983
- return {"ok": True, "contact_id": contact.id, "message": "Contact created"}
984
 
985
 
986
  CONTACTS_TO_LEADS_CAMPAIGN_ID = "contacts_import"
@@ -3887,10 +3849,10 @@ async def enrich_contact(contact_id: int, t: TenantContext = Depends(get_tenant_
3887
  if not contact:
3888
  raise HTTPException(status_code=404, detail="Contact not found")
3889
  src = (contact.source or "").strip().lower()
3890
- if src != "manual" and contact.file_id != MANUAL_CONTACT_FILE_ID:
3891
  raise HTTPException(
3892
  status_code=400,
3893
- detail="Enrich is only available for manually added contacts",
3894
  )
3895
 
3896
  seed = {
@@ -5354,6 +5316,10 @@ async def get_lead(lead_id: int, t: TenantContext = Depends(get_tenant_context))
5354
  )
5355
  if not row:
5356
  raise HTTPException(status_code=404, detail="Lead not found")
 
 
 
 
5357
  d = _crm_lead_to_dict(row)
5358
  if row.contact_id:
5359
  c = (
 
83
  from .tenant_routes import router as tenant_router
84
  from .outreach_routes import router as outreach_router
85
  from .cta_forms_routes import router as cta_forms_router, lead_is_cta_form
86
+ from .crm_lead_contacts import (
87
+ MANUAL_CONTACT_FILE_ID,
88
+ SMARTLEAD_IMPORT_FILE_ID,
89
+ link_lead_to_contact,
90
+ )
91
  from .outreach_routes import (
92
  _format_wait_step_label,
93
  parse_tracking_label,
 
746
  "unknown",
747
  })
748
  DEAL_REVENUE_TYPE_ALLOWED = frozenset({"arr", "qrr", "mrr", "one_time"})
 
 
749
  DEMO_CONTACTS_FILE_ID = "demo-contacts-crm"
750
 
751
 
 
942
 
943
 
944
  def _move_lead_to_contacts_core(db: Session, lead: CrmLead) -> dict:
945
+ return link_lead_to_contact(db, lead)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
946
 
947
 
948
  CONTACTS_TO_LEADS_CAMPAIGN_ID = "contacts_import"
 
3849
  if not contact:
3850
  raise HTTPException(status_code=404, detail="Contact not found")
3851
  src = (contact.source or "").strip().lower()
3852
+ if src not in ("manual", "cta_form") and contact.file_id != MANUAL_CONTACT_FILE_ID:
3853
  raise HTTPException(
3854
  status_code=400,
3855
+ detail="Enrich is only available for manually added or form-submission contacts",
3856
  )
3857
 
3858
  seed = {
 
5316
  )
5317
  if not row:
5318
  raise HTTPException(status_code=404, detail="Lead not found")
5319
+ if lead_is_cta_form(row) and not row.contact_id:
5320
+ link_lead_to_contact(db, row)
5321
+ db.commit()
5322
+ db.refresh(row)
5323
  d = _crm_lead_to_dict(row)
5324
  if row.contact_id:
5325
  c = (
frontend/src/pages/Contacts.jsx CHANGED
@@ -405,7 +405,10 @@ export default function Contacts() {
405
  );
406
 
407
  const isManualContact =
408
- selectedContactDetails?.source === 'manual' || selectedContact?.source === 'manual';
 
 
 
409
 
410
  const enrichContactFromGpt = async () => {
411
  if (!selectedContact?.id) return;
 
405
  );
406
 
407
  const isManualContact =
408
+ selectedContactDetails?.source === 'manual' ||
409
+ selectedContactDetails?.source === 'cta_form' ||
410
+ selectedContact?.source === 'manual' ||
411
+ selectedContact?.source === 'cta_form';
412
 
413
  const enrichContactFromGpt = async () => {
414
  if (!selectedContact?.id) return;