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