ligbox-ops-platform/projects/ops-desk/api/app/crm_leads.py
Ligbox Spec Hub 821675ab4a Reorganize monorepo into projects/wizard, ops-desk, finance
Specs stay at repo root (cross-VM). Move deploy and code into logical
projects with README per domain, updated manifest.yaml, and symlinks at
legacy paths for VM122 backward compatibility.
2026-06-19 18:55:03 +00:00

199 lines
6.2 KiB
Python

"""Abandoned onboarding → Lead CRM — Spec 012 Phase B."""
from __future__ import annotations
import json
import os
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Any
ONBOARD_STALE_HOURS = int(os.getenv("ONBOARD_STALE_HOURS", "24"))
ONBOARD_SOURCE = "vm112-onboard"
FUNNEL_EVENT_RANK = {
"onboarding.started": 1,
"domain.validated": 2,
"dns.applied": 3,
"account.created": 4,
"infra.synced": 5,
"onboarding.completed": 6,
"onboarding.failed": 99,
}
FUNNEL_STAGE_BY_RANK = {
1: "started",
2: "domain_validated",
3: "dns_applied",
4: "account_created",
5: "infra_synced",
6: "completed",
99: "failed",
}
LEAD_PROMOTE_STATUSES = frozenset({"open", "escalated"})
SKIP_STAGES = frozenset({"completed", "failed"})
def _parse_payload(raw: str | None) -> dict:
if not raw:
return {}
try:
return json.loads(raw)
except json.JSONDecodeError:
return {}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def session_funnel_state(conn: sqlite3.Connection, session_id: str) -> dict[str, Any]:
sid = (session_id or "").strip()
if not sid:
return {"stage": "unknown", "last_event_at": None, "failed": False}
rows = conn.execute(
"""
SELECT event_type, payload, created_at
FROM webhook_events
WHERE source = ?
ORDER BY id ASC
""",
(ONBOARD_SOURCE,),
).fetchall()
max_rank = 0
last_event_at = None
domain = None
failed = False
for row in rows:
payload = _parse_payload(row["payload"])
if (payload.get("session_id") or "").strip() != sid:
continue
if payload.get("domain"):
domain = payload.get("domain")
last_event_at = row["created_at"]
if row["event_type"] == "onboarding.failed":
failed = True
max_rank = max(max_rank, 99)
else:
rank = FUNNEL_EVENT_RANK.get(row["event_type"], 0)
if rank > max_rank and not failed:
max_rank = rank
if failed:
stage = "failed"
else:
stage = FUNNEL_STAGE_BY_RANK.get(max_rank, "started")
return {
"stage": stage,
"last_event_at": last_event_at,
"failed": failed,
"domain": domain,
}
def is_session_stale(last_event_at: str | None, stage: str, stale_hours: int) -> bool:
if not last_event_at or stage in SKIP_STAGES:
return False
cutoff = (datetime.now(timezone.utc) - timedelta(hours=stale_hours)).isoformat()
return last_event_at < cutoff
def promote_stale_leads(conn: sqlite3.Connection, stale_hours: int | None = None) -> dict[str, Any]:
hours = stale_hours if stale_hours is not None else ONBOARD_STALE_HOURS
now = _now()
promoted_ids: list[int] = []
rows = conn.execute(
"""
SELECT id, status, session_id, subject, payload, created_at
FROM tickets
WHERE session_id IS NOT NULL AND session_id != ''
ORDER BY id DESC
LIMIT 500
"""
).fetchall()
for row in rows:
payload = _parse_payload(row["payload"])
crm_track = payload.get("crm_track") or "onboarding"
if crm_track != "onboarding":
continue
if row["status"] not in LEAD_PROMOTE_STATUSES:
continue
sid = (row["session_id"] or payload.get("session_id") or "").strip()
if not sid:
continue
meta = session_funnel_state(conn, sid)
if not is_session_stale(meta.get("last_event_at"), meta.get("stage", ""), hours):
continue
payload["crm_track"] = "lead"
payload["lead_detected_at"] = now
payload["lead_reason"] = "stale_session"
payload["lead_funnel_stage"] = meta.get("stage")
payload["lead_last_event_at"] = meta.get("last_event_at")
conn.execute(
"UPDATE tickets SET payload = ? WHERE id = ?",
(json.dumps(payload), int(row["id"])),
)
promoted_ids.append(int(row["id"]))
if promoted_ids:
conn.commit()
return {
"promoted": len(promoted_ids),
"ticket_ids": promoted_ids,
"stale_hours": hours,
"ran_at": now,
}
def lead_from_ticket(row: sqlite3.Row, conn: sqlite3.Connection | None = None) -> dict[str, Any]:
payload = _parse_payload(row["payload"])
sid = (row["session_id"] or payload.get("session_id") or "").strip()
stage = payload.get("lead_funnel_stage")
last_event = payload.get("lead_last_event_at")
domain = payload.get("domain")
if conn and sid and (not stage or not last_event):
meta = session_funnel_state(conn, sid)
stage = stage or meta.get("stage")
last_event = last_event or meta.get("last_event_at")
domain = domain or meta.get("domain")
email = payload.get("account_email") or (payload.get("data") or {}).get("email")
return {
"ticket_id": int(row["id"]),
"session_id": sid,
"domain": domain,
"email": email,
"subject": row["subject"],
"status": row["status"],
"crm_track": payload.get("crm_track"),
"lead_detected_at": payload.get("lead_detected_at"),
"lead_reason": payload.get("lead_reason"),
"funnel_stage": stage,
"last_event_at": last_event,
"created_at": row["created_at"],
"assigned_to": row["assigned_to"] if "assigned_to" in row.keys() else None,
}
def list_leads(conn: sqlite3.Connection, limit: int = 100) -> list[dict[str, Any]]:
rows = conn.execute(
"""
SELECT id, status, session_id, subject, payload, created_at, assigned_to
FROM tickets
ORDER BY id DESC
LIMIT 500
"""
).fetchall()
leads = []
for row in rows:
payload = _parse_payload(row["payload"])
if payload.get("crm_track") != "lead":
continue
leads.append(lead_from_ticket(row, conn))
if len(leads) >= limit:
break
leads.sort(key=lambda x: x.get("lead_detected_at") or "", reverse=True)
return leads
def count_leads(conn: sqlite3.Connection) -> int:
rows = conn.execute("SELECT payload FROM tickets").fetchall()
return sum(1 for row in rows if _parse_payload(row["payload"]).get("crm_track") == "lead")