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.
199 lines
6.2 KiB
Python
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")
|