EMAILOUT / backend /app /cta_forms_routes.py
Seth
update
08b05b2
Raw
History Blame Contribute Delete
17.5 kB
"""
Embeddable CTA forms: create in Settings, embed on WordPress, submissions → Leads.
"""
from __future__ import annotations
import json
import re
import uuid
from datetime import datetime
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse, PlainTextResponse, Response
from sqlalchemy.orm import Session
from .database import CrmLead, CtaForm, db_commit_with_retry, get_db
from .crm_lead_contacts import link_lead_to_contact
from .models import CtaFormCreateRequest, CtaFormPatchRequest, CtaFormSubmitRequest
from .tenant_deps import TenantContext, get_tenant_context
router = APIRouter(tags=["cta-forms"])
CTA_LEAD_SOURCE = "cta_form"
CTA_LEAD_ID_PREFIX = "cta-"
DEFAULT_FIELDS: List[dict] = [
{"key": "name", "label": "Name", "type": "text", "required": True, "map_to": "name"},
{"key": "email", "label": "Email", "type": "email", "required": True, "map_to": "email"},
{
"key": "company_name",
"label": "Company Name",
"type": "text",
"required": False,
"map_to": "company_name",
},
]
_CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
}
def _safe_str(val: Any) -> str:
if val is None:
return ""
return str(val).strip()
def _slug_key(label: str, existing: set[str]) -> str:
base = re.sub(r"[^a-z0-9]+", "_", label.lower()).strip("_") or "field"
key = base
n = 2
while key in existing:
key = f"{base}_{n}"
n += 1
existing.add(key)
return key
def _normalize_fields(raw: List[Any]) -> List[dict]:
out: List[dict] = []
seen: set[str] = set()
for item in raw or []:
if isinstance(item, dict):
label = _safe_str(item.get("label")) or "Field"
key = _safe_str(item.get("key")) or _slug_key(label, seen)
if key in seen:
key = _slug_key(key, seen)
else:
seen.add(key)
ftype = _safe_str(item.get("type")) or "text"
if ftype not in ("text", "email", "tel", "textarea"):
ftype = "text"
map_to = _safe_str(item.get("map_to")) or "custom"
if map_to not in ("name", "email", "company_name", "title", "phone", "message", "custom"):
map_to = "custom"
out.append(
{
"key": key[:64],
"label": label[:120],
"type": ftype,
"required": bool(item.get("required")),
"map_to": map_to,
}
)
return out or list(DEFAULT_FIELDS)
def _build_embed_codes(
*,
origin: str,
public_id: str,
name: str,
) -> dict:
title = (name or "Form").replace('"', """)
iframe_style = (
"width:100%;max-width:100%;border:none;border-radius:12px;display:block;min-height:280px;"
)
return {
"embed_iframe": (
f'<iframe src="{origin}/embed/cta/{public_id}" title="{title}" '
f'style="{iframe_style}" loading="lazy"></iframe>'
if origin
else ""
),
"embed_script": (
f'<div id="ezofis-cta-{public_id}"></div>\n'
f'<script src="{origin}/api/embed/cta/{public_id}.js" async></script>'
if origin
else ""
),
}
def _form_to_dict(row: CtaForm, *, request: Optional[Request] = None) -> dict:
origin = ""
if request is not None:
origin = _public_origin(request)
pid = row.public_id
codes = _build_embed_codes(
origin=origin,
public_id=pid,
name=row.name or "",
)
return {
"id": row.id,
"public_id": pid,
"name": row.name or "",
"fields": row.fields if isinstance(row.fields, list) else [],
"is_active": bool(row.is_active),
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
**codes,
}
def _public_origin(request: Request) -> str:
fwd = request.headers.get("x-forwarded-proto")
proto = (fwd.split(",")[0].strip() if fwd else request.url.scheme or "https").lower()
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc
host = host.split(",")[0].strip() if host else ""
if not host:
return str(request.base_url).rstrip("/")
base = f"{proto}://{host}"
if base.startswith("http://") and ".hf.space" in base:
base = "https://" + base[7:]
return base.rstrip("/")
def _split_name(full: str) -> tuple[str, str]:
parts = _safe_str(full).split(None, 1)
if not parts:
return "", ""
if len(parts) == 1:
return parts[0], ""
return parts[0], parts[1]
def _submission_to_lead_fields(
form: CtaForm,
payload: Dict[str, str],
) -> dict:
fields_def = form.fields if isinstance(form.fields, list) else []
mapped: Dict[str, str] = {}
custom_lines: List[str] = []
for fd in fields_def:
if not isinstance(fd, dict):
continue
key = _safe_str(fd.get("key"))
label = _safe_str(fd.get("label")) or key
val = _safe_str(payload.get(key))
map_to = _safe_str(fd.get("map_to")) or "custom"
if map_to == "custom":
if val:
custom_lines.append(f"{label}: {val}")
else:
if val:
mapped[map_to] = val
first_name, last_name = "", ""
if mapped.get("name"):
first_name, last_name = _split_name(mapped["name"])
email = mapped.get("email", "")
company = mapped.get("company_name", "")
title = mapped.get("title", "")
phone = mapped.get("phone", "")
message = mapped.get("message", "")
body_lines = [f"Form: {form.name or 'CTA Form'}", "---"]
for fd in fields_def:
if not isinstance(fd, dict):
continue
key = _safe_str(fd.get("key"))
label = _safe_str(fd.get("label")) or key
val = _safe_str(payload.get(key))
if val:
body_lines.append(f"{label}: {val}")
body = "\n".join(body_lines).strip()
if message and message not in body:
body = f"{body}\n\nMessage:\n{message}".strip()
return {
"first_name": first_name,
"last_name": last_name,
"email": email,
"company_name": company,
"title": title,
"phone": phone,
"last_reply_subject": f"CTA: {form.name or 'Form submission'}",
"last_reply_body": body,
"custom_lines": custom_lines,
}
def _create_lead_from_submission(
db: Session,
form: CtaForm,
payload: Dict[str, str],
*,
page_url: str = "",
when: Optional[datetime] = None,
) -> CrmLead:
when = when or datetime.utcnow()
parsed = _submission_to_lead_fields(form, payload)
if not parsed["email"]:
raise HTTPException(status_code=400, detail="Email is required")
submission_id = uuid.uuid4().hex[:16]
lead_key = f"{CTA_LEAD_ID_PREFIX}{form.public_id}-{submission_id}"
raw: Dict[str, Any] = {
"source": CTA_LEAD_SOURCE,
"form_id": form.id,
"form_public_id": form.public_id,
"form_name": form.name,
"submission": payload,
"page_url": page_url or None,
"messages": [
{
"direction": "inbound",
"channel": "cta_form",
"subject": parsed["last_reply_subject"],
"body": parsed["last_reply_body"],
"at": when.replace(microsecond=0).isoformat() + "Z",
}
],
}
row = CrmLead(
tenant_id=form.tenant_id,
smartlead_lead_id=lead_key,
campaign_id=form.public_id,
campaign_name=form.name or "CTA Form",
email=parsed["email"],
first_name=parsed["first_name"],
last_name=parsed["last_name"],
company_name=parsed["company_name"],
title=parsed["title"],
last_reply_subject=parsed["last_reply_subject"],
last_reply_body=parsed["last_reply_body"],
last_reply_at=when,
crm_status="new_lead",
raw_webhook=raw,
)
db.add(row)
db.flush()
link_lead_to_contact(db, row)
return row
def _get_active_form(db: Session, public_id: str) -> CtaForm:
row = (
db.query(CtaForm)
.filter(CtaForm.public_id == public_id, CtaForm.is_active == 1)
.first()
)
if not row:
raise HTTPException(status_code=404, detail="Form not found")
return row
def _validate_submission(form: CtaForm, body: CtaFormSubmitRequest) -> Dict[str, str]:
fields_def = form.fields if isinstance(form.fields, list) else []
out: Dict[str, str] = {}
for fd in fields_def:
if not isinstance(fd, dict):
continue
key = _safe_str(fd.get("key"))
if not key:
continue
val = _safe_str(body.fields.get(key))
if fd.get("required") and not val:
label = _safe_str(fd.get("label")) or key
raise HTTPException(status_code=400, detail=f"{label} is required")
if val:
out[key] = val
return out
# ---- Authenticated management ----
@router.get("/api/cta-forms")
def list_cta_forms(request: Request, tc: TenantContext = Depends(get_tenant_context)):
rows = (
tc.db.query(CtaForm)
.filter(CtaForm.tenant_id == tc.tenant_id)
.order_by(CtaForm.updated_at.desc(), CtaForm.id.desc())
.all()
)
return {"forms": [_form_to_dict(r, request=request) for r in rows]}
@router.post("/api/cta-forms")
def create_cta_form(
body: CtaFormCreateRequest,
request: Request,
tc: TenantContext = Depends(get_tenant_context),
):
fields = _normalize_fields([f.model_dump() for f in body.fields] if body.fields else DEFAULT_FIELDS)
row = CtaForm(
tenant_id=tc.tenant_id,
user_id=tc.user_id,
public_id=uuid.uuid4().hex,
name=body.name.strip(),
fields=fields,
is_active=1,
)
tc.db.add(row)
db_commit_with_retry(tc.db)
tc.db.refresh(row)
return _form_to_dict(row, request=request)
@router.get("/api/cta-forms/{form_id}")
def get_cta_form(form_id: int, request: Request, tc: TenantContext = Depends(get_tenant_context)):
row = (
tc.db.query(CtaForm)
.filter(CtaForm.tenant_id == tc.tenant_id, CtaForm.id == form_id)
.first()
)
if not row:
raise HTTPException(status_code=404, detail="Form not found")
return _form_to_dict(row, request=request)
@router.patch("/api/cta-forms/{form_id}")
def patch_cta_form(
form_id: int,
body: CtaFormPatchRequest,
request: Request,
tc: TenantContext = Depends(get_tenant_context),
):
row = (
tc.db.query(CtaForm)
.filter(CtaForm.tenant_id == tc.tenant_id, CtaForm.id == form_id)
.first()
)
if not row:
raise HTTPException(status_code=404, detail="Form not found")
if body.name is not None:
row.name = body.name.strip()
if body.fields is not None:
row.fields = _normalize_fields([f.model_dump() for f in body.fields])
if body.is_active is not None:
row.is_active = 1 if body.is_active else 0
row.updated_at = datetime.utcnow()
db_commit_with_retry(tc.db)
tc.db.refresh(row)
return _form_to_dict(row, request=request)
@router.delete("/api/cta-forms/{form_id}")
def delete_cta_form(form_id: int, tc: TenantContext = Depends(get_tenant_context)):
row = (
tc.db.query(CtaForm)
.filter(CtaForm.tenant_id == tc.tenant_id, CtaForm.id == form_id)
.first()
)
if not row:
raise HTTPException(status_code=404, detail="Form not found")
tc.db.delete(row)
db_commit_with_retry(tc.db)
return {"ok": True}
# ---- Public embed + submit ----
@router.get("/api/public/cta-forms/{public_id}")
def public_cta_form(public_id: str, request: Request, db: Session = Depends(get_db)):
row = _get_active_form(db, public_id)
return JSONResponse(
content={
"public_id": row.public_id,
"name": row.name or "",
"fields": row.fields if isinstance(row.fields, list) else [],
},
headers=_CORS_HEADERS,
)
@router.options("/api/public/cta-forms/{public_id}/submit")
async def public_cta_submit_options():
return Response(headers=_CORS_HEADERS)
@router.post("/api/public/cta-forms/{public_id}/submit")
def public_cta_submit(
public_id: str,
body: CtaFormSubmitRequest,
db: Session = Depends(get_db),
):
form = _get_active_form(db, public_id)
payload = _validate_submission(form, body)
lead = _create_lead_from_submission(
db,
form,
payload,
page_url=_safe_str(body.page_url),
)
db_commit_with_retry(db)
return JSONResponse(
content={"ok": True, "lead_id": lead.id, "message": "Thank you — we will be in touch soon."},
headers=_CORS_HEADERS,
)
@router.get("/api/embed/cta/{public_id}.js")
def embed_cta_script(public_id: str, request: Request, db: Session = Depends(get_db)):
form = _get_active_form(db, public_id)
origin = _public_origin(request)
fields_json = json.dumps(form.fields if isinstance(form.fields, list) else [])
js = f"""(function() {{
var FORM_ID = {json.dumps(public_id)};
var API = {json.dumps(origin)};
var FIELDS = {fields_json};
var mount = document.getElementById('ezofis-cta-' + FORM_ID);
if (!mount) {{
var s = document.currentScript;
mount = document.createElement('div');
mount.id = 'ezofis-cta-' + FORM_ID;
if (s && s.parentNode) s.parentNode.insertBefore(mount, s);
else document.body.appendChild(mount);
}}
var css = document.createElement('style');
css.textContent = '.ezofis-cta{{font-family:system-ui,-apple-system,sans-serif;max-width:100%;margin:0 auto;padding:16px;border:1px solid #e2e8f0;border-radius:12px;background:#fff;box-sizing:border-box}}.ezofis-cta *{{box-sizing:border-box}}.ezofis-cta label{{display:block;font-size:0.8125rem;font-weight:500;color:#475569;margin-bottom:4px}}.ezofis-cta input,.ezofis-cta textarea{{width:100%;padding:10px 12px;border:1px solid #cbd5e1;border-radius:8px;font-size:0.9375rem;margin-bottom:12px}}.ezofis-cta textarea{{min-height:96px;resize:vertical}}.ezofis-cta button{{width:100%;padding:12px;background:#7c3aed;color:#fff;border:none;border-radius:8px;font-weight:600;font-size:0.9375rem;cursor:pointer}}.ezofis-cta button:disabled{{opacity:0.6;cursor:not-allowed}}.ezofis-cta .ok{{color:#15803d;font-size:0.875rem;text-align:center;padding:12px}}.ezofis-cta .err{{color:#b91c1c;font-size:0.8125rem;margin-bottom:8px}}';
document.head.appendChild(css);
var root = document.createElement('div');
root.className = 'ezofis-cta';
mount.appendChild(root);
var err = document.createElement('div');
err.className = 'err';
err.style.display = 'none';
root.appendChild(err);
var form = document.createElement('form');
root.appendChild(form);
FIELDS.forEach(function(f) {{
var wrap = document.createElement('div');
var lab = document.createElement('label');
lab.textContent = f.label + (f.required ? ' *' : '');
wrap.appendChild(lab);
var inp;
if (f.type === 'textarea') {{
inp = document.createElement('textarea');
}} else {{
inp = document.createElement('input');
inp.type = f.type || 'text';
}}
inp.name = f.key;
inp.required = !!f.required;
wrap.appendChild(inp);
form.appendChild(wrap);
}});
var btn = document.createElement('button');
btn.type = 'submit';
btn.textContent = 'Submit';
form.appendChild(btn);
form.addEventListener('submit', function(e) {{
e.preventDefault();
err.style.display = 'none';
btn.disabled = true;
var data = {{ fields: {{}}, page_url: window.location.href }};
FIELDS.forEach(function(f) {{
var el = form.querySelector('[name="' + f.key + '"]');
if (el) data.fields[f.key] = el.value || '';
}});
fetch(API + '/api/public/cta-forms/' + FORM_ID + '/submit', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify(data)
}}).then(function(r) {{
return r.json().then(function(j) {{ return {{ ok: r.ok, j: j }}; }});
}}).then(function(res) {{
if (res.ok) {{
form.innerHTML = '<div class="ok">' + (res.j.message || 'Thank you!') + '</div>';
}} else {{
err.textContent = (res.j && res.j.detail) ? (typeof res.j.detail === 'string' ? res.j.detail : 'Submission failed') : 'Submission failed';
err.style.display = 'block';
btn.disabled = false;
}}
}}).catch(function() {{
err.textContent = 'Network error. Please try again.';
err.style.display = 'block';
btn.disabled = false;
}});
}});
}})();
"""
return PlainTextResponse(js, media_type="application/javascript", headers=_CORS_HEADERS)
def lead_is_cta_form(row: CrmLead) -> bool:
raw = row.raw_webhook if isinstance(row.raw_webhook, dict) else {}
if raw.get("source") == CTA_LEAD_SOURCE:
return True
sid = _safe_str(row.smartlead_lead_id)
return sid.startswith(CTA_LEAD_ID_PREFIX)