Files
Project_Velocity/backend/api/routes_crm.py
sagnik 4645ff737b feat: Complete code integration of modules (#18)
The complete code integration is done.

Co-authored-by: Sagnik <sagnik7896@gmail.com>
Reviewed-on: sagnik/Project_Velocity#18
2026-04-12 19:20:14 +05:30

631 lines
23 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, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
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",
"qualifying": "Qualifying",
"qualified": "Qualifying",
"site_visit": "Site Visit",
"site visit": "Site Visit",
"negotiation": "Negotiation",
"closed": "Closed",
"closed_won": "Closed",
"closed/won": "Closed",
}
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)
async with pool.acquire() as conn:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS leads (
id TEXT PRIMARY KEY,
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,
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("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_chat_logs_lead_id ON chat_logs (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 _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),
) -> dict[str, Any]:
await _ensure_schema(request)
pool = await _get_pool(request)
clauses: list[str] = []
params: list[Any] = []
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)}" if clauses else ""
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
"""
async with pool.acquire() as conn:
rows = await conn.fetch(query, *params)
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) -> 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, 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::jsonb, NOW(), NOW()
)
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),
)
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) -> 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
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),
)
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) -> 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", 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) -> 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, 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::jsonb, NOW(), NOW()
)
ON CONFLICT (id) DO NOTHING
""",
row["id"],
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, lead_id, sender, channel, content, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())
""",
str(uuid.uuid4()),
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) -> 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
GROUP BY source
ORDER BY lead_count DESC, source ASC
"""
)
qualification_rows = await conn.fetch(
"""
SELECT qualification, COUNT(*)::int AS lead_count
FROM leads
GROUP BY qualification
ORDER BY lead_count DESC, qualification ASC
"""
)
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) -> dict[str, Any]:
await _ensure_schema(request)
pool = await _get_pool(request)
async with pool.acquire() as conn:
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
""",
lead_id,
)
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,
) -> dict[str, Any]:
await _ensure_schema(request)
pool = await _get_pool(request)
clauses: list[str] = []
params: list[Any] = []
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)}" if clauses else ""
query = f"""
SELECT id, lead_id, sender, channel, content, metadata, created_at
FROM chat_logs
{where}
ORDER BY created_at DESC
"""
async with pool.acquire() as conn:
rows = await conn.fetch(query, *params)
data = [_serialize_chat_log(row) for row in rows]
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) -> 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 FROM leads WHERE id = $1", payload.lead_id)
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, lead_id, sender, channel, content, metadata, created_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())
RETURNING id, lead_id, sender, channel, content, metadata, created_at
""",
log_id,
payload.lead_id,
payload.sender,
payload.channel,
payload.content,
json.dumps(payload.metadata),
)
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) -> 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
ORDER BY score DESC, updated_at DESC, created_at DESC
"""
)
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) -> 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
RETURNING id, name, email, phone, source, notes, qualification, score, kanban_status,
budget, unit_interest, metadata, created_at, updated_at
""",
payload.lead_id,
stage,
)
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) -> 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 score IS NOT NULL
ORDER BY score DESC, updated_at DESC
"""
)
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