1390 lines
51 KiB
Python
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
|