Files
Project_Velocity/backend/api/routes_crm.py
2026-04-28 11:32:56 +05:30

1390 lines
51 KiB
Python

from __future__ import annotations
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from backend.auth.dependencies import UserPrincipal, get_current_user
from backend.crm.canonical_schema import ensure_canonical_crm_schema
logger = logging.getLogger(__name__)
crm_router = APIRouter()
analytics_router = APIRouter()
_CRM_SCHEMA_CACHE_KEY = "_crm_schema_ready"
_KANBAN_STAGE_MAP = {
"new": "New",
"new_inquiries": "New",
"contacted": "Qualifying",
"qualifying": "Qualifying",
"qualified": "Qualifying",
"site_visit_scheduled": "Site Visit",
"site_visited": "Site Visit",
"site_visit": "Site Visit",
"site visit": "Site Visit",
"negotiation": "Negotiation",
"booking_initiated": "Closed",
"booked": "Closed",
"lost": "Closed",
"closed": "Closed",
"closed_won": "Closed",
"closed/won": "Closed",
}
_CANONICAL_STATUS_MAP = {
"new": "new",
"qualifying": "qualified",
"site_visit": "site_visit_scheduled",
"negotiation": "negotiation",
"closed": "booked",
"contacted": "contacted",
"qualified": "qualified",
"site_visit_scheduled": "site_visit_scheduled",
"site_visited": "site_visited",
"booking_initiated": "booking_initiated",
"booked": "booked",
"lost": "lost",
"dormant": "dormant",
}
_INTEL_CHANNEL_MAP = {
"whatsapp": "whatsapp",
"phone": "phone",
"email": "email",
"walkin": "site_visit",
"site_visit": "site_visit",
"office_meeting": "office_meeting",
}
def _now() -> datetime:
return datetime.now(timezone.utc)
def _normalize_stage(value: str | None) -> str:
if not value:
return "New"
return _KANBAN_STAGE_MAP.get(value.strip().lower(), value.strip())
def _stage_key(value: str) -> str:
stage = _normalize_stage(value)
return stage.lower().replace(" ", "_")
def _infer_qualification(score: int | None, source: str | None, notes: str | None) -> str:
joined = f"{source or ''} {notes or ''}".lower()
if score is None:
return "UNKNOWN"
if score >= 90 or "cash" in joined or "hnw" in joined or "family office" in joined:
return "WHALE"
if score >= 70:
return "POTENTIAL"
if score >= 45:
return "HOT"
return "TIRE_KICKER"
async def _broadcast_crm_event(request: Request, payload: dict[str, Any]) -> None:
broadcaster = getattr(request.app.state, "broadcast_crm_event", None)
if broadcaster is not None:
await broadcaster(payload)
async def _get_pool(request: Request):
pool = getattr(request.app.state, "db_pool", None)
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable.")
return pool
async def _ensure_schema(request: Request) -> None:
if getattr(request.app.state, _CRM_SCHEMA_CACHE_KEY, False):
return
pool = await _get_pool(request)
default_tenant = "tenant_velocity"
async with pool.acquire() as conn:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS leads (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity',
name TEXT NOT NULL,
email TEXT,
phone TEXT,
source TEXT NOT NULL DEFAULT 'website',
notes TEXT NOT NULL DEFAULT '',
qualification TEXT NOT NULL DEFAULT 'UNKNOWN',
score INTEGER NOT NULL DEFAULT 0,
kanban_status TEXT NOT NULL DEFAULT 'New',
budget TEXT NOT NULL DEFAULT '',
unit_interest TEXT NOT NULL DEFAULT '',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS chat_logs (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL DEFAULT 'tenant_velocity',
lead_id TEXT NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
sender TEXT NOT NULL,
channel TEXT NOT NULL DEFAULT 'oracle',
content TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
await conn.execute("ALTER TABLE leads ADD COLUMN IF NOT EXISTS tenant_id TEXT")
await conn.execute("ALTER TABLE chat_logs ADD COLUMN IF NOT EXISTS tenant_id TEXT")
await conn.execute(
"""
UPDATE leads
SET tenant_id = $1
WHERE tenant_id IS NULL OR tenant_id = ''
""",
default_tenant,
)
await conn.execute(
"""
UPDATE chat_logs
SET tenant_id = $1
WHERE tenant_id IS NULL OR tenant_id = ''
""",
default_tenant,
)
await conn.execute("ALTER TABLE leads ALTER COLUMN tenant_id SET DEFAULT 'tenant_velocity'")
await conn.execute("ALTER TABLE chat_logs ALTER COLUMN tenant_id SET DEFAULT 'tenant_velocity'")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_leads_stage ON leads (kanban_status)")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_leads_score ON leads (score DESC)")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_leads_tenant_stage ON leads (tenant_id, kanban_status)")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_logs_lead_id ON chat_logs (lead_id, created_at DESC)")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_logs_tenant_lead_id ON chat_logs (tenant_id, lead_id, created_at DESC)")
setattr(request.app.state, _CRM_SCHEMA_CACHE_KEY, True)
class LeadUpsertRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
email: str | None = Field(default=None, max_length=255)
phone: str | None = Field(default=None, max_length=64)
source: str = Field(default="website", max_length=64)
notes: str = Field(default="", max_length=5000)
qualification: str | None = Field(default=None, max_length=64)
score: int = Field(default=0, ge=0, le=100)
kanban_status: str = Field(default="New", max_length=64)
budget: str = Field(default="", max_length=255)
unit_interest: str = Field(default="", max_length=255)
metadata: dict[str, Any] = Field(default_factory=dict)
class KanbanMoveRequest(BaseModel):
lead_id: str
target_status: str
class ChatLogCreateRequest(BaseModel):
lead_id: str
sender: Literal["lead", "oracle", "system", "broker"] = "oracle"
channel: str = Field(default="oracle", max_length=64)
content: str = Field(..., min_length=1, max_length=8000)
metadata: dict[str, Any] = Field(default_factory=dict)
class SyntheticSeedRequest(BaseModel):
count: int = Field(default=100, ge=1, le=500)
def _serialize_lead(row: Any) -> dict[str, Any]:
score = int(row["score"] or 0)
status_label = _normalize_stage(row["kanban_status"])
qualification = row["qualification"] or _infer_qualification(score, row.get("source"), row.get("notes"))
return {
"id": row["id"],
"name": row["name"],
"email": row["email"],
"phone": row["phone"],
"source": row["source"],
"notes": row["notes"],
"qualification": qualification,
"score": score,
"kanban_status": status_label,
"stage": _stage_key(status_label),
"budget": row["budget"],
"unit_interest": row["unit_interest"],
"metadata": row["metadata"] or {},
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
}
def _serialize_chat_log(row: Any) -> dict[str, Any]:
return {
"id": row["id"],
"lead_id": row["lead_id"],
"sender": row["sender"],
"channel": row["channel"],
"content": row["content"],
"metadata": row["metadata"] or {},
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
def _tenant_scope(user: UserPrincipal) -> str:
return user.tenant_id
def _legacy_stage_to_canonical_status(value: str | None) -> str:
normalized = _stage_key(_normalize_stage(value))
return _CANONICAL_STATUS_MAP.get(normalized, "new")
def _legacy_score_to_urgency(score: int | None) -> str:
if score is None:
return "low"
if score >= 90:
return "high"
if score >= 70:
return "medium"
return "low"
def _legacy_channel_to_intel_channel(channel: str | None) -> str:
normalized = (channel or "").strip().lower()
return _INTEL_CHANNEL_MAP.get(normalized, "system")
def _canonical_lead_score(row: Any) -> int:
intent = float(row.get("intent_score") or 0.0)
engagement = float(row.get("engagement_score") or 0.0)
return max(0, min(100, round(max(intent, engagement) * 100)))
def _serialize_canonical_lead(row: Any) -> dict[str, Any]:
score = _canonical_lead_score(row)
source = (row.get("source") or "website").strip().lower()
if source not in {"website", "walkin", "whatsapp"}:
source = "website"
notes = row.get("notes") or ""
status_label = _normalize_stage(row.get("kanban_status"))
qualification = _infer_qualification(score, source, notes)
metadata: dict[str, Any] = {}
person_metadata = row.get("person_metadata") or {}
lead_metadata = row.get("lead_metadata") or {}
if isinstance(person_metadata, dict):
metadata.update(person_metadata)
if isinstance(lead_metadata, dict):
metadata.update(lead_metadata)
metadata["canonical_person_id"] = row.get("canonical_person_id")
metadata["canonical_lead_id"] = row.get("canonical_lead_id")
metadata["source_of_truth"] = "canonical_crm"
legacy_id = row.get("legacy_lead_id")
if legacy_id:
metadata["legacy_lead_id"] = legacy_id
created_at = row.get("created_at") or row.get("person_created_at")
updated_at = row.get("updated_at") or row.get("person_updated_at")
return {
"id": row["id"],
"name": row["name"],
"email": row.get("email"),
"phone": row.get("phone"),
"source": source,
"notes": notes,
"qualification": qualification,
"score": score,
"kanban_status": status_label,
"stage": _stage_key(status_label),
"budget": row.get("budget") or "",
"unit_interest": row.get("unit_interest") or "",
"metadata": metadata,
"created_at": created_at.isoformat() if created_at else None,
"updated_at": updated_at.isoformat() if updated_at else None,
}
async def _fetch_canonical_leads(
conn: Any,
tenant_id: str,
search: str | None = None,
) -> list[dict[str, Any]]:
clauses = ["cl.tenant_id = $1", "p.tenant_id = $1"]
params: list[Any] = [tenant_id]
if search:
params.append(f"%{search.lower()}%")
clauses.append(
f"(LOWER(p.full_name) LIKE ${len(params)} OR LOWER(COALESCE(p.primary_email, '')) LIKE ${len(params)} OR LOWER(COALESCE(p.primary_phone, '')) LIKE ${len(params)})"
)
where = " AND ".join(clauses)
rows = await conn.fetch(
f"""
SELECT
COALESCE(NULLIF(p.legacy_lead_id, ''), NULLIF(cl.legacy_lead_id, ''), cl.lead_id::text, p.person_id::text) AS id,
p.full_name AS name,
p.primary_email AS email,
p.primary_phone AS phone,
COALESCE(NULLIF(cl.source_system, ''), 'website') AS source,
COALESCE(latest_interaction.summary, cl.metadata_json->>'notes', '') AS notes,
cl.status AS kanban_status,
cl.budget_band AS budget,
COALESCE(primary_interest.unit_preference, primary_interest.configuration, primary_interest.project_name, '') AS unit_interest,
p.metadata_json AS person_metadata,
cl.metadata_json AS lead_metadata,
p.legacy_lead_id AS legacy_lead_id,
p.person_id::text AS canonical_person_id,
cl.lead_id::text AS canonical_lead_id,
COALESCE(qs.intent_value, 0.0) AS intent_score,
COALESCE(qs.engagement_value, qs.intent_value, 0.0) AS engagement_score,
p.created_at AS person_created_at,
p.updated_at AS person_updated_at,
cl.created_at,
cl.updated_at
FROM crm_leads cl
INNER JOIN crm_people p
ON p.person_id = cl.person_id
AND p.tenant_id = cl.tenant_id
LEFT JOIN LATERAL (
SELECT ii.summary
FROM intel_interactions ii
WHERE ii.tenant_id = cl.tenant_id
AND ii.person_id = p.person_id
ORDER BY ii.happened_at DESC
LIMIT 1
) latest_interaction ON TRUE
LEFT JOIN LATERAL (
SELECT cpi.project_name, cpi.unit_preference, cpi.configuration
FROM crm_property_interests cpi
WHERE cpi.tenant_id = cl.tenant_id
AND cpi.person_id = p.person_id
ORDER BY cpi.priority ASC, cpi.created_at DESC
LIMIT 1
) primary_interest ON TRUE
LEFT JOIN LATERAL (
SELECT
MAX(CASE WHEN score_type = 'intent_score' THEN current_value END) AS intent_value,
MAX(CASE WHEN score_type = 'engagement_score' THEN current_value END) AS engagement_value
FROM intel_qd_scores iqs
WHERE iqs.tenant_id = cl.tenant_id
AND iqs.person_id = p.person_id
) qs ON TRUE
WHERE {where}
ORDER BY COALESCE(qs.intent_value, 0.0) DESC, cl.updated_at DESC, cl.created_at DESC
""",
*params,
)
return [_serialize_canonical_lead(dict(row)) for row in rows]
def _apply_lead_filters(
leads: list[dict[str, Any]],
kanban_status: str | None = None,
qualification: str | None = None,
) -> list[dict[str, Any]]:
filtered = leads
if kanban_status:
target_status = _normalize_stage(kanban_status)
filtered = [lead for lead in filtered if lead["kanban_status"] == target_status]
if qualification:
target_qualification = qualification.upper()
filtered = [lead for lead in filtered if str(lead["qualification"]).upper() == target_qualification]
return filtered
def _merge_lead_sources(
canonical_leads: list[dict[str, Any]],
legacy_leads: list[dict[str, Any]],
) -> list[dict[str, Any]]:
merged: dict[str, dict[str, Any]] = {}
shadow_ids: set[str] = set()
for lead in canonical_leads:
merged[lead["id"]] = lead
metadata = lead.get("metadata") or {}
legacy_id = metadata.get("legacy_lead_id")
if isinstance(legacy_id, str) and legacy_id:
shadow_ids.add(legacy_id)
for lead in legacy_leads:
if lead["id"] in merged or lead["id"] in shadow_ids:
continue
merged[lead["id"]] = lead
return sorted(
merged.values(),
key=lambda lead: (
int(lead.get("score") or 0),
lead.get("updated_at") or "",
lead.get("created_at") or "",
),
reverse=True,
)
async def _fetch_legacy_chat_logs(conn: Any, tenant_id: str, lead_id: str | None = None, channel: str | None = None) -> list[dict[str, Any]]:
clauses: list[str] = ["tenant_id = $1"]
params: list[Any] = [tenant_id]
if lead_id:
params.append(lead_id)
clauses.append(f"lead_id = ${len(params)}")
if channel:
params.append(channel)
clauses.append(f"channel = ${len(params)}")
where = f"WHERE {' AND '.join(clauses)}"
query = f"""
SELECT id, lead_id, sender, channel, content, metadata, created_at
FROM chat_logs
{where}
ORDER BY created_at DESC
"""
rows = await conn.fetch(query, *params)
return [_serialize_chat_log(row) for row in rows]
async def _fetch_canonical_chat_logs(conn: Any, tenant_id: str, lead_id: str, channel: str | None = None) -> list[dict[str, Any]]:
params: list[Any] = [tenant_id, lead_id]
channel_clause = ""
if channel:
params.append(channel)
channel_clause = f"AND ii.channel = ${len(params)}"
rows = await conn.fetch(
f"""
SELECT
ii.interaction_id::text AS id,
COALESCE(NULLIF(p.legacy_lead_id, ''), NULLIF(cl.legacy_lead_id, ''), cl.lead_id::text, p.person_id::text) AS lead_id,
CASE
WHEN LOWER(COALESCE(ii.metadata_json->>'sender_role', '')) = 'lead' THEN 'lead'
ELSE 'oracle'
END AS sender,
COALESCE(ii.channel::text, 'oracle') AS channel,
COALESCE(ii.summary, ii.metadata_json->>'message_text', ii.interaction_type, 'Interaction logged') AS content,
ii.metadata_json AS metadata,
ii.happened_at AS created_at
FROM intel_interactions ii
INNER JOIN crm_people p
ON p.person_id = ii.person_id
AND p.tenant_id = ii.tenant_id
LEFT JOIN crm_leads cl
ON cl.lead_id = ii.lead_id
AND cl.tenant_id = ii.tenant_id
WHERE ii.tenant_id = $1
AND (
COALESCE(NULLIF(p.legacy_lead_id, ''), NULLIF(cl.legacy_lead_id, ''), cl.lead_id::text, p.person_id::text) = $2
OR cl.lead_id::text = $2
OR p.person_id::text = $2
)
{channel_clause}
ORDER BY ii.happened_at DESC
""",
*params,
)
return [_serialize_chat_log(dict(row)) for row in rows]
async def _sync_canonical_lead_bridge(
request: Request,
conn: Any,
user: UserPrincipal,
legacy_lead: dict[str, Any],
) -> dict[str, str] | None:
try:
await ensure_canonical_crm_schema(request.app)
tenant_id = _tenant_scope(user)
legacy_lead_id = str(legacy_lead["id"])
binding = await conn.fetchrow(
"""
SELECT p.person_id::text AS person_id, cl.lead_id::text AS lead_id, cl.status AS current_status
FROM crm_people p
LEFT JOIN crm_leads cl
ON cl.person_id = p.person_id
AND cl.tenant_id = p.tenant_id
AND cl.legacy_lead_id = $2
WHERE p.tenant_id = $1
AND p.legacy_lead_id = $2
ORDER BY cl.updated_at DESC NULLS LAST, cl.created_at DESC NULLS LAST
LIMIT 1
""",
tenant_id,
legacy_lead_id,
)
if binding is not None:
binding = dict(binding)
if binding is None:
binding = await conn.fetchrow(
"""
SELECT person_id::text AS person_id, lead_id::text AS lead_id, status AS current_status
FROM crm_leads
WHERE tenant_id = $1
AND legacy_lead_id = $2
ORDER BY updated_at DESC, created_at DESC
LIMIT 1
""",
tenant_id,
legacy_lead_id,
)
if binding is not None:
binding = dict(binding)
person_id = str(binding["person_id"]) if binding and binding.get("person_id") else str(uuid.uuid4())
lead_id = str(binding["lead_id"]) if binding and binding.get("lead_id") else str(uuid.uuid4())
current_status = str(binding["current_status"]) if binding and binding.get("current_status") else None
next_status = _legacy_stage_to_canonical_status(legacy_lead.get("kanban_status"))
metadata_json = json.dumps(
{
**(legacy_lead.get("metadata") or {}),
"legacy_bridge": "routes_crm",
"legacy_notes": legacy_lead.get("notes") or "",
"legacy_budget": legacy_lead.get("budget") or "",
"legacy_unit_interest": legacy_lead.get("unit_interest") or "",
}
)
if binding and binding.get("person_id"):
await conn.execute(
"""
UPDATE crm_people
SET full_name = $3,
primary_email = $4,
primary_phone = $5,
legacy_lead_id = $6,
metadata_json = COALESCE(metadata_json, '{}'::jsonb) || $7::jsonb,
updated_at = NOW()
WHERE tenant_id = $1
AND person_id = $2::uuid
""",
tenant_id,
person_id,
legacy_lead["name"],
legacy_lead.get("email"),
legacy_lead.get("phone"),
legacy_lead_id,
metadata_json,
)
else:
await conn.execute(
"""
INSERT INTO crm_people (
person_id, tenant_id, full_name, primary_email, primary_phone,
legacy_lead_id, metadata_json, created_at, updated_at
) VALUES (
$1::uuid, $2, $3, $4, $5, $6, $7::jsonb, NOW(), NOW()
)
""",
person_id,
tenant_id,
legacy_lead["name"],
legacy_lead.get("email"),
legacy_lead.get("phone"),
legacy_lead_id,
metadata_json,
)
if binding and binding.get("lead_id"):
await conn.execute(
"""
UPDATE crm_leads
SET person_id = $3::uuid,
source_system = $4,
status = $5::crm_lead_status,
budget_band = $6,
urgency = $7,
legacy_lead_id = $8,
metadata_json = COALESCE(metadata_json, '{}'::jsonb) || $9::jsonb,
updated_at = NOW()
WHERE tenant_id = $1
AND lead_id = $2::uuid
""",
tenant_id,
lead_id,
person_id,
legacy_lead.get("source") or "website",
next_status,
legacy_lead.get("budget") or "",
_legacy_score_to_urgency(legacy_lead.get("score")),
legacy_lead_id,
metadata_json,
)
else:
await conn.execute(
"""
INSERT INTO crm_leads (
lead_id, tenant_id, person_id, source_system, status, budget_band,
urgency, legacy_lead_id, metadata_json, created_at, updated_at
) VALUES (
$1::uuid, $2, $3::uuid, $4, $5::crm_lead_status, $6,
$7, $8, $9::jsonb, NOW(), NOW()
)
""",
lead_id,
tenant_id,
person_id,
legacy_lead.get("source") or "website",
next_status,
legacy_lead.get("budget") or "",
_legacy_score_to_urgency(legacy_lead.get("score")),
legacy_lead_id,
metadata_json,
)
if current_status and current_status != next_status:
await conn.execute(
"""
INSERT INTO crm_stage_history (
history_id, lead_id, from_status, to_status, changed_by_type, notes, happened_at
) VALUES (
$1::uuid, $2::uuid, $3, $4, 'system', $5, NOW()
)
""",
str(uuid.uuid4()),
lead_id,
current_status,
next_status,
f"Legacy CRM bridge update from lead {legacy_lead_id}",
)
unit_interest = (legacy_lead.get("unit_interest") or "").strip()
if unit_interest:
existing_interest = await conn.fetchrow(
"""
SELECT interest_id::text AS interest_id
FROM crm_property_interests
WHERE tenant_id = $1
AND person_id = $2::uuid
AND lead_id = $3::uuid
ORDER BY priority ASC, created_at DESC
LIMIT 1
""",
tenant_id,
person_id,
lead_id,
)
if existing_interest:
await conn.execute(
"""
UPDATE crm_property_interests
SET project_name = $4,
unit_preference = $5,
configuration = $6,
notes = $7
WHERE tenant_id = $1
AND person_id = $2::uuid
AND interest_id = $3::uuid
""",
tenant_id,
person_id,
existing_interest["interest_id"],
unit_interest,
unit_interest,
unit_interest,
legacy_lead.get("notes") or None,
)
else:
await conn.execute(
"""
INSERT INTO crm_property_interests (
interest_id, tenant_id, person_id, lead_id, project_name,
unit_preference, configuration, notes, created_at
) VALUES (
$1::uuid, $2, $3::uuid, $4::uuid, $5, $6, $7, $8, NOW()
)
""",
str(uuid.uuid4()),
tenant_id,
person_id,
lead_id,
unit_interest,
unit_interest,
unit_interest,
legacy_lead.get("notes") or None,
)
return {"person_id": person_id, "lead_id": lead_id}
except Exception as exc:
logger.warning(
"Canonical CRM write bridge unavailable for tenant %s legacy lead %s: %s",
_tenant_scope(user),
legacy_lead.get("id"),
exc,
)
return None
async def _sync_canonical_chat_log_bridge(
request: Request,
conn: Any,
user: UserPrincipal,
legacy_chat_log: dict[str, Any],
legacy_lead: dict[str, Any],
) -> None:
binding = await _sync_canonical_lead_bridge(request, conn, user, legacy_lead)
if binding is None:
return
try:
await ensure_canonical_crm_schema(request.app)
interaction_id = str(uuid.uuid4())
await conn.execute(
"""
INSERT INTO intel_interactions (
interaction_id, tenant_id, person_id, lead_id, channel,
interaction_type, happened_at, summary, source_ref, metadata_json, created_at
) VALUES (
$1::uuid, $2, $3::uuid, $4::uuid, $5::intel_channel,
'message', NOW(), $6, $7, $8::jsonb, NOW()
)
""",
interaction_id,
_tenant_scope(user),
binding["person_id"],
binding["lead_id"],
_legacy_channel_to_intel_channel(legacy_chat_log.get("channel")),
legacy_chat_log.get("content") or "",
legacy_chat_log.get("id"),
json.dumps(
{
**(legacy_chat_log.get("metadata") or {}),
"legacy_bridge": "routes_crm",
"sender_role": legacy_chat_log.get("sender") or "oracle",
"message_text": legacy_chat_log.get("content") or "",
"legacy_chat_log_id": legacy_chat_log.get("id"),
}
),
)
await conn.execute(
"""
INSERT INTO intel_messages (
message_id, interaction_id, sender_role, message_text, delivered_at, metadata_json
) VALUES (
$1::uuid, $2::uuid, $3, $4, NOW(), $5::jsonb
)
""",
str(uuid.uuid4()),
interaction_id,
legacy_chat_log.get("sender") or "oracle",
legacy_chat_log.get("content") or "",
json.dumps(
{
**(legacy_chat_log.get("metadata") or {}),
"legacy_bridge": "routes_crm",
"legacy_chat_log_id": legacy_chat_log.get("id"),
}
),
)
except Exception as exc:
logger.warning(
"Canonical CRM chat-log write bridge unavailable for tenant %s legacy log %s: %s",
_tenant_scope(user),
legacy_chat_log.get("id"),
exc,
)
async def _delete_canonical_lead_bridge(
request: Request,
conn: Any,
user: UserPrincipal,
legacy_lead_id: str,
) -> None:
try:
await ensure_canonical_crm_schema(request.app)
tenant_id = _tenant_scope(user)
binding = await conn.fetchrow(
"""
SELECT lead_id::text AS lead_id, person_id::text AS person_id
FROM crm_leads
WHERE tenant_id = $1
AND legacy_lead_id = $2
ORDER BY updated_at DESC, created_at DESC
LIMIT 1
""",
tenant_id,
legacy_lead_id,
)
if not binding:
return
await conn.execute(
"DELETE FROM crm_property_interests WHERE tenant_id = $1 AND lead_id = $2::uuid",
tenant_id,
binding["lead_id"],
)
await conn.execute(
"DELETE FROM crm_stage_history WHERE lead_id = $1::uuid",
binding["lead_id"],
)
await conn.execute(
"DELETE FROM crm_leads WHERE tenant_id = $1 AND lead_id = $2::uuid",
tenant_id,
binding["lead_id"],
)
remaining_leads = await conn.fetchval(
"SELECT COUNT(*) FROM crm_leads WHERE tenant_id = $1 AND person_id = $2::uuid",
tenant_id,
binding["person_id"],
)
if int(remaining_leads or 0) == 0:
person_row = await conn.fetchrow(
"""
SELECT legacy_lead_id
FROM crm_people
WHERE tenant_id = $1
AND person_id = $2::uuid
""",
tenant_id,
binding["person_id"],
)
if person_row is not None:
person_row = dict(person_row)
if person_row and person_row.get("legacy_lead_id") == legacy_lead_id:
await conn.execute(
"DELETE FROM crm_people WHERE tenant_id = $1 AND person_id = $2::uuid",
tenant_id,
binding["person_id"],
)
except Exception as exc:
logger.warning(
"Canonical CRM delete bridge unavailable for tenant %s legacy lead %s: %s",
_tenant_scope(user),
legacy_lead_id,
exc,
)
def _build_synthetic_leads(count: int) -> list[dict[str, Any]]:
first_names = ["Amina", "Omar", "Farah", "Rayan", "Maya", "Khalid", "Noor", "Zara", "Ibrahim", "Layla"]
last_names = ["Rahman", "Al-Farsi", "Kapoor", "Haddad", "Mehta", "Nadeem", "Shaikh", "Rao", "Wilson", "Chen"]
sources = ["website", "walkin", "whatsapp"]
stages = ["New", "Qualifying", "Site Visit", "Negotiation", "Closed"]
interests = ["2BHK Marina View", "3BHK Corner Unit", "Penthouse Sky Deck", "Investment Studio", "4BHK Sea View"]
budgets = ["AED 2.4M", "AED 4.8M", "AED 7.2M", "AED 12M", "AED 18M"]
rows: list[dict[str, Any]] = []
for idx in range(count):
score = 35 + ((idx * 7) % 61)
if idx % 12 == 0:
score = 94
name = f"{first_names[idx % len(first_names)]} {last_names[(idx * 3) % len(last_names)]}"
source = sources[idx % len(sources)]
notes = (
"Cash-ready HNI buyer focusing on waterfront premium inventory."
if score >= 90
else "Follow-up required on payment plan and amenity preferences."
)
rows.append(
{
"id": str(uuid.uuid4()),
"name": name,
"email": f"{name.lower().replace(' ', '.')}@synthetic.velocity.local",
"phone": f"+9715000{idx:05d}",
"source": source,
"notes": notes,
"qualification": _infer_qualification(score, source, notes).upper(),
"score": score,
"kanban_status": stages[idx % len(stages)],
"budget": budgets[idx % len(budgets)],
"unit_interest": interests[idx % len(interests)],
"metadata": {
"synthetic": True,
"campaign": "verification-seed",
"batch": "sprint1-root-integration",
},
}
)
return rows
@crm_router.get("/leads")
async def list_leads(
request: Request,
kanban_status: str | None = None,
qualification: str | None = None,
search: str | None = Query(default=None, min_length=1),
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
await _ensure_schema(request)
pool = await _get_pool(request)
async with pool.acquire() as conn:
try:
canonical_leads = _apply_lead_filters(
await _fetch_canonical_leads(conn, _tenant_scope(user), search),
kanban_status=kanban_status,
qualification=qualification,
)
except Exception as exc:
logger.warning("Canonical CRM lead bridge unavailable for tenant %s: %s", _tenant_scope(user), exc)
canonical_leads = []
clauses: list[str] = ["tenant_id = $1"]
params: list[Any] = [_tenant_scope(user)]
if kanban_status:
params.append(_normalize_stage(kanban_status))
clauses.append(f"kanban_status = ${len(params)}")
if qualification:
params.append(qualification.upper())
clauses.append(f"qualification = ${len(params)}")
if search:
params.append(f"%{search.lower()}%")
clauses.append(f"(LOWER(name) LIKE ${len(params)} OR LOWER(COALESCE(email, '')) LIKE ${len(params)} OR LOWER(COALESCE(phone, '')) LIKE ${len(params)})")
where = f"WHERE {' AND '.join(clauses)}"
query = f"""
SELECT id, name, email, phone, source, notes, qualification, score, kanban_status,
budget, unit_interest, metadata, created_at, updated_at
FROM leads
{where}
ORDER BY score DESC, updated_at DESC, created_at DESC
"""
rows = await conn.fetch(query, *params)
leads = _merge_lead_sources(canonical_leads, [_serialize_lead(row) for row in rows])
return {"status": "ok", "data": leads, "meta": {"count": len(leads)}}
@crm_router.post("/leads", status_code=status.HTTP_201_CREATED)
async def create_lead(
request: Request,
payload: LeadUpsertRequest,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
await _ensure_schema(request)
pool = await _get_pool(request)
lead_id = str(uuid.uuid4())
qualification = (payload.qualification or _infer_qualification(payload.score, payload.source, payload.notes)).upper()
stage = _normalize_stage(payload.kanban_status)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO leads (
id, tenant_id, name, email, phone, source, notes, qualification, score, kanban_status,
budget, unit_interest, metadata, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13::jsonb, NOW(), NOW()
)
RETURNING id, name, email, phone, source, notes, qualification, score, kanban_status,
budget, unit_interest, metadata, created_at, updated_at
""",
lead_id,
_tenant_scope(user),
payload.name,
payload.email,
payload.phone,
payload.source,
payload.notes,
qualification,
payload.score,
stage,
payload.budget,
payload.unit_interest,
json.dumps(payload.metadata),
)
await _sync_canonical_lead_bridge(request, conn, user, dict(row))
data = _serialize_lead(row)
await _broadcast_crm_event(request, {"type": "lead_created", "entity": "lead", "data": data})
return {"status": "ok", "data": data}
@crm_router.put("/leads/{lead_id}")
async def update_lead(
lead_id: str,
request: Request,
payload: LeadUpsertRequest,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
await _ensure_schema(request)
pool = await _get_pool(request)
qualification = (payload.qualification or _infer_qualification(payload.score, payload.source, payload.notes)).upper()
stage = _normalize_stage(payload.kanban_status)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
UPDATE leads
SET name = $2,
email = $3,
phone = $4,
source = $5,
notes = $6,
qualification = $7,
score = $8,
kanban_status = $9,
budget = $10,
unit_interest = $11,
metadata = $12::jsonb,
updated_at = NOW()
WHERE id = $1 AND tenant_id = $13
RETURNING id, name, email, phone, source, notes, qualification, score, kanban_status,
budget, unit_interest, metadata, created_at, updated_at
""",
lead_id,
payload.name,
payload.email,
payload.phone,
payload.source,
payload.notes,
qualification,
payload.score,
stage,
payload.budget,
payload.unit_interest,
json.dumps(payload.metadata),
_tenant_scope(user),
)
if row is not None:
await _sync_canonical_lead_bridge(request, conn, user, dict(row))
if row is None:
raise HTTPException(status_code=404, detail=f"Lead '{lead_id}' not found.")
data = _serialize_lead(row)
await _broadcast_crm_event(request, {"type": "lead_updated", "entity": "lead", "data": data})
return {"status": "ok", "data": data}
@crm_router.delete("/leads/{lead_id}")
async def delete_lead(
lead_id: str,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
await _ensure_schema(request)
pool = await _get_pool(request)
async with pool.acquire() as conn:
result = await conn.execute("DELETE FROM leads WHERE id = $1 AND tenant_id = $2", lead_id, _tenant_scope(user))
if not result.endswith("0"):
await _delete_canonical_lead_bridge(request, conn, user, lead_id)
if result.endswith("0"):
raise HTTPException(status_code=404, detail=f"Lead '{lead_id}' not found.")
await _broadcast_crm_event(request, {"type": "lead_deleted", "entity": "lead", "entity_id": lead_id})
return {"status": "ok", "data": {"id": lead_id, "deleted": True}}
@crm_router.post("/leads/seed-synthetic", status_code=status.HTTP_201_CREATED)
async def seed_synthetic_leads(
request: Request,
payload: SyntheticSeedRequest,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
await _ensure_schema(request)
pool = await _get_pool(request)
synthetic_rows = _build_synthetic_leads(payload.count)
inserted = 0
chat_logs_inserted = 0
async with pool.acquire() as conn:
for row in synthetic_rows:
await conn.execute(
"""
INSERT INTO leads (
id, tenant_id, name, email, phone, source, notes, qualification, score, kanban_status,
budget, unit_interest, metadata, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13::jsonb, NOW(), NOW()
)
ON CONFLICT (id) DO NOTHING
""",
row["id"],
_tenant_scope(user),
row["name"],
row["email"],
row["phone"],
row["source"],
row["notes"],
row["qualification"],
row["score"],
row["kanban_status"],
row["budget"],
row["unit_interest"],
json.dumps(row["metadata"]),
)
inserted += 1
for sender, channel, content in [
("lead", "whatsapp", f"{row['name']} asked for availability on {row['unit_interest']}."),
("oracle", "oracle", "Oracle generated a guided follow-up based on budget, stage, and source quality."),
]:
await conn.execute(
"""
INSERT INTO chat_logs (id, tenant_id, lead_id, sender, channel, content, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, NOW())
""",
str(uuid.uuid4()),
_tenant_scope(user),
row["id"],
sender,
channel,
content,
json.dumps({"synthetic": True}),
)
chat_logs_inserted += 1
result = {
"status": "ok",
"data": {
"seeded": inserted,
"chat_logs_seeded": chat_logs_inserted,
"batch": "sprint1-root-integration",
},
}
await _broadcast_crm_event(
request,
{
"type": "crm_seeded",
"entity": "lead_batch",
"data": result["data"],
},
)
return result
@crm_router.get("/leads/demographics")
async def lead_demographics(
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
await _ensure_schema(request)
pool = await _get_pool(request)
async with pool.acquire() as conn:
source_rows = await conn.fetch(
"""
SELECT source, COUNT(*)::int AS lead_count, COALESCE(AVG(score), 0)::float AS avg_score
FROM leads
WHERE tenant_id = $1
GROUP BY source
ORDER BY lead_count DESC, source ASC
""",
_tenant_scope(user),
)
qualification_rows = await conn.fetch(
"""
SELECT qualification, COUNT(*)::int AS lead_count
FROM leads
WHERE tenant_id = $1
GROUP BY qualification
ORDER BY lead_count DESC, qualification ASC
""",
_tenant_scope(user),
)
return {
"status": "ok",
"data": {
"by_source": [dict(row) for row in source_rows],
"by_qualification": [dict(row) for row in qualification_rows],
},
}
@crm_router.get("/leads/{lead_id}")
async def get_lead(
lead_id: str,
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
await _ensure_schema(request)
pool = await _get_pool(request)
async with pool.acquire() as conn:
try:
canonical_lead = next(
(
lead
for lead in await _fetch_canonical_leads(conn, _tenant_scope(user))
if lead["id"] == lead_id
or (lead.get("metadata") or {}).get("canonical_lead_id") == lead_id
or (lead.get("metadata") or {}).get("canonical_person_id") == lead_id
),
None,
)
if canonical_lead is not None:
return {"status": "ok", "data": canonical_lead}
except Exception as exc:
logger.warning("Canonical CRM lead lookup unavailable for tenant %s: %s", _tenant_scope(user), exc)
row = await conn.fetchrow(
"""
SELECT id, name, email, phone, source, notes, qualification, score, kanban_status,
budget, unit_interest, metadata, created_at, updated_at
FROM leads
WHERE id = $1 AND tenant_id = $2
""",
lead_id,
_tenant_scope(user),
)
if row is None:
raise HTTPException(status_code=404, detail=f"Lead '{lead_id}' not found.")
return {"status": "ok", "data": _serialize_lead(row)}
@crm_router.get("/chat-logs")
async def list_chat_logs(
request: Request,
lead_id: str | None = None,
channel: str | None = None,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
await _ensure_schema(request)
pool = await _get_pool(request)
async with pool.acquire() as conn:
data = await _fetch_legacy_chat_logs(conn, _tenant_scope(user), lead_id=lead_id, channel=channel)
if lead_id and not data:
try:
data = await _fetch_canonical_chat_logs(conn, _tenant_scope(user), lead_id, channel=channel)
except Exception as exc:
logger.warning("Canonical CRM chat-log bridge unavailable for tenant %s lead %s: %s", _tenant_scope(user), lead_id, exc)
return {"status": "ok", "data": data, "meta": {"count": len(data)}}
@crm_router.post("/chat-logs", status_code=status.HTTP_201_CREATED)
async def create_chat_log(
request: Request,
payload: ChatLogCreateRequest,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
await _ensure_schema(request)
pool = await _get_pool(request)
log_id = str(uuid.uuid4())
async with pool.acquire() as conn:
lead = await conn.fetchrow(
"""
SELECT id, name, email, phone, source, notes, qualification, score, kanban_status,
budget, unit_interest, metadata, created_at, updated_at
FROM leads
WHERE id = $1 AND tenant_id = $2
""",
payload.lead_id,
_tenant_scope(user),
)
if lead is None:
raise HTTPException(status_code=404, detail=f"Lead '{payload.lead_id}' not found.")
row = await conn.fetchrow(
"""
INSERT INTO chat_logs (id, tenant_id, lead_id, sender, channel, content, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, NOW())
RETURNING id, lead_id, sender, channel, content, metadata, created_at
""",
log_id,
_tenant_scope(user),
payload.lead_id,
payload.sender,
payload.channel,
payload.content,
json.dumps(payload.metadata),
)
await _sync_canonical_chat_log_bridge(request, conn, user, dict(row), dict(lead))
data = _serialize_chat_log(row)
await _broadcast_crm_event(request, {"type": "chat_log_created", "entity": "chat_log", "data": data})
return {"status": "ok", "data": data}
@crm_router.get("/kanban/board")
async def get_kanban_board(
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
await _ensure_schema(request)
pool = await _get_pool(request)
ordered_stages = ["New", "Qualifying", "Site Visit", "Negotiation", "Closed"]
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, name, email, phone, source, notes, qualification, score, kanban_status,
budget, unit_interest, metadata, created_at, updated_at
FROM leads
WHERE tenant_id = $1
ORDER BY score DESC, updated_at DESC, created_at DESC
""",
_tenant_scope(user),
)
leads = [_serialize_lead(row) for row in rows]
grouped = {stage: [] for stage in ordered_stages}
for lead in leads:
grouped.setdefault(lead["kanban_status"], []).append(lead)
board = [
{
"status": stage,
"stage": _stage_key(stage),
"count": len(grouped.get(stage, [])),
"items": grouped.get(stage, []),
}
for stage in ordered_stages
]
return {"status": "ok", "data": board}
@crm_router.put("/kanban/move")
async def move_kanban_card(
request: Request,
payload: KanbanMoveRequest,
user: UserPrincipal = Depends(get_current_user),
) -> dict[str, Any]:
await _ensure_schema(request)
pool = await _get_pool(request)
stage = _normalize_stage(payload.target_status)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
UPDATE leads
SET kanban_status = $2,
qualification = CASE
WHEN score >= 90 THEN 'WHALE'
WHEN score >= 70 THEN 'POTENTIAL'
WHEN score >= 45 THEN 'HOT'
ELSE qualification
END,
updated_at = NOW()
WHERE id = $1 AND tenant_id = $3
RETURNING id, name, email, phone, source, notes, qualification, score, kanban_status,
budget, unit_interest, metadata, created_at, updated_at
""",
payload.lead_id,
stage,
_tenant_scope(user),
)
if row is not None:
await _sync_canonical_lead_bridge(request, conn, user, dict(row))
if row is None:
raise HTTPException(status_code=404, detail=f"Lead '{payload.lead_id}' not found.")
data = _serialize_lead(row)
await _broadcast_crm_event(
request,
{
"type": "kanban_moved",
"entity": "lead",
"entity_id": payload.lead_id,
"data": data,
},
)
return {"status": "ok", "data": data}
@analytics_router.get("/sentiment-scatter")
async def sentiment_scatter(
request: Request,
user: UserPrincipal = Depends(get_current_user),
) -> list[dict[str, Any]]:
await _ensure_schema(request)
pool = await _get_pool(request)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT id, name, score, qualification, kanban_status, source, notes, updated_at
FROM leads
WHERE tenant_id = $1 AND score IS NOT NULL
ORDER BY score DESC, updated_at DESC
""",
_tenant_scope(user),
)
points: list[dict[str, Any]] = []
for row in rows:
score = int(row["score"] or 0)
qualification = row["qualification"] or _infer_qualification(score, row["source"], row["notes"])
points.append(
{
"id": row["id"],
"name": row["name"],
"sentiment_score": max(0, min(100, int(score * 0.82) + 10)),
"response_time_ms": max(120, 10000 - (score * 55)),
"score": score,
"qualification": qualification,
"kanban_status": _normalize_stage(row["kanban_status"]),
}
)
return points