forked from sagnik/Project_Velocity
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
This commit is contained in:
@@ -11,14 +11,23 @@ Routes:
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.services.ad_network_service import (
|
||||
AdInsight,
|
||||
BidStrategyUpdate,
|
||||
BudgetUpdate,
|
||||
Platform,
|
||||
ad_network_service,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -91,6 +100,7 @@ def _sha256_hash(value: str) -> str:
|
||||
|
||||
class CampaignCreateRequest(BaseModel):
|
||||
name: str = Field(..., description="Campaign display name")
|
||||
platform: Platform = Field(default=Platform.META, description="Target ad network platform")
|
||||
objective: str = Field("OUTCOME_LEADS", description="Meta campaign objective enum")
|
||||
budget_daily: int = Field(..., gt=0, description="Daily budget in cents (AED × 100)")
|
||||
status: str = Field("PAUSED", description="Initial campaign status — start PAUSED for review")
|
||||
@@ -121,9 +131,55 @@ class MetaAuthRequest(BaseModel):
|
||||
short_lived_token: str = Field(..., description="Short-lived user access token from Meta OAuth")
|
||||
|
||||
|
||||
@router.get("/campaigns", summary="List unified campaign summaries for the Catalyst marketing tab")
|
||||
async def list_campaigns(platform: Platform | None = Query(default=None)) -> dict:
|
||||
campaigns = await ad_network_service.list_campaigns(platform=platform)
|
||||
insights = await ad_network_service.get_insights(platform=platform, days=7)
|
||||
rollup: dict[str, dict[str, float]] = {}
|
||||
for insight in insights:
|
||||
insight_campaign_id = insight.campaign_id if isinstance(insight, AdInsight) else insight.get("campaign_id")
|
||||
if not insight_campaign_id:
|
||||
continue
|
||||
spent = insight.spend if isinstance(insight, AdInsight) else float(insight.get("spend", 0))
|
||||
impressions = insight.impressions if isinstance(insight, AdInsight) else int(insight.get("impressions", 0))
|
||||
clicks = insight.clicks if isinstance(insight, AdInsight) else int(insight.get("clicks", 0))
|
||||
conversions = insight.conversions if isinstance(insight, AdInsight) else int(insight.get("conversions", 0))
|
||||
slot = rollup.setdefault(
|
||||
insight_campaign_id,
|
||||
{
|
||||
"spent": 0.0,
|
||||
"impressions": 0.0,
|
||||
"clicks": 0.0,
|
||||
"conversions": 0.0,
|
||||
},
|
||||
)
|
||||
slot["spent"] += spent
|
||||
slot["impressions"] += impressions
|
||||
slot["clicks"] += clicks
|
||||
slot["conversions"] += conversions
|
||||
data = [
|
||||
{
|
||||
"id": campaign.id,
|
||||
"name": campaign.name,
|
||||
"platform": campaign.platform.value,
|
||||
"status": campaign.status.value,
|
||||
"budget": campaign.daily_budget,
|
||||
"spent": round(rollup.get(campaign.id, {}).get("spent", campaign.spent), 2),
|
||||
"impressions": int(rollup.get(campaign.id, {}).get("impressions", 0)),
|
||||
"clicks": int(rollup.get(campaign.id, {}).get("clicks", 0)),
|
||||
"conversions": int(rollup.get(campaign.id, {}).get("conversions", 0)),
|
||||
"objective": campaign.objective,
|
||||
"bid_strategy": campaign.bid_strategy,
|
||||
}
|
||||
for campaign in campaigns
|
||||
]
|
||||
source = "ad_network_service_live" if platform else "ad_network_service_unified"
|
||||
return _ok(data, meta={"count": len(data), "source": source})
|
||||
|
||||
|
||||
# ── 1. POST /campaigns/create ─────────────────────────────────────────────────
|
||||
|
||||
@router.post("/campaigns/create", summary="Bulk-create Meta Marketing campaigns")
|
||||
@router.post("/campaigns/create", summary="Create Meta or Google marketing campaigns")
|
||||
async def create_campaigns(
|
||||
request: Request,
|
||||
payload: CampaignCreateRequest,
|
||||
@@ -134,6 +190,25 @@ async def create_campaigns(
|
||||
|
||||
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID
|
||||
"""
|
||||
if payload.platform == Platform.GOOGLE:
|
||||
campaign_id = f"google-camp-{uuid.uuid4().hex[:8]}"
|
||||
if hasattr(request.app.state, "broadcast_live_event"):
|
||||
await request.app.state.broadcast_live_event(
|
||||
"create",
|
||||
f"Created Google Ads campaign '{payload.name}'.",
|
||||
payload.name,
|
||||
f"Budget: AED {payload.budget_daily / 100:.0f}/day",
|
||||
)
|
||||
return _ok(
|
||||
CampaignCreateResponse(
|
||||
campaign_id=campaign_id,
|
||||
name=payload.name,
|
||||
status=payload.status,
|
||||
created_at=datetime.utcnow().isoformat(),
|
||||
).model_dump(),
|
||||
meta={"platform": "google", "mode": "simulated_or_provider_managed"},
|
||||
)
|
||||
|
||||
_api, account_id = _get_sdk()
|
||||
|
||||
try:
|
||||
@@ -226,53 +301,55 @@ async def sync_creative(
|
||||
|
||||
# ── 3. GET /insights/realtime ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/insights/realtime", summary="Poll Meta Ads Insights API")
|
||||
@router.get("/insights/realtime", summary="Poll unified Meta and Google Ads insights")
|
||||
async def get_realtime_insights(
|
||||
date_preset: str = "last_7_days",
|
||||
level: str = "adset",
|
||||
campaign_id: str | None = None,
|
||||
platform: Platform | None = Query(default=None),
|
||||
days: int = Query(default=7, ge=1, le=90),
|
||||
) -> dict:
|
||||
"""
|
||||
Polls `AdAccount.get_insights()` for CTR, CPA, spend, impressions across Ad Sets.
|
||||
Supports `date_preset` (e.g. 'today', 'last_7_days', 'last_30_days') and
|
||||
`level` ('campaign', 'adset', 'ad').
|
||||
|
||||
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID
|
||||
"""
|
||||
_api, account_id = _get_sdk()
|
||||
|
||||
try:
|
||||
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
|
||||
from facebook_business.adobjects.adsinsights import AdsInsights # type: ignore
|
||||
|
||||
account = AdAccount(account_id)
|
||||
fields = [
|
||||
AdsInsights.Field.campaign_name,
|
||||
AdsInsights.Field.adset_name,
|
||||
AdsInsights.Field.spend,
|
||||
AdsInsights.Field.impressions,
|
||||
AdsInsights.Field.clicks,
|
||||
AdsInsights.Field.ctr,
|
||||
AdsInsights.Field.cpp, # cost per purchase (proxy for CPA)
|
||||
AdsInsights.Field.date_start,
|
||||
AdsInsights.Field.date_stop,
|
||||
]
|
||||
params = {
|
||||
"date_preset": date_preset,
|
||||
"level": level,
|
||||
}
|
||||
insights_cursor = account.get_insights(fields=fields, params=params)
|
||||
results = [dict(row) for row in insights_cursor]
|
||||
|
||||
return _ok(results, meta={
|
||||
"account_id": account_id,
|
||||
"date_preset": date_preset,
|
||||
"level": level,
|
||||
"count": len(results),
|
||||
})
|
||||
insights = await ad_network_service.get_insights(campaign_id=campaign_id, platform=platform, days=days)
|
||||
except Exception as exc:
|
||||
logger.error("Insights fetch failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
data = [item.model_dump() if isinstance(item, AdInsight) else item for item in insights]
|
||||
return _ok(data, meta={"count": len(data), "days": days, "platform": platform.value if platform else "all"})
|
||||
|
||||
|
||||
@router.put("/budget", summary="Update Meta or Google Ads budget and campaign status")
|
||||
async def update_campaign_budget(request: Request, payload: BudgetUpdate) -> dict:
|
||||
try:
|
||||
result = await ad_network_service.update_budget(payload)
|
||||
if hasattr(request.app.state, "broadcast_live_event"):
|
||||
await request.app.state.broadcast_live_event(
|
||||
"budget_update",
|
||||
f"Updated {payload.platform.value} budget for {payload.campaign_id}.",
|
||||
payload.campaign_id,
|
||||
f"daily={payload.daily_budget} lifetime={payload.lifetime_budget}",
|
||||
)
|
||||
return _ok(result)
|
||||
except Exception as exc:
|
||||
logger.error("Budget update failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
|
||||
@router.put("/bid-strategy", summary="Apply Meta or Google Ads bid strategy changes")
|
||||
async def update_bid_strategy(request: Request, payload: BidStrategyUpdate) -> dict:
|
||||
try:
|
||||
action = await ad_network_service.update_bid_strategy(payload)
|
||||
if hasattr(request.app.state, "broadcast_live_event"):
|
||||
await request.app.state.broadcast_live_event(
|
||||
"bid_strategy_update",
|
||||
f"Updated {payload.platform.value} bid strategy for {payload.campaign_id}.",
|
||||
payload.campaign_id,
|
||||
payload.strategy,
|
||||
)
|
||||
return _ok(action.model_dump())
|
||||
except Exception as exc:
|
||||
logger.error("Bid strategy update failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
|
||||
# ── 4. POST /audiences/lookalike ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
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
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.oracle.action_service import oracle_action_service
|
||||
from backend.oracle.persona_service import persona_service
|
||||
from backend.services.mcp_registry import mcp_registry
|
||||
from backend.services.nemoclaw_runtime import nemoclaw_runtime
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class WorkflowPreviewRequest(BaseModel):
|
||||
prompt: str = Field(..., min_length=1, max_length=4096)
|
||||
tenant_id: str = "tenant_velocity"
|
||||
actor_role: str = "sales_director"
|
||||
|
||||
|
||||
class MCPExecuteRequest(BaseModel):
|
||||
tool_name: str = Field(..., min_length=1, max_length=128)
|
||||
query: str = Field(..., min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class OracleWritebackRequest(BaseModel):
|
||||
action_id: str
|
||||
tenant_id: str = "tenant_velocity"
|
||||
actor_id: str = "oracle_operator"
|
||||
target_entity_type: str = Field(..., min_length=1, max_length=64)
|
||||
target_entity_id: str = Field(..., min_length=1, max_length=128)
|
||||
action_type: str = Field(default="lead_writeback", min_length=1, max_length=128)
|
||||
writeback_payload: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def oracle_health() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"persona": await persona_service.health(),
|
||||
"mcp_tools": mcp_registry.list_tools(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/mcp/tools")
|
||||
async def oracle_mcp_tools() -> dict:
|
||||
return {"status": "ok", "data": mcp_registry.list_tools()}
|
||||
|
||||
|
||||
@router.post("/mcp/execute")
|
||||
async def oracle_mcp_execute(request: Request, payload: MCPExecuteRequest) -> dict:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
result = await mcp_registry.execute(payload.tool_name, payload.query, crm_pool=pool)
|
||||
return {"status": "ok", "data": result}
|
||||
|
||||
|
||||
@router.post("/workflow/preview")
|
||||
async def workflow_preview(payload: WorkflowPreviewRequest) -> dict:
|
||||
persona_plan = await persona_service.plan_for_prompt(
|
||||
prompt=payload.prompt,
|
||||
tenant_id=payload.tenant_id,
|
||||
actor_role=payload.actor_role,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"persona_plan": persona_plan,
|
||||
"workflow": nemoclaw_runtime.build_workflow_dispatch(
|
||||
prompt=payload.prompt,
|
||||
tenant_id=payload.tenant_id,
|
||||
actor_role=payload.actor_role,
|
||||
component_templates=persona_plan["recommendedTemplates"],
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/actions")
|
||||
async def list_oracle_actions(status: str | None = None, limit: int = 50) -> dict:
|
||||
actions = await oracle_action_service.list_actions(status=status, limit=limit)
|
||||
return {"status": "ok", "data": actions, "meta": {"count": len(actions)}}
|
||||
|
||||
|
||||
@router.get("/actions/{action_id}")
|
||||
async def get_oracle_action(action_id: str) -> dict:
|
||||
action = await oracle_action_service.get_action(action_id)
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail=f"Oracle action '{action_id}' not found.")
|
||||
return {"status": "ok", "data": action}
|
||||
|
||||
|
||||
@router.post("/actions/writeback")
|
||||
async def apply_oracle_writeback(request: Request, payload: OracleWritebackRequest) -> dict:
|
||||
result = await oracle_action_service.apply_writeback(payload.model_dump())
|
||||
if hasattr(request.app.state, "broadcast_crm_event"):
|
||||
await request.app.state.broadcast_crm_event(
|
||||
{
|
||||
"type": "oracle_writeback",
|
||||
"entity": payload.target_entity_type,
|
||||
"entity_id": payload.target_entity_id,
|
||||
"action_id": payload.action_id,
|
||||
"payload": result["resultPayload"],
|
||||
}
|
||||
)
|
||||
return {"status": "ok", "data": result}
|
||||
|
||||
Reference in New Issue
Block a user