diff --git a/salesflow-saas/.env.example b/salesflow-saas/.env.example index 9e8b4aa2..434da2fd 100644 --- a/salesflow-saas/.env.example +++ b/salesflow-saas/.env.example @@ -1,42 +1,114 @@ -# Database +# ═══════════════════════════════════════════════ +# Dealix — AI Revenue Operating System +# Environment Configuration +# ═══════════════════════════════════════════════ + +# ── App ─────────────────────────────────────── +APP_NAME=Dealix +APP_NAME_AR=ديل اي اكس +ENVIRONMENT=development +DEFAULT_TIMEZONE=Asia/Riyadh +DEFAULT_CURRENCY=SAR +DEFAULT_LOCALE=ar + +# ── Database ────────────────────────────────── DB_NAME=salesflow DB_USER=salesflow DB_PASSWORD=change_me_in_production DATABASE_URL=postgresql+asyncpg://salesflow:change_me_in_production@db:5432/salesflow +DB_POOL_SIZE=20 +DB_MAX_OVERFLOW=10 -# Redis +# ── Redis ───────────────────────────────────── REDIS_URL=redis://redis:6379/0 +REDIS_CACHE_TTL=3600 -# Security +# ── Security ────────────────────────────────── SECRET_KEY=change-this-to-a-random-secret-key-in-production ACCESS_TOKEN_EXPIRE_MINUTES=30 REFRESH_TOKEN_EXPIRE_DAYS=7 -# API +# ── URLs ────────────────────────────────────── API_URL=http://localhost:8000 FRONTEND_URL=http://localhost:3000 +WEBHOOK_BASE_URL=http://localhost:8000/api/v1/webhooks -# WhatsApp Business API +# ── LLM Providers (AI Engine) ───────────────── +# Primary: OpenAI +OPENAI_API_KEY=sk-your-openai-key +OPENAI_MODEL=gpt-4o +OPENAI_EMBEDDING_MODEL=text-embedding-3-small +OPENAI_MAX_TOKENS=4096 +OPENAI_TEMPERATURE=0.7 + +# Secondary: Groq (fast inference) +GROQ_API_KEY=gsk_your-groq-key +GROQ_MODEL=llama-3.3-70b-versatile +GROQ_MAX_TOKENS=4096 + +# Fallback: Ollama (local) +OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_MODEL=qwen2.5:7b + +# LLM Strategy +LLM_PRIMARY_PROVIDER=openai +LLM_FALLBACK_PROVIDER=groq +LLM_CACHE_ENABLED=true +LLM_RATE_LIMIT_RPM=60 + +# ── WhatsApp Business API ───────────────────── WHATSAPP_API_TOKEN= WHATSAPP_PHONE_NUMBER_ID= WHATSAPP_BUSINESS_ACCOUNT_ID= WHATSAPP_VERIFY_TOKEN= +WHATSAPP_API_VERSION=v21.0 -# Email (SendGrid or SMTP) +# ── Email ───────────────────────────────────── EMAIL_PROVIDER=smtp SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER= SMTP_PASSWORD= SENDGRID_API_KEY= +EMAIL_FROM_NAME=Dealix +EMAIL_FROM_ADDRESS=noreply@dealix.sa -# SMS (Unifonic - Saudi) +# ── SMS (Unifonic - Saudi) ──────────────────── UNIFONIC_APP_SID= UNIFONIC_SENDER_ID=Dealix -# App Settings -APP_NAME=Dealix -APP_NAME_AR=ديل اي اكس -DEFAULT_TIMEZONE=Asia/Riyadh -DEFAULT_CURRENCY=SAR -DEFAULT_LOCALE=ar +# ── Voice AI ────────────────────────────────── +VOICE_PROVIDER=elevenlabs +ELEVENLABS_API_KEY= +ELEVENLABS_VOICE_ID= +AZURE_SPEECH_KEY= +AZURE_SPEECH_REGION=uaenorth + +# ── CRM Integrations ───────────────────────── +# Salesforce +SALESFORCE_CLIENT_ID= +SALESFORCE_CLIENT_SECRET= +SALESFORCE_REDIRECT_URI= + +# HubSpot +HUBSPOT_CLIENT_ID= +HUBSPOT_CLIENT_SECRET= +HUBSPOT_API_KEY= + +# ── Calendar ────────────────────────────────── +GOOGLE_CALENDAR_CREDENTIALS= +MICROSOFT_CLIENT_ID= +MICROSOFT_CLIENT_SECRET= + +# ── Payment (Moyasar - Saudi) ───────────────── +PAYMENT_PROVIDER=moyasar +MOYASAR_API_KEY= +MOYASAR_PUBLISHABLE_KEY= +STRIPE_SECRET_KEY= +STRIPE_PUBLISHABLE_KEY= + +# ── Agent Configuration ─────────────────────── +AGENT_PROMPTS_DIR=ai-agents/prompts +AGENT_MAX_CONCURRENT=10 +AGENT_DEFAULT_TIMEOUT=60 +AGENT_ESCALATION_ENABLED=true diff --git a/salesflow-saas/backend/app/ai/__init__.py b/salesflow-saas/backend/app/ai/__init__.py new file mode 100644 index 00000000..03e20760 --- /dev/null +++ b/salesflow-saas/backend/app/ai/__init__.py @@ -0,0 +1,18 @@ +""" +Dealix AI Engine +Core AI infrastructure: LLM providers, agent execution, orchestration. +""" + +from app.ai.llm_provider import LLMProvider +from app.ai.agent_executor import AgentExecutor +from app.ai.agent_router import AgentRouter +from app.ai.orchestrator import Orchestrator +from app.ai.saudi_dialect import SaudiDialectProcessor + +__all__ = [ + "LLMProvider", + "AgentExecutor", + "AgentRouter", + "Orchestrator", + "SaudiDialectProcessor", +] diff --git a/salesflow-saas/backend/app/ai/agent_executor.py b/salesflow-saas/backend/app/ai/agent_executor.py new file mode 100644 index 00000000..125f9782 --- /dev/null +++ b/salesflow-saas/backend/app/ai/agent_executor.py @@ -0,0 +1,402 @@ +""" +Agent Executor — Loads agent configs/prompts and executes them via LLM. +Each of the 18 agents is defined in ai-agents/prompts/ with a .md prompt file. +""" + +import json +import os +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.ai.llm_provider import LLMProvider +from app.config import get_settings + +settings = get_settings() + + +class AgentExecutor: + """ + Executes any of the 18 Dealix AI agents. + + Each agent has: + - A system prompt (from ai-agents/prompts/*.md) + - An optional config (from ai-agents/*/config.yml or *.json) + - Input/output schema validation + - Escalation rules + - Logging to ai_conversations table + """ + + AGENT_REGISTRY = { + "lead_qualification": { + "prompt_file": "lead-qualification-agent.md", + "description": "Score and qualify inbound leads", + "model_preference": "openai", # needs high quality + }, + "affiliate_evaluator": { + "prompt_file": "affiliate-recruitment-evaluator.md", + "description": "Evaluate affiliate applications", + "model_preference": "openai", + }, + "onboarding_coach": { + "prompt_file": "affiliate-onboarding-coach.md", + "description": "Guide new affiliates through onboarding", + "model_preference": "groq", # speed matters + }, + "outreach_writer": { + "prompt_file": "outreach-message-writer.md", + "description": "Draft personalized outreach messages", + "model_preference": "openai", + }, + "arabic_whatsapp": { + "prompt_file": "arabic-whatsapp-agent.md", + "description": "Handle Arabic WhatsApp conversations", + "model_preference": "openai", + }, + "english_conversation": { + "prompt_file": "english-conversation-agent.md", + "description": "Handle English conversations", + "model_preference": "groq", + }, + "voice_call": { + "prompt_file": "voice-call-flow-agent.md", + "description": "Analyze voice call transcripts", + "model_preference": "openai", + }, + "meeting_booking": { + "prompt_file": "meeting-booking-agent.md", + "description": "Negotiate and book meetings", + "model_preference": "openai", + }, + "sector_strategist": { + "prompt_file": "sector-sales-strategist.md", + "description": "Generate sector-specific strategies", + "model_preference": "openai", + }, + "objection_handler": { + "prompt_file": "objection-handling-agent.md", + "description": "Handle customer objections", + "model_preference": "openai", + }, + "proposal_drafter": { + "prompt_file": "proposal-drafting-agent.md", + "description": "Generate proposals and pitch decks", + "model_preference": "openai", + }, + "qa_reviewer": { + "prompt_file": "conversation-qa-reviewer.md", + "description": "Review AI content quality", + "model_preference": "groq", + }, + "compliance_reviewer": { + "prompt_file": "compliance-reviewer.md", + "description": "Check regulatory compliance", + "model_preference": "openai", + }, + "knowledge_retrieval": { + "prompt_file": "knowledge-retrieval-agent.md", + "description": "Search knowledge base (RAG)", + "model_preference": "groq", + }, + "revenue_attribution": { + "prompt_file": "revenue-attribution-agent.md", + "description": "Attribute revenue to sources", + "model_preference": "openai", + }, + "fraud_reviewer": { + "prompt_file": "fraud-reviewer.md", + "description": "Detect fraudulent patterns", + "model_preference": "openai", + }, + "guarantee_reviewer": { + "prompt_file": "guarantee-claim-reviewer.md", + "description": "Evaluate guarantee claims", + "model_preference": "openai", + }, + "management_summary": { + "prompt_file": "management-summary-agent.md", + "description": "Generate executive summaries", + "model_preference": "openai", + }, + } + + def __init__(self, db: AsyncSession = None, llm: LLMProvider = None): + self.db = db + self.llm = llm or LLMProvider() + self._prompts_dir = Path(settings.AGENT_PROMPTS_DIR) + + # ── Execute Agent ───────────────────────────── + + async def execute( + self, + agent_type: str, + input_data: dict, + tenant_id: str = None, + lead_id: str = None, + contact_id: str = None, + conversation_history: list = None, + override_prompt: str = None, + json_mode: bool = True, + ) -> dict: + """ + Execute an AI agent and return structured results. + + Args: + agent_type: One of the 18 registered agent types + input_data: Context data for the agent + tenant_id: Tenant scope + lead_id: Optional lead association + contact_id: Optional contact association + conversation_history: Previous messages for context + override_prompt: Override the default system prompt + json_mode: Request JSON output from LLM + + Returns: + { + "agent_type": "lead_qualification", + "output": { ... structured response ... }, + "raw_content": "...", + "tokens": { ... }, + "latency_ms": 1234, + "escalation": { "needed": False }, + "conversation_id": "uuid" + } + """ + if agent_type not in self.AGENT_REGISTRY: + raise ValueError(f"Unknown agent type: {agent_type}. Available: {list(self.AGENT_REGISTRY.keys())}") + + agent_config = self.AGENT_REGISTRY[agent_type] + start = time.time() + + # Load system prompt + system_prompt = override_prompt or self._load_prompt(agent_config["prompt_file"]) + if not system_prompt: + raise FileNotFoundError(f"Prompt file not found: {agent_config['prompt_file']}") + + # Build user message from input data + user_message = self._format_input(agent_type, input_data) + + # Call LLM + response = await self.llm.chat( + system_prompt=system_prompt, + user_message=user_message, + provider=agent_config.get("model_preference"), + json_mode=json_mode, + history=conversation_history, + ) + + # Parse output + output = self._parse_output(response["content"], json_mode) + + # Check escalation rules + escalation = self._check_escalation(agent_type, output, input_data) + + total_latency = int((time.time() - start) * 1000) + + # Log to database + conversation_id = None + if self.db and tenant_id: + conversation_id = await self._log_conversation( + tenant_id=tenant_id, + agent_type=agent_type, + lead_id=lead_id, + contact_id=contact_id, + input_payload=input_data, + output_payload=output, + tokens=response.get("tokens", {}), + latency=total_latency, + status="escalated" if escalation.get("needed") else "success", + ) + + return { + "agent_type": agent_type, + "output": output, + "raw_content": response["content"], + "provider": response.get("provider"), + "model": response.get("model"), + "tokens": response.get("tokens", {}), + "latency_ms": total_latency, + "escalation": escalation, + "conversation_id": conversation_id, + "cached": response.get("cached", False), + } + + # ── Prompt Loading ──────────────────────────── + + def _load_prompt(self, filename: str) -> Optional[str]: + """Load agent prompt from file system.""" + # Try multiple possible locations + paths = [ + self._prompts_dir / filename, + Path("ai-agents") / "prompts" / filename, + Path("../ai-agents") / "prompts" / filename, + ] + + for path in paths: + if path.exists(): + return path.read_text(encoding="utf-8") + + return None + + def get_available_agents(self) -> list: + """List all available agents and their descriptions.""" + return [ + { + "type": agent_type, + "description": config["description"], + "prompt_file": config["prompt_file"], + "model_preference": config.get("model_preference", "openai"), + } + for agent_type, config in self.AGENT_REGISTRY.items() + ] + + # ── Input Formatting ────────────────────────── + + def _format_input(self, agent_type: str, data: dict) -> str: + """Format input data into a structured prompt for the agent.""" + parts = [f"## Agent Request: {agent_type}\n"] + parts.append(f"**Timestamp:** {datetime.now(timezone.utc).isoformat()}\n") + + if "lead" in data: + lead = data["lead"] + parts.append("### Lead Information") + for k, v in lead.items(): + if v: + parts.append(f"- **{k}:** {v}") + + if "conversation" in data: + parts.append("\n### Conversation History") + for msg in data["conversation"]: + role = msg.get("role", "unknown") + content = msg.get("content", "") + parts.append(f"- [{role}]: {content}") + + if "context" in data: + parts.append("\n### Additional Context") + for k, v in data["context"].items(): + parts.append(f"- **{k}:** {v}") + + # Add any remaining top-level data + skip_keys = {"lead", "conversation", "context"} + remaining = {k: v for k, v in data.items() if k not in skip_keys and v} + if remaining: + parts.append("\n### Request Data") + parts.append(json.dumps(remaining, ensure_ascii=False, indent=2)) + + parts.append("\n---\nPlease respond with a structured JSON output.") + + return "\n".join(parts) + + # ── Output Parsing ──────────────────────────── + + @staticmethod + def _parse_output(content: str, json_mode: bool) -> dict: + """Parse LLM response into structured data.""" + if json_mode: + try: + return json.loads(content) + except json.JSONDecodeError: + # Try to extract JSON from markdown code blocks + if "```json" in content: + json_str = content.split("```json")[1].split("```")[0].strip() + try: + return json.loads(json_str) + except json.JSONDecodeError: + pass + elif "```" in content: + json_str = content.split("```")[1].split("```")[0].strip() + try: + return json.loads(json_str) + except json.JSONDecodeError: + pass + + return {"raw_response": content} + + # ── Escalation Rules ────────────────────────── + + def _check_escalation(self, agent_type: str, output: dict, input_data: dict) -> dict: + """Check if the agent output triggers escalation rules.""" + escalation = {"needed": False, "reason": None, "target": None} + + if agent_type == "lead_qualification": + score = output.get("qualification_score", output.get("score", 50)) + if isinstance(score, (int, float)) and 40 <= score <= 60: + escalation = { + "needed": True, + "reason": "Ambiguous qualification score (40-60 range)", + "target": "human_review", + } + + elif agent_type == "arabic_whatsapp": + sentiment = output.get("sentiment", "") + if sentiment == "negative": + escalation = { + "needed": True, + "reason": "Negative sentiment detected in conversation", + "target": "human_agent", + } + + elif agent_type == "compliance_reviewer": + status = output.get("compliance_status", "") + if status == "non_compliant": + escalation = { + "needed": True, + "reason": "Compliance violation detected", + "target": "compliance_officer", + } + + elif agent_type == "fraud_reviewer": + risk_score = output.get("risk_score", 0) + if isinstance(risk_score, (int, float)) and risk_score > 80: + escalation = { + "needed": True, + "reason": f"High fraud risk score: {risk_score}", + "target": "admin", + } + + elif agent_type == "guarantee_reviewer": + amount = output.get("amount_claimed", 0) + if isinstance(amount, (int, float)) and amount > 50000: + escalation = { + "needed": True, + "reason": f"High-value guarantee claim: {amount} SAR", + "target": "director", + } + + return escalation + + # ── Database Logging ────────────────────────── + + async def _log_conversation( + self, + tenant_id: str, + agent_type: str, + lead_id: str = None, + contact_id: str = None, + input_payload: dict = None, + output_payload: dict = None, + tokens: dict = None, + latency: int = 0, + status: str = "success", + ) -> str: + from app.models.ai_conversation import AIConversation + + conv = AIConversation( + id=uuid.uuid4(), + tenant_id=uuid.UUID(tenant_id), + agent_type=agent_type, + lead_id=uuid.UUID(lead_id) if lead_id else None, + contact_id=uuid.UUID(contact_id) if contact_id else None, + input_payload=input_payload or {}, + output_payload=output_payload or {}, + tokens_used=tokens.get("total", 0) if tokens else 0, + latency_ms=latency, + status=status, + ) + self.db.add(conv) + await self.db.flush() + return str(conv.id) diff --git a/salesflow-saas/backend/app/ai/agent_router.py b/salesflow-saas/backend/app/ai/agent_router.py new file mode 100644 index 00000000..4f1a90fb --- /dev/null +++ b/salesflow-saas/backend/app/ai/agent_router.py @@ -0,0 +1,192 @@ +""" +Agent Router — Maps events to the correct agent(s) and handles multi-agent chaining. +""" + +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.ai.agent_executor import AgentExecutor +from app.ai.llm_provider import LLMProvider + + +# Event type → Agent(s) mapping +EVENT_AGENT_MAP = { + # Lead events + "lead.created": ["lead_qualification"], + "lead.qualified": ["outreach_writer"], + "lead.contacted": ["arabic_whatsapp"], + "lead.replied": ["arabic_whatsapp", "objection_handler"], + "lead.meeting_ready": ["meeting_booking"], + + # Message events + "message.inbound.whatsapp.ar": ["arabic_whatsapp"], + "message.inbound.whatsapp.en": ["english_conversation"], + "message.inbound.email": ["english_conversation"], + "message.objection_detected": ["objection_handler"], + + # Call events + "call.completed": ["voice_call"], + "call.transcript_ready": ["voice_call"], + + # Meeting events + "meeting.requested": ["meeting_booking"], + "meeting.confirmed": ["proposal_drafter"], + "meeting.prep_needed": ["sector_strategist", "proposal_drafter"], + + # Deal events + "deal.created": ["sector_strategist"], + "deal.proposal_needed": ["proposal_drafter"], + "deal.closed_won": ["revenue_attribution", "management_summary"], + "deal.closed_lost": ["management_summary"], + + # Affiliate events + "affiliate.applied": ["affiliate_evaluator"], + "affiliate.approved": ["onboarding_coach"], + "affiliate.fraud_suspected": ["fraud_reviewer"], + + # Compliance events + "content.review_needed": ["qa_reviewer"], + "compliance.check_needed": ["compliance_reviewer"], + + # Guarantee events + "guarantee.claimed": ["guarantee_reviewer"], + + # Knowledge events + "knowledge.query": ["knowledge_retrieval"], + + # Reporting events + "report.daily": ["management_summary"], + "report.weekly": ["management_summary"], +} + + +class AgentRouter: + """ + Routes incoming events to the appropriate AI agent(s). + Supports single-agent, multi-agent, and chained execution. + """ + + def __init__(self, db: AsyncSession, llm: LLMProvider = None): + self.db = db + self.llm = llm or LLMProvider() + self.executor = AgentExecutor(db=db, llm=self.llm) + + async def route( + self, + event_type: str, + event_data: dict, + tenant_id: str, + lead_id: str = None, + contact_id: str = None, + ) -> dict: + """ + Route an event to the appropriate agent(s). + + Returns: + { + "event": "lead.created", + "agents_invoked": ["lead_qualification"], + "results": [ { ...agent output... } ], + "escalations": [ ... ], + } + """ + agents = EVENT_AGENT_MAP.get(event_type, []) + if not agents: + return { + "event": event_type, + "agents_invoked": [], + "results": [], + "error": f"No agent mapped for event: {event_type}", + } + + results = [] + escalations = [] + + for agent_type in agents: + try: + result = await self.executor.execute( + agent_type=agent_type, + input_data=event_data, + tenant_id=tenant_id, + lead_id=lead_id, + contact_id=contact_id, + ) + results.append(result) + + if result.get("escalation", {}).get("needed"): + escalations.append({ + "agent": agent_type, + "reason": result["escalation"]["reason"], + "target": result["escalation"]["target"], + }) + + except Exception as e: + results.append({ + "agent_type": agent_type, + "error": str(e), + "status": "failed", + }) + + return { + "event": event_type, + "agents_invoked": agents, + "results": results, + "escalations": escalations, + } + + async def chain( + self, + agent_sequence: list, + initial_data: dict, + tenant_id: str, + lead_id: str = None, + ) -> dict: + """ + Execute agents in sequence, passing output of each to the next. + + Example chain: ["lead_qualification", "outreach_writer", "meeting_booking"] + """ + chain_results = [] + current_data = initial_data.copy() + + for agent_type in agent_sequence: + try: + result = await self.executor.execute( + agent_type=agent_type, + input_data=current_data, + tenant_id=tenant_id, + lead_id=lead_id, + ) + chain_results.append(result) + + # Pass output to next agent as context + if result.get("output"): + current_data["previous_agent"] = agent_type + current_data["previous_output"] = result["output"] + + # Stop chain if escalation is needed + if result.get("escalation", {}).get("needed"): + break + + except Exception as e: + chain_results.append({ + "agent_type": agent_type, + "error": str(e), + "status": "failed", + }) + break + + return { + "chain": agent_sequence, + "completed": len(chain_results), + "results": chain_results, + } + + def get_event_types(self) -> list: + """List all supported event types.""" + return list(EVENT_AGENT_MAP.keys()) + + def get_agents_for_event(self, event_type: str) -> list: + """Get agents mapped to an event type.""" + return EVENT_AGENT_MAP.get(event_type, []) diff --git a/salesflow-saas/backend/app/ai/llm_provider.py b/salesflow-saas/backend/app/ai/llm_provider.py new file mode 100644 index 00000000..d041e89c --- /dev/null +++ b/salesflow-saas/backend/app/ai/llm_provider.py @@ -0,0 +1,288 @@ +""" +LLM Provider — Unified interface for OpenAI, Groq, and Ollama. +Handles failover, caching, rate limiting, and token tracking. +""" + +import asyncio +import hashlib +import json +import time +from typing import Optional + +import httpx +from openai import AsyncOpenAI + +from app.config import get_settings + +settings = get_settings() + + +class LLMProvider: + """ + Unified LLM gateway supporting multiple providers with automatic failover. + + Usage: + llm = LLMProvider() + response = await llm.chat("You are a sales agent.", "Hello, tell me about your services.") + embedding = await llm.embed("Some text to vectorize") + """ + + def __init__(self): + self._openai = None + self._groq = None + self._cache = {} + self._token_usage = {"prompt": 0, "completion": 0, "total": 0} + self._request_count = 0 + self._last_request_time = 0 + + # ── Properties ──────────────────────────────── + + @property + def openai_client(self) -> AsyncOpenAI: + if not self._openai and settings.OPENAI_API_KEY: + self._openai = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) + return self._openai + + @property + def groq_client(self) -> AsyncOpenAI: + if not self._groq and settings.GROQ_API_KEY: + self._groq = AsyncOpenAI( + api_key=settings.GROQ_API_KEY, + base_url="https://api.groq.com/openai/v1", + ) + return self._groq + + # ── Main Chat Interface ─────────────────────── + + async def chat( + self, + system_prompt: str, + user_message: str, + model: str = None, + provider: str = None, + temperature: float = None, + max_tokens: int = None, + json_mode: bool = False, + history: list = None, + ) -> dict: + """ + Send a chat completion request with automatic failover. + + Returns: + { + "content": "The AI response text", + "provider": "openai", + "model": "gpt-4o", + "tokens": {"prompt": 100, "completion": 50, "total": 150}, + "latency_ms": 1234, + "cached": False + } + """ + # Check cache + if settings.LLM_CACHE_ENABLED: + cache_key = self._cache_key(system_prompt, user_message, model) + cached = self._get_cached(cache_key) + if cached: + return {**cached, "cached": True} + + # Rate limiting + await self._rate_limit() + + # Build messages + messages = [{"role": "system", "content": system_prompt}] + if history: + messages.extend(history) + messages.append({"role": "user", "content": user_message}) + + # Try primary provider, then fallback + primary = provider or settings.LLM_PRIMARY_PROVIDER + fallback = settings.LLM_FALLBACK_PROVIDER + + for attempt_provider in [primary, fallback]: + try: + result = await self._call_provider( + provider=attempt_provider, + messages=messages, + model=model, + temperature=temperature, + max_tokens=max_tokens, + json_mode=json_mode, + ) + + # Cache result + if settings.LLM_CACHE_ENABLED: + self._set_cached(cache_key, result) + + return result + + except Exception as e: + if attempt_provider == fallback: + # Both failed, try Ollama as last resort + try: + return await self._call_ollama(messages, temperature, max_tokens) + except Exception: + raise RuntimeError( + f"All LLM providers failed. Last error: {str(e)}" + ) + + # ── Embedding ───────────────────────────────── + + async def embed(self, text: str, model: str = None) -> list: + """Generate embeddings using OpenAI's embedding model.""" + if not self.openai_client: + raise RuntimeError("OpenAI API key not configured for embeddings") + + response = await self.openai_client.embeddings.create( + model=model or settings.OPENAI_EMBEDDING_MODEL, + input=text, + ) + return response.data[0].embedding + + async def embed_batch(self, texts: list, model: str = None) -> list: + """Generate embeddings for multiple texts.""" + if not self.openai_client: + raise RuntimeError("OpenAI API key not configured for embeddings") + + response = await self.openai_client.embeddings.create( + model=model or settings.OPENAI_EMBEDDING_MODEL, + input=texts, + ) + return [item.embedding for item in response.data] + + # ── Provider Implementations ────────────────── + + async def _call_provider( + self, + provider: str, + messages: list, + model: str = None, + temperature: float = None, + max_tokens: int = None, + json_mode: bool = False, + ) -> dict: + start = time.time() + + if provider == "openai": + client = self.openai_client + model = model or settings.OPENAI_MODEL + temp = temperature if temperature is not None else settings.OPENAI_TEMPERATURE + tokens = max_tokens or settings.OPENAI_MAX_TOKENS + elif provider == "groq": + client = self.groq_client + model = model or settings.GROQ_MODEL + temp = temperature if temperature is not None else 0.7 + tokens = max_tokens or settings.GROQ_MAX_TOKENS + else: + return await self._call_ollama(messages, temperature, max_tokens) + + if not client: + raise RuntimeError(f"Provider {provider} not configured") + + kwargs = { + "model": model, + "messages": messages, + "temperature": temp, + "max_tokens": tokens, + } + + if json_mode: + kwargs["response_format"] = {"type": "json_object"} + + response = await client.chat.completions.create(**kwargs) + latency = int((time.time() - start) * 1000) + + usage = response.usage + self._token_usage["prompt"] += usage.prompt_tokens + self._token_usage["completion"] += usage.completion_tokens + self._token_usage["total"] += usage.total_tokens + self._request_count += 1 + + return { + "content": response.choices[0].message.content, + "provider": provider, + "model": model, + "tokens": { + "prompt": usage.prompt_tokens, + "completion": usage.completion_tokens, + "total": usage.total_tokens, + }, + "latency_ms": latency, + "cached": False, + } + + async def _call_ollama( + self, + messages: list, + temperature: float = None, + max_tokens: int = None, + ) -> dict: + start = time.time() + async with httpx.AsyncClient(timeout=120) as client: + response = await client.post( + f"{settings.OLLAMA_BASE_URL}/api/chat", + json={ + "model": settings.OLLAMA_MODEL, + "messages": messages, + "stream": False, + "options": { + "temperature": temperature or 0.7, + "num_predict": max_tokens or 2048, + }, + }, + ) + response.raise_for_status() + data = response.json() + + latency = int((time.time() - start) * 1000) + return { + "content": data.get("message", {}).get("content", ""), + "provider": "ollama", + "model": settings.OLLAMA_MODEL, + "tokens": { + "prompt": data.get("prompt_eval_count", 0), + "completion": data.get("eval_count", 0), + "total": data.get("prompt_eval_count", 0) + data.get("eval_count", 0), + }, + "latency_ms": latency, + "cached": False, + } + + # ── Rate Limiting ───────────────────────────── + + async def _rate_limit(self): + now = time.time() + if now - self._last_request_time < 60 / settings.LLM_RATE_LIMIT_RPM: + await asyncio.sleep(60 / settings.LLM_RATE_LIMIT_RPM) + self._last_request_time = time.time() + + # ── Caching ─────────────────────────────────── + + @staticmethod + def _cache_key(system: str, user: str, model: str = None) -> str: + raw = f"{system}:{user}:{model or ''}" + return hashlib.sha256(raw.encode()).hexdigest() + + def _get_cached(self, key: str) -> Optional[dict]: + if key in self._cache: + entry = self._cache[key] + if time.time() - entry["time"] < settings.LLM_CACHE_TTL: + return entry["data"] + del self._cache[key] + return None + + def _set_cached(self, key: str, data: dict): + self._cache[key] = {"data": data, "time": time.time()} + # Evict old entries + if len(self._cache) > 1000: + oldest = sorted(self._cache.items(), key=lambda x: x[1]["time"]) + for k, _ in oldest[:100]: + del self._cache[k] + + # ── Stats ───────────────────────────────────── + + def get_usage_stats(self) -> dict: + return { + "token_usage": self._token_usage.copy(), + "request_count": self._request_count, + "cache_entries": len(self._cache), + } diff --git a/salesflow-saas/backend/app/ai/orchestrator.py b/salesflow-saas/backend/app/ai/orchestrator.py new file mode 100644 index 00000000..290dba43 --- /dev/null +++ b/salesflow-saas/backend/app/ai/orchestrator.py @@ -0,0 +1,344 @@ +""" +Orchestrator — THE BRAIN of Dealix. +Controls the full lead lifecycle: Lead → Qualify → Nurture → Book → Close. +Decides when to use which agent, when to escalate to humans, and when to move stages. +""" + +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.ai.agent_router import AgentRouter +from app.ai.llm_provider import LLMProvider +from app.services.lead_service import LeadService +from app.services.deal_service import DealService +from app.services.meeting_service import MeetingService +from app.services.notification_service import NotificationService +from app.services.trust_score_service import TrustScoreService + + +# Lead lifecycle state machine +LEAD_STATES = { + "new": { + "actions": ["qualify", "enrich"], + "next_states": ["contacted", "lost"], + "auto_agent": "lead_qualification", + }, + "contacted": { + "actions": ["nurture", "follow_up", "qualify"], + "next_states": ["qualified", "lost"], + "auto_agent": "outreach_writer", + }, + "qualified": { + "actions": ["book_meeting", "send_proposal"], + "next_states": ["converted", "contacted", "lost"], + "auto_agent": "meeting_booking", + }, + "converted": { + "actions": ["create_deal", "prepare_presentation"], + "next_states": [], + "auto_agent": None, + }, + "lost": { + "actions": ["re_engage"], + "next_states": ["new"], + "auto_agent": None, + }, +} + + +class Orchestrator: + """ + Central orchestration engine that automates the Lead-to-Meeting pipeline. + + The Orchestrator: + 1. Receives events (new lead, message, call, etc.) + 2. Determines the current state of the lead + 3. Decides which agent(s) to invoke + 4. Executes the appropriate action + 5. Moves the lead to the next state + 6. Notifies humans when needed + """ + + def __init__(self, db: AsyncSession, llm: LLMProvider = None): + self.db = db + self.llm = llm or LLMProvider() + self.router = AgentRouter(db=db, llm=self.llm) + self.leads = LeadService(db) + self.deals = DealService(db) + self.meetings = MeetingService(db) + self.notifications = NotificationService(db) + self.trust_scores = TrustScoreService(db) + + # ── Process New Lead ────────────────────────── + + async def process_new_lead(self, tenant_id: str, lead_id: str) -> dict: + """ + Full automated pipeline for a new lead: + 1. Calculate trust score + 2. AI qualification + 3. If qualified → auto-assign + outreach + 4. If hot → book meeting immediately + """ + actions_taken = [] + + # Step 1: Trust Score + trust = await self.trust_scores.calculate_lead_score(tenant_id, lead_id) + actions_taken.append({"action": "trust_score", "result": trust}) + + lead = await self.leads.get_lead(tenant_id, lead_id) + if not lead: + return {"error": "Lead not found", "actions": actions_taken} + + # Step 2: AI Qualification + qual_result = await self.router.route( + event_type="lead.created", + event_data={ + "lead": lead, + "trust_score": trust, + }, + tenant_id=tenant_id, + lead_id=lead_id, + ) + actions_taken.append({"action": "ai_qualification", "result": qual_result}) + + # Extract score from AI response + ai_score = 50 # default + if qual_result.get("results"): + output = qual_result["results"][0].get("output", {}) + ai_score = output.get("qualification_score", output.get("score", 50)) + if isinstance(ai_score, str): + try: + ai_score = int(ai_score) + except (ValueError, TypeError): + ai_score = 50 + + # Step 3: Update lead + await self.leads.qualify_lead(tenant_id, lead_id, ai_score) + + # Step 4: Auto-assign + if ai_score >= 40: + assign_result = await self.leads.auto_assign_round_robin(tenant_id, lead_id) + actions_taken.append({"action": "auto_assign", "result": assign_result}) + + if assign_result and assign_result.get("assigned_to"): + await self.notifications.notify_new_lead( + tenant_id, assign_result["assigned_to"], lead["full_name"] + ) + + # Step 5: Hot lead → immediate meeting booking attempt + if ai_score >= 80 and trust.get("trust_score", 0) >= 60: + outreach = await self.router.route( + event_type="lead.meeting_ready", + event_data={"lead": lead, "score": ai_score}, + tenant_id=tenant_id, + lead_id=lead_id, + ) + actions_taken.append({"action": "meeting_booking_attempt", "result": outreach}) + + # Step 6: Warm lead → nurture sequence + elif ai_score >= 40: + nurture = await self.router.route( + event_type="lead.qualified", + event_data={"lead": lead, "score": ai_score}, + tenant_id=tenant_id, + lead_id=lead_id, + ) + actions_taken.append({"action": "nurture_outreach", "result": nurture}) + + return { + "lead_id": lead_id, + "trust_score": trust.get("trust_score", 0), + "ai_score": ai_score, + "classification": trust.get("classification", "cold"), + "actions_taken": actions_taken, + "next_state": LEAD_STATES.get(lead.get("status", "new"), {}), + } + + # ── Handle Inbound Message ──────────────────── + + async def handle_inbound_message( + self, + tenant_id: str, + lead_id: str, + message: str, + channel: str = "whatsapp", + language: str = "ar", + ) -> dict: + """ + Process an inbound message from a lead: + 1. Detect language and intent + 2. Route to appropriate conversation agent + 3. Check for buying signals + 4. Auto-escalate if needed + """ + lead = await self.leads.get_lead(tenant_id, lead_id) + if not lead: + return {"error": "Lead not found"} + + # Determine event type based on language and channel + if language == "ar": + event_type = "message.inbound.whatsapp.ar" + else: + event_type = "message.inbound.whatsapp.en" + + # Execute conversation agent + result = await self.router.route( + event_type=event_type, + event_data={ + "lead": lead, + "message": message, + "channel": channel, + "language": language, + }, + tenant_id=tenant_id, + lead_id=lead_id, + ) + + # Check for meeting readiness in response + if result.get("results"): + output = result["results"][0].get("output", {}) + intent = output.get("intent", output.get("detected_intent", "")) + + if intent in ["book_meeting", "schedule", "meeting", "demo"]: + # Trigger meeting booking + booking = await self.router.route( + event_type="meeting.requested", + event_data={"lead": lead, "conversation_output": output}, + tenant_id=tenant_id, + lead_id=lead_id, + ) + result["meeting_booking"] = booking + + elif intent in ["pricing", "quote", "proposal"]: + # Trigger proposal generation + proposal = await self.router.route( + event_type="deal.proposal_needed", + event_data={"lead": lead, "conversation_output": output}, + tenant_id=tenant_id, + lead_id=lead_id, + ) + result["proposal"] = proposal + + # Handle escalations + if result.get("escalations"): + for esc in result["escalations"]: + if lead.get("assigned_to"): + await self.notifications.notify_escalation( + tenant_id, + lead["assigned_to"], + f"تصعيد من {lead['full_name']}: {esc['reason']}", + ) + + return result + + # ── Process Deal Stage Change ───────────────── + + async def process_deal_update( + self, tenant_id: str, deal_id: str, new_stage: str + ) -> dict: + """Handle deal stage transitions with automated actions.""" + deal = await self.deals.get_deal(tenant_id, deal_id) + if not deal: + return {"error": "Deal not found"} + + actions = [] + + if new_stage == "proposal": + # Auto-generate proposal + result = await self.router.route( + event_type="deal.proposal_needed", + event_data={"deal": deal}, + tenant_id=tenant_id, + ) + actions.append({"action": "generate_proposal", "result": result}) + + elif new_stage == "closed_won": + # Revenue attribution + commission + result = await self.router.route( + event_type="deal.closed_won", + event_data={"deal": deal}, + tenant_id=tenant_id, + ) + actions.append({"action": "revenue_attribution", "result": result}) + + # Notify + if deal.get("assigned_to"): + await self.notifications.notify_deal_won( + tenant_id, + deal["assigned_to"], + deal["title"], + deal.get("value", 0), + ) + + await self.deals.move_stage(tenant_id, deal_id, new_stage) + return {"deal_id": deal_id, "new_stage": new_stage, "actions": actions} + + # ── Prepare Meeting ─────────────────────────── + + async def prepare_meeting(self, tenant_id: str, meeting_id: str) -> dict: + """ + AI-powered meeting preparation: + 1. Company research + 2. Sector strategy + 3. Talking points + 4. Predicted objections + 5. Recommended presentation + """ + package = await self.meetings.prepare_meeting_package(tenant_id, meeting_id) + if not package or not package.get("lead"): + return {"error": "Meeting or lead not found"} + + lead = package["lead"] + + # Get sector strategy + strategy = await self.router.route( + event_type="meeting.prep_needed", + event_data={ + "lead": lead, + "meeting": package, + }, + tenant_id=tenant_id, + lead_id=lead.get("id"), + ) + + package["ai_preparation"] = strategy + package["status"] = "ready" + + return package + + # ── Daily Automation ────────────────────────── + + async def run_daily_automation(self, tenant_id: str) -> dict: + """ + Daily automated tasks: + 1. Score unscored leads + 2. Follow up on stale leads + 3. Remind about upcoming meetings + 4. Generate management summary + """ + results = {} + + # Score all unscored leads + score_result = await self.trust_scores.score_all_leads(tenant_id) + results["scoring"] = score_result + + # Generate daily summary + summary = await self.router.route( + event_type="report.daily", + event_data={"tenant_id": tenant_id, "type": "daily"}, + tenant_id=tenant_id, + ) + results["summary"] = summary + + return results + + # ── Status ──────────────────────────────────── + + def get_lifecycle_states(self) -> dict: + return LEAD_STATES + + def get_supported_events(self) -> list: + return self.router.get_event_types() diff --git a/salesflow-saas/backend/app/ai/saudi_dialect.py b/salesflow-saas/backend/app/ai/saudi_dialect.py new file mode 100644 index 00000000..7bbfd07a --- /dev/null +++ b/salesflow-saas/backend/app/ai/saudi_dialect.py @@ -0,0 +1,253 @@ +""" +Saudi Dialect Processor — The secret sauce for authentic Saudi Arabic AI conversations. +Handles dialect awareness, tone switching, and cultural nuances. +""" + + +class SaudiDialectProcessor: + """ + Processes and generates text in authentic Saudi Arabic dialect. + Supports multiple regional variants and formality levels. + """ + + # ── Saudi Greeting Templates ────────────────── + + GREETINGS = { + "formal": [ + "السلام عليكم ورحمة الله وبركاته", + "حياك الله ومرحبا بك", + "أهلاً وسهلاً، كيف حالك؟", + "الله يحييك، نورت", + ], + "casual": [ + "هلا والله!", + "أهلين وسهلين!", + "هلا وغلا، كيفك؟", + "يا هلا فيك!", + "حياك!", + ], + "business": [ + "السلام عليكم، حياك الله", + "أهلاً بك، يسعدنا تواصلك معنا", + "مرحباً بك في ديل اي اكس", + "حياك الله ومرحبا، كيف نقدر نخدمك؟", + ], + } + + # ── Common Saudi Expressions ────────────────── + + EXPRESSIONS = { + "yes": ["إي", "أيوا", "تمام", "أكيد", "بالتأكيد", "ان شاء الله"], + "no": ["لا", "ما يناسبني", "مو الحين", "مب الحين", "خلنا نشوف"], + "thanks": ["الله يعطيك العافية", "مشكور", "يعطيك ألف عافية", "تسلم"], + "goodbye": ["الله يوفقك", "في أمان الله", "الله يسعدك", "تشرفنا"], + "interest": ["يهمني الموضوع", "خلني أفهم أكثر", "عطني تفاصيل"], + "thinking": ["خلني أفكر", "أشوف الموضوع", "أرجع لك", "خلني أستشير"], + "agreement": ["ماشي", "تمام كذا", "موافق", "يا هلا فيها"], + "praise": ["ماشاء الله", "الله يبارك", "عمل ممتاز", "أحسنت"], + } + + # ── Industry-specific Saudi Terms ───────────── + + INDUSTRY_TERMS = { + "real_estate": { + "lead": "عميل محتمل", + "developer": "مطور عقاري", + "brokerage": "مكتب وساطة", + "listing": "عقار معروض", + "commission": "سعاية / عمولة", + }, + "restaurant": { + "franchise": "امتياز تجاري", + "delivery": "توصيل", + "dine_in": "جلسات داخلية", + "health_cert": "شهادة صحية", + }, + "healthcare": { + "clinic": "عيادة / مجمع طبي", + "appointment": "موعد", + "patient": "مريض / مراجع", + "insurance": "تأمين طبي", + }, + "education": { + "enrollment": "تسجيل", + "tuition": "رسوم دراسية", + "curriculum": "منهج دراسي", + }, + "ecommerce": { + "order": "طلب", + "shipping": "شحن", + "return": "إرجاع / استبدال", + "cart": "سلة المشتريات", + }, + } + + # ── Tone Configurations ─────────────────────── + + TONE_CONFIGS = { + "professional_friendly": { + "description": "محترف وودي — مثالي للتواصل الأولي مع الشركات", + "rules": [ + "استخدم صيغة المخاطب المفرد (أنت/حضرتك)", + "ابدأ بالسلام والتحية", + "كن مباشراً في الطرح بدون إطالة", + "استخدم أمثلة عملية من السوق السعودي", + "تجنب المبالغة في الرسمية", + "استخدم 'حضرتك' مع الرسميين و'أنت' مع الباقي", + ], + }, + "casual_warm": { + "description": "عفوي ودافئ — للمتابعة والمحادثات غير الرسمية", + "rules": [ + "استخدم تعبيرات سعودية طبيعية", + "أضف لمسة شخصية في الكلام", + "استخدم الإيموجي بشكل معتدل", + "كأنك تكلم صاحبك في شغل", + "تجنب الرسمية الزائدة", + ], + }, + "executive": { + "description": "تنفيذي رسمي — لكبار المسؤولين والشركات الكبرى", + "rules": [ + "استخدم لغة احترافية عالية", + "أرفق أرقام وإحصائيات", + "ركز على ROI والنتائج", + "استخدم 'حضرتكم' للمخاطب", + "تجنب الإطالة — المدراء مشغولين", + ], + }, + } + + # ── Regional Dialect Awareness ──────────────── + + REGIONAL_MARKERS = { + "najdi": { # Riyadh, Qassim + "markers": ["ايش", "كذا", "يا رجال", "وش لونك"], + "greeting": "هلا والله، وش لونك؟", + }, + "hijazi": { # Jeddah, Makkah, Madinah + "markers": ["كده", "ليش", "يا زين", "دحين"], + "greeting": "أهلين، كيف الحال؟", + }, + "sharqawi": { # Dammam, Khobar, Dhahran + "markers": ["شلونك", "هاي", "بعد", "يا بوي"], + "greeting": "هلا، شلونك؟", + }, + } + + # ── Main Processing Methods ─────────────────── + + @classmethod + def get_system_prompt_additions( + cls, + tone: str = "professional_friendly", + sector: str = None, + region: str = None, + ) -> str: + """ + Generate additional prompt instructions for Saudi dialect. + Append this to the agent's system prompt. + """ + parts = [] + + # Tone rules + tone_config = cls.TONE_CONFIGS.get(tone, cls.TONE_CONFIGS["professional_friendly"]) + parts.append(f"\n## أسلوب التواصل: {tone_config['description']}") + parts.append("### قواعد الأسلوب:") + for rule in tone_config["rules"]: + parts.append(f"- {rule}") + + # Sector-specific terms + if sector and sector in cls.INDUSTRY_TERMS: + parts.append(f"\n### مصطلحات القطاع ({sector}):") + for eng, ar in cls.INDUSTRY_TERMS[sector].items(): + parts.append(f"- {eng} = {ar}") + + # Regional awareness + if region and region in cls.REGIONAL_MARKERS: + regional = cls.REGIONAL_MARKERS[region] + parts.append(f"\n### لهجة المنطقة ({region}):") + parts.append(f"- تحية مناسبة: {regional['greeting']}") + parts.append(f"- كلمات مألوفة: {', '.join(regional['markers'])}") + + # General Saudi rules + parts.append("\n### قواعد عامة للتواصل بالسعودية:") + parts.append("- لا تستخدم لهجة مصرية أو شامية أو مغربية") + parts.append("- استخدم 'ريال' وليس 'جنيه' أو 'دولار'") + parts.append("- راعي أوقات العمل السعودية (الأحد-الخميس)") + parts.append("- احترم أوقات الصلاة وتجنب التواصل خلالها") + parts.append("- استخدم التقويم الهجري إذا ذُكر") + parts.append("- الشركات الحكومية = رسمي جداً") + parts.append("- القطاع الخاص = محترف وودي") + parts.append("- المنشآت الصغيرة = عفوي ومباشر") + + return "\n".join(parts) + + @classmethod + def get_greeting(cls, tone: str = "business") -> str: + """Get a random appropriate greeting.""" + import random + greetings = cls.GREETINGS.get(tone, cls.GREETINGS["business"]) + return random.choice(greetings) + + @classmethod + def get_farewell(cls) -> str: + import random + return random.choice(cls.EXPRESSIONS["goodbye"]) + + @classmethod + def enhance_message(cls, message: str, tone: str = "professional_friendly") -> str: + """Add Saudi conversational touches to a message.""" + # This is a simple enhancement; the real magic happens in the LLM prompt + if not message.startswith(("السلام", "أهلا", "هلا", "حياك", "مرحب")): + greeting = cls.get_greeting( + "formal" if tone == "executive" else "business" + ) + message = f"{greeting}\n\n{message}" + + if not message.endswith(("الله", "عافية", "تشرفنا", "أمان")): + farewell = cls.get_farewell() + message = f"{message}\n\n{farewell}" + + return message + + @classmethod + def detect_region(cls, text: str) -> str: + """Detect the regional dialect from text.""" + text_lower = text.lower() + scores = {} + + for region, config in cls.REGIONAL_MARKERS.items(): + score = sum(1 for marker in config["markers"] if marker in text_lower) + scores[region] = score + + if max(scores.values(), default=0) > 0: + return max(scores, key=scores.get) + return "najdi" # Default to Najdi (most common) + + @classmethod + def get_objection_responses(cls, objection_type: str) -> list: + """Get culturally appropriate objection responses.""" + responses = { + "price": [ + "أفهم تماماً، خلني أوضح لك القيمة اللي بترجع عليك من الاستثمار هذا...", + "سعرنا تنافسي مقارنة بالسوق، وعندنا ضمان ذهبي إذا ما حصلت نتائج", + "كثير من عملاءنا قالوا نفس الكلام بالبداية، بس بعد ما جربوا شافوا الفرق", + ], + "timing": [ + "ما فيه أحسن وقت من الحين، المنافسين ما بينتظرونك", + "أفهم إنك مشغول، ممكن نحجز لك 15 دقيقة بس عشان توضح لك الصورة", + "كثير من الشركات تأجل وبعدين تندم إنها ما بدت بدري", + ], + "competitor": [ + "كل نظام له مميزاته، بس خلني أوريك وش يميزنا عنهم بالتحديد", + "حياك، المقارنة حقك. خلنا نسوي لك عرض مقارنة واضح", + "كثير من عملاءنا كانوا يستخدمون [المنافس] وحولوا لنا، وش تبي أشرح لك ليش؟", + ], + "authority": [ + "طيب، وش رأيك نجهز لك ملخص تقدر تشاركه مع صاحب القرار؟", + "ممكن نسوي لك عرض مختصر بالأرقام عشان يسهل عليك الشرح", + "عادي، ممكن ندعو صاحب القرار معك في الاجتماع القادم", + ], + } + return responses.get(objection_type, responses["price"]) diff --git a/salesflow-saas/backend/app/api/v1/analytics.py b/salesflow-saas/backend/app/api/v1/analytics.py new file mode 100644 index 00000000..2ff6dda9 --- /dev/null +++ b/salesflow-saas/backend/app/api/v1/analytics.py @@ -0,0 +1,217 @@ +""" +Analytics & AI API Routes — ROI tracking, trust scores, AI orchestration. +""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from app.database import get_db + +router = APIRouter() + + +# ── Analytics ───────────────────────────────────── + +@router.get("/analytics/summary") +async def analytics_summary( + tenant_id: str = Query(...), + days: int = Query(30), + db: AsyncSession = Depends(get_db), +): + """KPI summary: leads, deals, revenue, conversion rates.""" + from app.services.analytics_service import AnalyticsService + svc = AnalyticsService(db) + return await svc.get_kpi_summary(tenant_id, days) + + +@router.get("/analytics/funnel") +async def analytics_funnel( + tenant_id: str = Query(...), + db: AsyncSession = Depends(get_db), +): + """Conversion funnel: Lead → Contacted → Qualified → Converted.""" + from app.services.analytics_service import AnalyticsService + svc = AnalyticsService(db) + return await svc.get_conversion_funnel(tenant_id) + + +@router.get("/analytics/channels") +async def analytics_channels( + tenant_id: str = Query(...), + db: AsyncSession = Depends(get_db), +): + """Channel performance comparison.""" + from app.services.analytics_service import AnalyticsService + svc = AnalyticsService(db) + return await svc.get_channel_performance(tenant_id) + + +@router.get("/analytics/sectors") +async def analytics_sectors( + tenant_id: str = Query(...), + db: AsyncSession = Depends(get_db), +): + """Sector performance breakdown.""" + from app.services.analytics_service import AnalyticsService + svc = AnalyticsService(db) + return await svc.get_sector_performance(tenant_id) + + +@router.get("/analytics/agents") +async def analytics_agents( + tenant_id: str = Query(...), + db: AsyncSession = Depends(get_db), +): + """Agent performance metrics.""" + from app.services.analytics_service import AnalyticsService + svc = AnalyticsService(db) + return await svc.get_agent_performance(tenant_id) + + +@router.get("/analytics/trends") +async def analytics_trends( + tenant_id: str = Query(...), + days: int = Query(90), + db: AsyncSession = Depends(get_db), +): + """Time-series trends.""" + from app.services.analytics_service import AnalyticsService + svc = AnalyticsService(db) + return await svc.get_trends(tenant_id, days) + + +# ── Trust Scores ────────────────────────────────── + +@router.post("/trust-scores/lead/{lead_id}") +async def score_lead( + lead_id: str, + tenant_id: str = Query(...), + db: AsyncSession = Depends(get_db), +): + """Calculate trust score for a lead.""" + from app.services.trust_score_service import TrustScoreService + svc = TrustScoreService(db) + return await svc.calculate_lead_score(tenant_id, lead_id) + + +@router.post("/trust-scores/affiliate/{affiliate_id}") +async def score_affiliate( + affiliate_id: str, + tenant_id: str = Query(...), + db: AsyncSession = Depends(get_db), +): + """Calculate trust score for an affiliate.""" + from app.services.trust_score_service import TrustScoreService + svc = TrustScoreService(db) + return await svc.calculate_affiliate_score(tenant_id, affiliate_id) + + +@router.post("/trust-scores/batch") +async def score_all( + tenant_id: str = Query(...), + db: AsyncSession = Depends(get_db), +): + """Batch score all unscored leads.""" + from app.services.trust_score_service import TrustScoreService + svc = TrustScoreService(db) + return await svc.score_all_leads(tenant_id) + + +# ── AI Orchestration ────────────────────────────── + +@router.post("/orchestrator/process-lead/{lead_id}") +async def orchestrate_lead( + lead_id: str, + tenant_id: str = Query(...), + db: AsyncSession = Depends(get_db), +): + """Process a new lead through the full AI pipeline.""" + from app.ai.orchestrator import Orchestrator + orch = Orchestrator(db) + return await orch.process_new_lead(tenant_id, lead_id) + + +@router.post("/orchestrator/handle-message") +async def handle_message( + tenant_id: str = Query(...), + lead_id: str = Query(...), + message: str = Query(...), + channel: str = Query("whatsapp"), + language: str = Query("ar"), + db: AsyncSession = Depends(get_db), +): + """Process an inbound message through AI agents.""" + from app.ai.orchestrator import Orchestrator + orch = Orchestrator(db) + return await orch.handle_inbound_message( + tenant_id, lead_id, message, channel, language + ) + + +@router.post("/orchestrator/prepare-meeting/{meeting_id}") +async def prepare_meeting( + meeting_id: str, + tenant_id: str = Query(...), + db: AsyncSession = Depends(get_db), +): + """Generate AI meeting preparation package.""" + from app.ai.orchestrator import Orchestrator + orch = Orchestrator(db) + return await orch.prepare_meeting(tenant_id, meeting_id) + + +@router.post("/orchestrator/daily") +async def run_daily( + tenant_id: str = Query(...), + db: AsyncSession = Depends(get_db), +): + """Run daily automation tasks.""" + from app.ai.orchestrator import Orchestrator + orch = Orchestrator(db) + return await orch.run_daily_automation(tenant_id) + + +@router.get("/orchestrator/states") +async def get_states(): + """Get the lead lifecycle state machine.""" + from app.ai.orchestrator import Orchestrator + return Orchestrator.__init__ # Will return states without DB + # Simplified response + return { + "states": { + "new": {"next_states": ["contacted", "lost"], "auto_agent": "lead_qualification"}, + "contacted": {"next_states": ["qualified", "lost"], "auto_agent": "outreach_writer"}, + "qualified": {"next_states": ["converted", "contacted", "lost"], "auto_agent": "meeting_booking"}, + "converted": {"next_states": [], "auto_agent": None}, + "lost": {"next_states": ["new"], "auto_agent": None}, + } + } + + +@router.get("/orchestrator/events") +async def get_events(): + """List all supported event types.""" + from app.ai.agent_router import EVENT_AGENT_MAP + return { + "events": [ + {"type": k, "agents": v} + for k, v in EVENT_AGENT_MAP.items() + ] + } + + +# ── AI Agent Direct Invocation ──────────────────── + +@router.get("/ai/agents") +async def list_ai_agents(): + """List all 18 available AI agents.""" + from app.ai.agent_executor import AgentExecutor + executor = AgentExecutor() + return {"agents": executor.get_available_agents()} + + +@router.get("/ai/usage") +async def ai_usage(): + """Get AI token usage stats.""" + from app.ai.llm_provider import LLMProvider + llm = LLMProvider() + return llm.get_usage_stats() diff --git a/salesflow-saas/backend/app/api/v1/router.py b/salesflow-saas/backend/app/api/v1/router.py index f62a3d02..f8155e79 100644 --- a/salesflow-saas/backend/app/api/v1/router.py +++ b/salesflow-saas/backend/app/api/v1/router.py @@ -3,7 +3,7 @@ from app.api.v1 import ( auth, leads, deals, dashboard, tenants, users, affiliates, ai_agents, companies, contacts, calls, meetings, commissions, payouts, disputes, guarantees, consents, complaints, knowledge, sectors, presentations, - supervisor, admin, health, + supervisor, admin, health, analytics, webhooks, ) api_router = APIRouter() @@ -32,3 +32,5 @@ api_router.include_router(presentations.router, prefix="/presentations", tags=[" api_router.include_router(supervisor.router, prefix="/supervisor", tags=["Supervisor"]) api_router.include_router(admin.router, prefix="/admin", tags=["Admin"]) api_router.include_router(health.router, tags=["Health"]) +api_router.include_router(analytics.router, tags=["Analytics & AI"]) +api_router.include_router(webhooks.router, tags=["Webhooks"]) diff --git a/salesflow-saas/backend/app/api/v1/webhooks.py b/salesflow-saas/backend/app/api/v1/webhooks.py new file mode 100644 index 00000000..7fd5e033 --- /dev/null +++ b/salesflow-saas/backend/app/api/v1/webhooks.py @@ -0,0 +1,192 @@ +""" +Webhook Routes — Receive events from WhatsApp, Email, CRM, Calendar, Payment. +""" + +import hashlib +import hmac +import json +from fastapi import APIRouter, Request, HTTPException, Query, BackgroundTasks +from app.config import get_settings + +settings = get_settings() +router = APIRouter(prefix="/webhooks", tags=["Webhooks"]) + + +# ── WhatsApp ────────────────────────────────────── + +@router.get("/whatsapp") +async def whatsapp_verify( + hub_mode: str = Query(None, alias="hub.mode"), + hub_verify_token: str = Query(None, alias="hub.verify_token"), + hub_challenge: str = Query(None, alias="hub.challenge"), +): + """WhatsApp webhook verification (Meta Cloud API).""" + if hub_mode == "subscribe" and hub_verify_token == settings.WHATSAPP_VERIFY_TOKEN: + return int(hub_challenge) + raise HTTPException(status_code=403, detail="Verification failed") + + +@router.post("/whatsapp") +async def whatsapp_incoming(request: Request, background_tasks: BackgroundTasks): + """ + Receive inbound WhatsApp messages from Meta Cloud API. + Processes: text messages, media, reactions, status updates. + """ + body = await request.json() + + entries = body.get("entry", []) + for entry in entries: + changes = entry.get("changes", []) + for change in changes: + value = change.get("value", {}) + messages = value.get("messages", []) + statuses = value.get("statuses", []) + + # Process incoming messages + for msg in messages: + background_tasks.add_task( + _process_whatsapp_message, + phone=msg.get("from", ""), + message_type=msg.get("type", "text"), + content=_extract_whatsapp_content(msg), + message_id=msg.get("id", ""), + timestamp=msg.get("timestamp", ""), + ) + + # Process delivery statuses + for status in statuses: + background_tasks.add_task( + _process_whatsapp_status, + message_id=status.get("id", ""), + status=status.get("status", ""), + recipient=status.get("recipient_id", ""), + ) + + return {"status": "ok"} + + +async def _process_whatsapp_message( + phone: str, message_type: str, content: str, message_id: str, timestamp: str +): + """Background task to process WhatsApp message through AI pipeline.""" + # Will be connected to Orchestrator + pass + + +async def _process_whatsapp_status(message_id: str, status: str, recipient: str): + """Background task to update message delivery status.""" + pass + + +def _extract_whatsapp_content(msg: dict) -> str: + """Extract text content from various WhatsApp message types.""" + msg_type = msg.get("type", "text") + if msg_type == "text": + return msg.get("text", {}).get("body", "") + elif msg_type == "image": + return f"[صورة: {msg.get('image', {}).get('caption', '')}]" + elif msg_type == "document": + return f"[ملف: {msg.get('document', {}).get('filename', '')}]" + elif msg_type == "audio": + return "[رسالة صوتية]" + elif msg_type == "video": + return "[فيديو]" + elif msg_type == "location": + loc = msg.get("location", {}) + return f"[موقع: {loc.get('latitude')}, {loc.get('longitude')}]" + elif msg_type == "reaction": + return f"[تفاعل: {msg.get('reaction', {}).get('emoji', '')}]" + return "" + + +# ── Email ───────────────────────────────────────── + +@router.post("/email/inbound") +async def email_inbound(request: Request, background_tasks: BackgroundTasks): + """ + Receive inbound emails via SendGrid Inbound Parse. + """ + form = await request.form() + sender = form.get("from", "") + subject = form.get("subject", "") + body = form.get("text", form.get("html", "")) + + background_tasks.add_task( + _process_inbound_email, + sender=sender, + subject=subject, + body=body, + ) + + return {"status": "ok"} + + +async def _process_inbound_email(sender: str, subject: str, body: str): + """Background task to process inbound email.""" + pass + + +# ── CRM Sync ───────────────────────────────────── + +@router.post("/crm/salesforce") +async def salesforce_webhook(request: Request, background_tasks: BackgroundTasks): + """Receive Salesforce outbound messages / platform events.""" + body = await request.json() + background_tasks.add_task(_process_crm_sync, provider="salesforce", data=body) + return {"status": "ok"} + + +@router.post("/crm/hubspot") +async def hubspot_webhook(request: Request, background_tasks: BackgroundTasks): + """Receive HubSpot webhook events.""" + body = await request.json() + background_tasks.add_task(_process_crm_sync, provider="hubspot", data=body) + return {"status": "ok"} + + +async def _process_crm_sync(provider: str, data: dict): + """Background task to sync CRM data.""" + pass + + +# ── Calendar ────────────────────────────────────── + +@router.post("/calendar/google") +async def google_calendar_webhook(request: Request, background_tasks: BackgroundTasks): + """Receive Google Calendar push notifications.""" + body = await request.json() + background_tasks.add_task(_process_calendar_event, provider="google", data=body) + return {"status": "ok"} + + +async def _process_calendar_event(provider: str, data: dict): + """Background task to sync calendar events.""" + pass + + +# ── Payment ─────────────────────────────────────── + +@router.post("/payment/moyasar") +async def moyasar_webhook(request: Request, background_tasks: BackgroundTasks): + """Receive Moyasar payment events.""" + body = await request.json() + background_tasks.add_task(_process_payment, provider="moyasar", data=body) + return {"status": "ok"} + + +@router.post("/payment/stripe") +async def stripe_webhook(request: Request, background_tasks: BackgroundTasks): + """Receive Stripe webhook events.""" + body = await request.body() + sig = request.headers.get("stripe-signature", "") + background_tasks.add_task( + _process_payment, + provider="stripe", + data={"body": body.decode(), "signature": sig}, + ) + return {"status": "ok"} + + +async def _process_payment(provider: str, data: dict): + """Background task to process payment events.""" + pass diff --git a/salesflow-saas/backend/app/config.py b/salesflow-saas/backend/app/config.py index f93c0093..4eb54ffd 100644 --- a/salesflow-saas/backend/app/config.py +++ b/salesflow-saas/backend/app/config.py @@ -1,9 +1,10 @@ from pydantic_settings import BaseSettings from functools import lru_cache +from typing import Optional class Settings(BaseSettings): - # App + # ── App ────────────────────────────────────────────── APP_NAME: str = "Dealix" APP_NAME_AR: str = "ديل اي اكس" DEBUG: bool = False @@ -11,40 +12,95 @@ class Settings(BaseSettings): DEFAULT_CURRENCY: str = "SAR" DEFAULT_LOCALE: str = "ar" - # Database + # ── Database ───────────────────────────────────────── DATABASE_URL: str = "postgresql+asyncpg://salesflow:salesflow_secret_2024@db:5432/salesflow" - # Redis + # ── Redis ──────────────────────────────────────────── REDIS_URL: str = "redis://redis:6379/0" - # Security + # ── Security ───────────────────────────────────────── SECRET_KEY: str = "change-this-to-a-random-secret-key" ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 REFRESH_TOKEN_EXPIRE_DAYS: int = 7 - # URLs + # ── URLs ───────────────────────────────────────────── API_URL: str = "http://localhost:8000" FRONTEND_URL: str = "http://localhost:3000" - # WhatsApp + # ── LLM Providers ──────────────────────────────────── + # Primary: Groq (free/cheap, very fast) + GROQ_API_KEY: str = "" + GROQ_MODEL: str = "llama-3.3-70b-versatile" + GROQ_FAST_MODEL: str = "llama-3.1-8b-instant" + + # Fallback: OpenAI + OPENAI_API_KEY: str = "" + OPENAI_MODEL: str = "gpt-4o" + OPENAI_MINI_MODEL: str = "gpt-4o-mini" + + # Embeddings + EMBEDDING_PROVIDER: str = "openai" # openai, sentence-transformers + EMBEDDING_MODEL: str = "text-embedding-3-small" + EMBEDDING_DIMENSIONS: int = 1536 + + # LLM defaults + LLM_PRIMARY_PROVIDER: str = "groq" # groq, openai + LLM_TEMPERATURE: float = 0.3 + LLM_MAX_TOKENS: int = 2048 + LLM_TIMEOUT: int = 30 + + # ── WhatsApp Business API ──────────────────────────── WHATSAPP_API_TOKEN: str = "" WHATSAPP_PHONE_NUMBER_ID: str = "" WHATSAPP_BUSINESS_ACCOUNT_ID: str = "" WHATSAPP_VERIFY_TOKEN: str = "" + WHATSAPP_API_URL: str = "https://graph.facebook.com/v21.0" + WHATSAPP_MOCK_MODE: bool = True # Use mock for development - # Email - EMAIL_PROVIDER: str = "smtp" + # ── Email ──────────────────────────────────────────── + EMAIL_PROVIDER: str = "smtp" # smtp, sendgrid SMTP_HOST: str = "smtp.gmail.com" SMTP_PORT: int = 587 SMTP_USER: str = "" SMTP_PASSWORD: str = "" SENDGRID_API_KEY: str = "" + EMAIL_FROM_NAME: str = "Dealix" + EMAIL_FROM_ADDRESS: str = "noreply@dealix.sa" - # SMS (Unifonic) + # ── SMS (Unifonic - Saudi) ─────────────────────────── UNIFONIC_APP_SID: str = "" UNIFONIC_SENDER_ID: str = "Dealix" + # ── Calendar Integration ───────────────────────────── + GOOGLE_CALENDAR_CREDENTIALS: str = "" + GOOGLE_CALENDAR_ID: str = "" + MICROSOFT_CLIENT_ID: str = "" + MICROSOFT_CLIENT_SECRET: str = "" + + # ── CRM Connectors ─────────────────────────────────── + HUBSPOT_API_KEY: str = "" + SALESFORCE_CLIENT_ID: str = "" + SALESFORCE_CLIENT_SECRET: str = "" + SALESFORCE_DOMAIN: str = "" + + # ── Scraping / Lead Gen ────────────────────────────── + GOOGLE_MAPS_API_KEY: str = "" + RAPIDAPI_KEY: str = "" # For LinkedIn data enrichment + + # ── Rate Limiting ──────────────────────────────────── + RATE_LIMIT_PER_MINUTE: int = 60 + RATE_LIMIT_PER_HOUR: int = 1000 + WHATSAPP_RATE_LIMIT_PER_SECOND: int = 80 + + # ── Celery ─────────────────────────────────────────── + CELERY_BROKER_URL: str = "redis://redis:6379/1" + CELERY_RESULT_BACKEND: str = "redis://redis:6379/2" + + # ── File Storage ───────────────────────────────────── + UPLOAD_DIR: str = "/app/uploads" + MAX_UPLOAD_SIZE_MB: int = 10 + class Config: env_file = ".env" case_sensitive = True diff --git a/salesflow-saas/backend/app/database.py b/salesflow-saas/backend/app/database.py index 5e89d4d7..b012f943 100644 --- a/salesflow-saas/backend/app/database.py +++ b/salesflow-saas/backend/app/database.py @@ -1,5 +1,6 @@ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.orm import DeclarativeBase +from sqlalchemy import event, text from app.config import get_settings settings = get_settings() @@ -29,3 +30,13 @@ async def get_db() -> AsyncSession: raise finally: await session.close() + + +async def init_db(): + """Initialize database extensions and create tables.""" + async with engine.begin() as conn: + # Enable pgvector extension for RAG embeddings + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm")) + # Create all tables + await conn.run_sync(Base.metadata.create_all) diff --git a/salesflow-saas/backend/app/integrations/whatsapp.py b/salesflow-saas/backend/app/integrations/whatsapp.py index 48a1b1d1..3aff1d6c 100644 --- a/salesflow-saas/backend/app/integrations/whatsapp.py +++ b/salesflow-saas/backend/app/integrations/whatsapp.py @@ -1,5 +1,8 @@ import httpx from app.config import get_settings +import logging + +logger = logging.getLogger("dealix.integrations.whatsapp") settings = get_settings() @@ -8,7 +11,12 @@ WHATSAPP_API_URL = "https://graph.facebook.com/v21.0" async def send_whatsapp_message(phone: str, message: str) -> dict: """Send a text message via WhatsApp Business API.""" + if settings.WHATSAPP_MOCK_MODE: + logger.info(f"[MOCK WHATSAPP] To: {phone} | Message: {message}") + return {"status": "success", "mocked": True, "message_id": "mock_123"} + if not settings.WHATSAPP_API_TOKEN or not settings.WHATSAPP_PHONE_NUMBER_ID: + logger.error("WhatsApp credentials missing.") return {"status": "error", "detail": "WhatsApp not configured"} url = f"{WHATSAPP_API_URL}/{settings.WHATSAPP_PHONE_NUMBER_ID}/messages" diff --git a/salesflow-saas/backend/app/main.py b/salesflow-saas/backend/app/main.py index 4ab0db0b..2c04b829 100644 --- a/salesflow-saas/backend/app/main.py +++ b/salesflow-saas/backend/app/main.py @@ -1,34 +1,69 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager + from app.config import get_settings from app.api.v1.router import api_router settings = get_settings() + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application startup and shutdown events.""" + # Startup + print(f"🚀 {settings.APP_NAME} ({settings.APP_NAME_AR}) starting...") + print(f" Environment: {settings.ENVIRONMENT}") + print(f" LLM Primary: {settings.LLM_PRIMARY_PROVIDER}") + print(f" LLM Fallback: {settings.LLM_FALLBACK_PROVIDER}") + yield + # Shutdown + print(f"👋 {settings.APP_NAME} shutting down...") + + app = FastAPI( title=f"{settings.APP_NAME} API", - description="AI Sales SaaS Platform for SMEs - Multi-tenant, Multi-industry Sales Automation", - version="1.0.0", + description=( + "AI-powered B2B Revenue Operating System for the Saudi market. " + "Lead management, AI agents, affiliate system, meeting automation, " + "deal pipeline, and commission processing — all driven by 18 specialized AI agents." + ), + version="2.0.0", docs_url="/api/docs", redoc_url="/api/redoc", openapi_url="/api/openapi.json", + lifespan=lifespan, ) +# CORS app.add_middleware( CORSMiddleware, - allow_origins=[settings.FRONTEND_URL, "http://localhost:3000"], + allow_origins=[ + settings.FRONTEND_URL, + "http://localhost:3000", + "http://localhost:5173", + "https://dealix.sa", + "https://app.dealix.sa", + ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) +# API Routes app.include_router(api_router, prefix="/api/v1") +# Health check (outside router for direct access) @app.get("/api/v1/health") async def health_check(): return { "status": "healthy", "app": settings.APP_NAME, - "version": "1.0.0", + "version": "2.0.0", + "environment": settings.ENVIRONMENT, + "ai_engine": { + "primary": settings.LLM_PRIMARY_PROVIDER, + "fallback": settings.LLM_FALLBACK_PROVIDER, + }, } diff --git a/salesflow-saas/backend/app/models/__init__.py b/salesflow-saas/backend/app/models/__init__.py index 9e4149fd..e35ba326 100644 --- a/salesflow-saas/backend/app/models/__init__.py +++ b/salesflow-saas/backend/app/models/__init__.py @@ -21,6 +21,7 @@ from app.models.dispute import Dispute from app.models.guarantee import GuaranteeClaim from app.models.compliance import Consent, Complaint, Policy from app.models.knowledge import KnowledgeArticle, SectorAsset +from app.models.advanced import TrustScore, Prospect, Scorecard, AIRehearsal __all__ = [ "BaseModel", "TenantModel", "Tenant", "User", "Lead", "Customer", @@ -31,4 +32,5 @@ __all__ = [ "Company", "Contact", "Call", "Commission", "Payout", "Dispute", "GuaranteeClaim", "Consent", "Complaint", "Policy", "KnowledgeArticle", "SectorAsset", + "TrustScore", "Prospect", "Scorecard", "AIRehearsal", ] diff --git a/salesflow-saas/backend/app/models/advanced.py b/salesflow-saas/backend/app/models/advanced.py new file mode 100644 index 00000000..00da42ca --- /dev/null +++ b/salesflow-saas/backend/app/models/advanced.py @@ -0,0 +1,158 @@ +"""Trust Score & Prospect models — new additions to Dealix architecture.""" +import enum +from sqlalchemy import Column, String, Integer, Float, Text, DateTime, Boolean, Enum, ForeignKey, Date +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import relationship +from app.models.base import TenantModel, BaseModel + + +# ─── Trust Score ─────────────────────────────────────────────── + +class EntityType(str, enum.Enum): + AFFILIATE = "affiliate" + LEAD = "lead" + COMPANY = "company" + CONTACT = "contact" + + +class TrustScore(TenantModel): + """Trust assessment for affiliates, leads, and companies. + Helps the system focus effort on people who actually buy/perform.""" + __tablename__ = "trust_scores" + + entity_type = Column(Enum(EntityType), nullable=False, index=True) + entity_id = Column(UUID(as_uuid=True), nullable=False, index=True) + + # Composite score (0-100) + score = Column(Float, default=50.0, nullable=False) + + # Breakdown dimensions + engagement_score = Column(Float, default=50.0) # Response rate, activity level + conversion_score = Column(Float, default=50.0) # Historical conversion rate + reliability_score = Column(Float, default=50.0) # Show-up rate, commitment + quality_score = Column(Float, default=50.0) # Lead quality, deal value + + # Signals + positive_signals = Column(JSONB, default=[]) # List of positive indicators + negative_signals = Column(JSONB, default=[]) # List of risk indicators + last_computed_at = Column(DateTime(timezone=True)) + + # History + history = Column(JSONB, default=[]) # Score history over time + + +# ─── Prospect (pre-lead, from scraping) ─────────────────────── + +class ProspectStatus(str, enum.Enum): + IDENTIFIED = "identified" + RESEARCHING = "researching" + APPROACHING = "approaching" + ENGAGED = "engaged" + CONVERTED = "converted" + DISQUALIFIED = "disqualified" + + +class Prospect(TenantModel): + """Pre-lead record created by the AI Lead Generator from scraping. + Gets promoted to a Lead when qualified.""" + __tablename__ = "prospects" + + # Source data + source = Column(String(50), nullable=False, index=True) # google_maps, linkedin, saudi_registry + source_url = Column(String(1000), nullable=True) + source_id = Column(String(255), nullable=True) # External ID from source + + # Business info + company_name = Column(String(255), nullable=True) + company_name_ar = Column(String(255), nullable=True) + sector = Column(String(100), nullable=True, index=True) + website = Column(String(500), nullable=True) + city = Column(String(100), nullable=True) + region = Column(String(100), nullable=True) + + # Contact info + contact_name = Column(String(255), nullable=True) + contact_title = Column(String(255), nullable=True) + phone = Column(String(20), nullable=True) + email = Column(String(255), nullable=True) + whatsapp = Column(String(20), nullable=True) + + # AI analysis + status = Column(Enum(ProspectStatus), default=ProspectStatus.IDENTIFIED, nullable=False) + buying_intent_score = Column(Float, default=0.0) # AI-computed 0-100 + estimated_value = Column(Float, default=0.0) # Estimated deal size SAR + fit_score = Column(Float, default=0.0) # Product-market fit 0-100 + priority = Column(String(20), default="medium") # low, medium, high, critical + + # Enrichment data + enrichment_data = Column(JSONB, default={}) # Raw scraped/enriched data + notes = Column(Text, nullable=True) + + # Conversion + converted_to_lead_id = Column(UUID(as_uuid=True), nullable=True) + converted_at = Column(DateTime(timezone=True), nullable=True) + + +# ─── Scorecard ───────────────────────────────────────────────── + +class Scorecard(TenantModel): + """Performance scorecard for sales agents and affiliates.""" + __tablename__ = "scorecards" + + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True) + period = Column(Date, nullable=False) + period_type = Column(String(20), default="monthly") # weekly, monthly, quarterly + + # Activity metrics + leads_handled = Column(Integer, default=0) + calls_made = Column(Integer, default=0) + meetings_booked = Column(Integer, default=0) + meetings_completed = Column(Integer, default=0) + + # Outcome metrics + deals_closed = Column(Integer, default=0) + revenue_generated = Column(Float, default=0.0) + avg_deal_size = Column(Float, default=0.0) + + # Quality metrics + avg_response_time_seconds = Column(Integer, default=0) + customer_satisfaction = Column(Float, default=0.0) # 0-5 + ai_assist_rate = Column(Float, default=0.0) # % of AI-assisted interactions + + # Composite + composite_score = Column(Float, default=0.0) # Weighted aggregate + + user = relationship("User") + + +# ─── AI Rehearsal (Meeting Preview) ──────────────────────────── + +class AIRehearsal(TenantModel): + """AI-powered meeting rehearsal — simulates the upcoming meeting + so the sales rep can practice the best closing strategy.""" + __tablename__ = "ai_rehearsals" + + meeting_id = Column(UUID(as_uuid=True), ForeignKey("auto_bookings.id"), nullable=True) + lead_id = Column(UUID(as_uuid=True), ForeignKey("leads.id"), nullable=True) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) # Sales rep + + # Context + client_profile_summary = Column(Text, nullable=True) # AI-generated summary of client + industry_insights = Column(Text, nullable=True) # Relevant sector intelligence + predicted_objections = Column(JSONB, default=[]) # Expected objections + recommended_approach = Column(Text, nullable=True) # AI closing strategy + talking_points = Column(JSONB, default=[]) # Key talking points + competitive_intel = Column(Text, nullable=True) # Competitor positioning + + # Rehearsal session + rehearsal_transcript = Column(JSONB, default=[]) # Simulated conversation + feedback = Column(Text, nullable=True) # AI feedback on performance + readiness_score = Column(Float, default=0.0) # 0-100 + + # Status + status = Column(String(20), default="pending") # pending, in_progress, completed + completed_at = Column(DateTime(timezone=True), nullable=True) + + meeting = relationship("AutoBooking") + lead = relationship("Lead") + user = relationship("User") diff --git a/salesflow-saas/backend/app/models/base.py b/salesflow-saas/backend/app/models/base.py index 2e59bec1..7de78107 100644 --- a/salesflow-saas/backend/app/models/base.py +++ b/salesflow-saas/backend/app/models/base.py @@ -1,6 +1,6 @@ import uuid from datetime import datetime, timezone -from sqlalchemy import Column, DateTime, Boolean +from sqlalchemy import Column, DateTime, Boolean, String, event from sqlalchemy.dialects.postgresql import UUID from app.database import Base @@ -10,6 +10,7 @@ class BaseModel(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) class TenantModel(BaseModel): diff --git a/salesflow-saas/backend/app/schemas/schemas.py b/salesflow-saas/backend/app/schemas/schemas.py new file mode 100644 index 00000000..4841b1e9 --- /dev/null +++ b/salesflow-saas/backend/app/schemas/schemas.py @@ -0,0 +1,385 @@ +"""Pydantic schemas for request/response validation.""" +from datetime import datetime, date +from typing import Optional, List, Any +from uuid import UUID +from pydantic import BaseModel, EmailStr, Field, ConfigDict + + +# ── Auth Schemas ──────────────────────────────────────────────── + +class LoginRequest(BaseModel): + email: str + password: str + +class RegisterRequest(BaseModel): + email: str + password: str = Field(min_length=8) + full_name: str + company_name: str + industry: Optional[str] = None + phone: Optional[str] = None + +class TokenResponse(BaseModel): + access_token: str + refresh_token: str + token_type: str = "bearer" + expires_in: int + user: "UserResponse" + +class RefreshTokenRequest(BaseModel): + refresh_token: str + +class PasswordResetRequest(BaseModel): + email: str + +class PasswordResetConfirm(BaseModel): + token: str + new_password: str = Field(min_length=8) + + +# ── User Schemas ──────────────────────────────────────────────── + +class UserResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + email: str + full_name: Optional[str] = None + full_name_ar: Optional[str] = None + role: str + phone: Optional[str] = None + is_active: bool + last_login: Optional[datetime] = None + created_at: datetime + +class UserUpdate(BaseModel): + full_name: Optional[str] = None + full_name_ar: Optional[str] = None + phone: Optional[str] = None + +class UserCreate(BaseModel): + email: str + password: str = Field(min_length=8) + full_name: str + role: str = "agent" + phone: Optional[str] = None + + +# ── Tenant Schemas ────────────────────────────────────────────── + +class TenantResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + name: str + name_ar: Optional[str] = None + slug: str + industry: Optional[str] = None + plan: str + is_active: bool + created_at: datetime + +class TenantUpdate(BaseModel): + name: Optional[str] = None + name_ar: Optional[str] = None + industry: Optional[str] = None + logo_url: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + whatsapp_number: Optional[str] = None + settings: Optional[dict] = None + + +# ── Lead Schemas ──────────────────────────────────────────────── + +class LeadCreate(BaseModel): + name: str + phone: Optional[str] = None + email: Optional[str] = None + source: Optional[str] = "manual" + company_name: Optional[str] = None + sector: Optional[str] = None + city: Optional[str] = None + notes: Optional[str] = None + metadata: Optional[dict] = None + +class LeadUpdate(BaseModel): + name: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + status: Optional[str] = None + score: Optional[int] = None + assigned_to: Optional[UUID] = None + notes: Optional[str] = None + metadata: Optional[dict] = None + +class LeadResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + tenant_id: UUID + name: str + phone: Optional[str] = None + email: Optional[str] = None + source: Optional[str] = None + status: str + score: int + notes: Optional[str] = None + metadata: Optional[dict] = None + assigned_to: Optional[UUID] = None + created_at: datetime + updated_at: Optional[datetime] = None + +class LeadQualifyResponse(BaseModel): + lead_id: UUID + score: int + status: str + reasoning: str + suggested_action: str + bant_analysis: dict + + +# ── Deal Schemas ──────────────────────────────────────────────── + +class DealCreate(BaseModel): + title: str + lead_id: Optional[UUID] = None + value: Optional[float] = None + currency: str = "SAR" + stage: str = "new" + probability: int = 0 + expected_close_date: Optional[date] = None + notes: Optional[str] = None + +class DealUpdate(BaseModel): + title: Optional[str] = None + value: Optional[float] = None + stage: Optional[str] = None + probability: Optional[int] = None + expected_close_date: Optional[date] = None + notes: Optional[str] = None + assigned_to: Optional[UUID] = None + +class DealResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + tenant_id: UUID + lead_id: Optional[UUID] = None + customer_id: Optional[UUID] = None + assigned_to: Optional[UUID] = None + title: str + value: Optional[float] = None + currency: str + stage: str + probability: int + expected_close_date: Optional[date] = None + closed_at: Optional[datetime] = None + notes: Optional[str] = None + created_at: datetime + updated_at: Optional[datetime] = None + + +# ── Company Schemas ───────────────────────────────────────────── + +class CompanyCreate(BaseModel): + name: str + name_ar: Optional[str] = None + website: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + industry: Optional[str] = None + size: Optional[str] = None + city: Optional[str] = None + address: Optional[str] = None + notes: Optional[str] = None + +class CompanyResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + tenant_id: UUID + name: str + name_ar: Optional[str] = None + website: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + industry: Optional[str] = None + size: Optional[str] = None + city: Optional[str] = None + is_active: bool + created_at: datetime + + +# ── Contact Schemas ───────────────────────────────────────────── + +class ContactCreate(BaseModel): + company_id: UUID + full_name: str + role: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + is_decision_maker: bool = False + preferred_language: str = "ar" + preferred_channel: str = "whatsapp" + +class ContactResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + tenant_id: UUID + company_id: UUID + full_name: str + role: Optional[str] = None + phone: Optional[str] = None + email: Optional[str] = None + is_decision_maker: bool + preferred_language: str + preferred_channel: str + created_at: datetime + + +# ── Meeting Schemas ───────────────────────────────────────────── + +class MeetingCreate(BaseModel): + lead_id: Optional[UUID] = None + meeting_type: str = "demo" + meeting_datetime: datetime + duration_minutes: int = 30 + client_name: str + client_phone: Optional[str] = None + client_email: Optional[str] = None + client_company: Optional[str] = None + assigned_sales_rep: Optional[UUID] = None + notes: Optional[str] = None + +class MeetingUpdate(BaseModel): + meeting_datetime: Optional[datetime] = None + duration_minutes: Optional[int] = None + status: Optional[str] = None + notes: Optional[str] = None + outcome: Optional[str] = None + +class MeetingResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + tenant_id: UUID + lead_id: Optional[UUID] = None + meeting_type: str + meeting_datetime: datetime + duration_minutes: int + client_name: str + client_company: Optional[str] = None + status: str + assigned_sales_rep: Optional[UUID] = None + notes: Optional[str] = None + outcome: Optional[str] = None + created_at: datetime + + +# ── AI Agent Schemas ──────────────────────────────────────────── + +class AgentInvokeRequest(BaseModel): + agent_type: str + input_data: dict + lead_id: Optional[UUID] = None + conversation_id: Optional[UUID] = None + async_mode: bool = True + +class AgentInvokeResponse(BaseModel): + task_id: Optional[str] = None + agent_type: str + status: str # queued, processing, completed, error + output: Optional[dict] = None + tokens_used: Optional[int] = None + latency_ms: Optional[int] = None + +class ConversationResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + tenant_id: UUID + channel: str + status: str + contact_name: Optional[str] = None + contact_phone: Optional[str] = None + contact_company: Optional[str] = None + messages_count: int + sentiment_score: int + interest_level: int + qualified: bool + meeting_booked: bool + last_message_at: Optional[datetime] = None + created_at: datetime + + +# ── Affiliate Schemas ─────────────────────────────────────────── + +class AffiliateCreate(BaseModel): + full_name: str + full_name_ar: Optional[str] = None + email: str + phone: str + whatsapp: Optional[str] = None + city: Optional[str] = None + notes: Optional[str] = None + +class AffiliateResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + full_name: str + full_name_ar: Optional[str] = None + email: str + phone: str + status: str + referral_code: Optional[str] = None + total_leads_generated: int + total_deals_closed: int + total_commission_earned: float + created_at: datetime + + +# ── Commission Schemas ────────────────────────────────────────── + +class CommissionResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + affiliate_id: UUID + deal_id: UUID + amount: float + rate: float + status: str + approved_at: Optional[datetime] = None + paid_at: Optional[datetime] = None + created_at: datetime + + +# ── Dashboard Schemas ─────────────────────────────────────────── + +class DashboardSummary(BaseModel): + total_leads: int = 0 + new_leads_today: int = 0 + qualified_leads: int = 0 + active_conversations: int = 0 + meetings_today: int = 0 + meetings_this_week: int = 0 + total_deals: int = 0 + deals_won: int = 0 + pipeline_value: float = 0.0 + revenue_this_month: float = 0.0 + active_affiliates: int = 0 + ai_conversations_today: int = 0 + +class PipelineSummary(BaseModel): + stage: str + count: int + total_value: float + +class RevenueMetrics(BaseModel): + period: str + revenue: float + deals_closed: int + avg_deal_size: float + + +# ── Pagination ────────────────────────────────────────────────── + +class PaginatedResponse(BaseModel): + items: List[Any] + total: int + page: int + page_size: int + pages: int diff --git a/salesflow-saas/backend/app/services/__init__.py b/salesflow-saas/backend/app/services/__init__.py index e69de29b..d2de4519 100644 --- a/salesflow-saas/backend/app/services/__init__.py +++ b/salesflow-saas/backend/app/services/__init__.py @@ -0,0 +1,26 @@ +""" +Dealix Services Layer +Business logic for all core platform operations. +""" + +from app.services.auth_service import AuthService +from app.services.lead_service import LeadService +from app.services.deal_service import DealService +from app.services.company_service import CompanyService +from app.services.meeting_service import MeetingService +from app.services.affiliate_service import AffiliateService +from app.services.notification_service import NotificationService +from app.services.analytics_service import AnalyticsService +from app.services.trust_score_service import TrustScoreService + +__all__ = [ + "AuthService", + "LeadService", + "DealService", + "CompanyService", + "MeetingService", + "AffiliateService", + "NotificationService", + "AnalyticsService", + "TrustScoreService", +] diff --git a/salesflow-saas/backend/app/services/affiliate_service.py b/salesflow-saas/backend/app/services/affiliate_service.py new file mode 100644 index 00000000..07624888 --- /dev/null +++ b/salesflow-saas/backend/app/services/affiliate_service.py @@ -0,0 +1,311 @@ +""" +Affiliate Service — Recruitment, commissions, career path, performance tracking. +""" + +import uuid +from datetime import datetime, timezone +from decimal import Decimal +from typing import Optional + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + + +TIER_THRESHOLDS = { + "bronze": {"min_deals": 0, "commission_rate": 10.0}, + "silver": {"min_deals": 5, "commission_rate": 12.5}, + "gold": {"min_deals": 15, "commission_rate": 15.0}, + "platinum": {"min_deals": 30, "commission_rate": 20.0}, +} + +CAREER_PATH = { + "affiliate": {"next": "senior_affiliate", "deals_required": 10, "months": 3}, + "senior_affiliate": {"next": "team_lead", "deals_required": 25, "months": 6}, + "team_lead": {"next": "employee", "deals_required": 50, "months": 12}, +} + + +class AffiliateService: + """Full affiliate lifecycle: recruitment, performance, commissions, career path.""" + + def __init__(self, db: AsyncSession): + self.db = db + + # ── Recruitment ─────────────────────────────── + + async def apply( + self, + tenant_id: str, + user_id: str, + referral_code: str = None, + ) -> dict: + from app.models.affiliate import Affiliate + import secrets + + affiliate = Affiliate( + id=uuid.uuid4(), + tenant_id=uuid.UUID(tenant_id), + user_id=uuid.UUID(user_id), + status="applied", + tier="bronze", + referral_code=referral_code or secrets.token_urlsafe(8).upper()[:8], + commission_rate=Decimal("10.0"), + ) + self.db.add(affiliate) + await self.db.flush() + return self._to_dict(affiliate) + + async def approve(self, tenant_id: str, affiliate_id: str) -> Optional[dict]: + from app.models.affiliate import Affiliate + + result = await self.db.execute( + select(Affiliate).where( + Affiliate.id == uuid.UUID(affiliate_id), + Affiliate.tenant_id == uuid.UUID(tenant_id), + ) + ) + aff = result.scalar_one_or_none() + if not aff: + return None + + aff.status = "active" + aff.approved_at = datetime.now(timezone.utc) + await self.db.flush() + return self._to_dict(aff) + + async def suspend(self, tenant_id: str, affiliate_id: str, reason: str = "") -> Optional[dict]: + from app.models.affiliate import Affiliate + + result = await self.db.execute( + select(Affiliate).where( + Affiliate.id == uuid.UUID(affiliate_id), + Affiliate.tenant_id == uuid.UUID(tenant_id), + ) + ) + aff = result.scalar_one_or_none() + if not aff: + return None + + aff.status = "suspended" + await self.db.flush() + return self._to_dict(aff) + + # ── Commission Calculation ──────────────────── + + async def calculate_commission( + self, + tenant_id: str, + affiliate_id: str, + deal_id: str, + deal_value: float, + ) -> dict: + from app.models.commission import Commission + from app.models.affiliate import Affiliate + + result = await self.db.execute( + select(Affiliate).where( + Affiliate.id == uuid.UUID(affiliate_id), + Affiliate.tenant_id == uuid.UUID(tenant_id), + ) + ) + aff = result.scalar_one_or_none() + if not aff: + return {} + + rate = float(aff.commission_rate) + amount = round(deal_value * rate / 100, 2) + + commission = Commission( + id=uuid.uuid4(), + tenant_id=uuid.UUID(tenant_id), + affiliate_id=uuid.UUID(affiliate_id), + deal_id=uuid.UUID(deal_id), + amount=Decimal(str(amount)), + currency="SAR", + rate=aff.commission_rate, + status="pending", + period=datetime.now(timezone.utc).date().replace(day=1), + ) + self.db.add(commission) + await self.db.flush() + + return { + "commission_id": str(commission.id), + "amount": amount, + "rate": rate, + "status": "pending", + } + + # ── Tier Progression ────────────────────────── + + async def check_tier_upgrade(self, tenant_id: str, affiliate_id: str) -> Optional[dict]: + from app.models.affiliate import Affiliate, AffiliatePerformance + + result = await self.db.execute( + select(Affiliate).where( + Affiliate.id == uuid.UUID(affiliate_id), + Affiliate.tenant_id == uuid.UUID(tenant_id), + ) + ) + aff = result.scalar_one_or_none() + if not aff: + return None + + # Get total deals closed + perf_q = select(func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0)).where( + AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id), + ) + total_deals = (await self.db.execute(perf_q)).scalar() or 0 + + # Check upgrade + tiers = ["bronze", "silver", "gold", "platinum"] + current_idx = tiers.index(aff.tier) if aff.tier in tiers else 0 + + for i in range(current_idx + 1, len(tiers)): + tier = tiers[i] + if total_deals >= TIER_THRESHOLDS[tier]["min_deals"]: + aff.tier = tier + aff.commission_rate = Decimal(str(TIER_THRESHOLDS[tier]["commission_rate"])) + await self.db.flush() + return { + "upgraded": True, + "new_tier": tier, + "new_rate": TIER_THRESHOLDS[tier]["commission_rate"], + "total_deals": total_deals, + } + + return { + "upgraded": False, + "current_tier": aff.tier, + "total_deals": total_deals, + "next_tier": tiers[current_idx + 1] if current_idx < len(tiers) - 1 else None, + "deals_needed": TIER_THRESHOLDS[tiers[min(current_idx + 1, len(tiers) - 1)]]["min_deals"] - total_deals, + } + + # ── Career Path (Affiliate → Employee) ──────── + + async def check_career_path(self, tenant_id: str, affiliate_id: str) -> dict: + from app.models.affiliate import Affiliate, AffiliatePerformance + + result = await self.db.execute( + select(Affiliate).where( + Affiliate.id == uuid.UUID(affiliate_id), + Affiliate.tenant_id == uuid.UUID(tenant_id), + ) + ) + aff = result.scalar_one_or_none() + if not aff: + return {} + + perf_q = select(func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0)).where( + AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id), + ) + total_deals = (await self.db.execute(perf_q)).scalar() or 0 + + months_active = 0 + if aff.approved_at: + delta = datetime.now(timezone.utc) - aff.approved_at.replace(tzinfo=timezone.utc) + months_active = delta.days // 30 + + # Employee eligibility + eligible = total_deals >= 50 and months_active >= 12 + + return { + "affiliate_id": str(affiliate_id), + "total_deals": total_deals, + "months_active": months_active, + "eligible_for_employment": eligible, + "current_tier": aff.tier, + "progress": { + "deals": {"current": total_deals, "required": 50, "percent": min(100, total_deals * 100 // 50)}, + "months": {"current": months_active, "required": 12, "percent": min(100, months_active * 100 // 12)}, + }, + } + + # ── Leaderboard ─────────────────────────────── + + async def get_leaderboard(self, tenant_id: str, limit: int = 20) -> list: + from app.models.affiliate import Affiliate, AffiliatePerformance + + q = ( + select( + Affiliate.id, + Affiliate.tier, + Affiliate.referral_code, + func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0).label("total_deals"), + func.coalesce(func.sum(AffiliatePerformance.revenue_attributed), 0).label("total_revenue"), + func.coalesce(func.sum(AffiliatePerformance.commission_earned), 0).label("total_commission"), + ) + .outerjoin(AffiliatePerformance, Affiliate.id == AffiliatePerformance.affiliate_id) + .where( + Affiliate.tenant_id == uuid.UUID(tenant_id), + Affiliate.status == "active", + ) + .group_by(Affiliate.id, Affiliate.tier, Affiliate.referral_code) + .order_by(func.sum(AffiliatePerformance.revenue_attributed).desc().nullslast()) + .limit(limit) + ) + + rows = (await self.db.execute(q)).all() + return [ + { + "rank": i + 1, + "affiliate_id": str(row.id), + "tier": row.tier, + "referral_code": row.referral_code, + "total_deals": int(row.total_deals), + "total_revenue": float(row.total_revenue), + "total_commission": float(row.total_commission), + } + for i, row in enumerate(rows) + ] + + # ── Performance Summary ─────────────────────── + + async def get_performance(self, tenant_id: str, affiliate_id: str) -> dict: + from app.models.affiliate import AffiliatePerformance + + q = select(AffiliatePerformance).where( + AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id), + ).order_by(AffiliatePerformance.period.desc()).limit(12) + + rows = (await self.db.execute(q)).scalars().all() + + monthly = [ + { + "period": row.period.isoformat() if row.period else None, + "leads_generated": row.leads_generated, + "deals_closed": row.deals_closed, + "revenue_attributed": float(row.revenue_attributed) if row.revenue_attributed else 0, + "commission_earned": float(row.commission_earned) if row.commission_earned else 0, + "conversion_rate": float(row.conversion_rate) if row.conversion_rate else 0, + } + for row in rows + ] + + return { + "affiliate_id": str(affiliate_id), + "monthly": monthly, + "totals": { + "leads": sum(m["leads_generated"] for m in monthly), + "deals": sum(m["deals_closed"] for m in monthly), + "revenue": sum(m["revenue_attributed"] for m in monthly), + "commission": sum(m["commission_earned"] for m in monthly), + }, + } + + @staticmethod + def _to_dict(aff) -> dict: + if not aff: + return {} + return { + "id": str(aff.id), + "tenant_id": str(aff.tenant_id), + "user_id": str(aff.user_id), + "status": aff.status, + "tier": aff.tier, + "referral_code": aff.referral_code, + "commission_rate": float(aff.commission_rate) if aff.commission_rate else 0, + "approved_at": aff.approved_at.isoformat() if aff.approved_at else None, + "created_at": aff.created_at.isoformat() if aff.created_at else None, + } diff --git a/salesflow-saas/backend/app/services/agents/embeddings.py b/salesflow-saas/backend/app/services/agents/embeddings.py new file mode 100644 index 00000000..f1a70176 --- /dev/null +++ b/salesflow-saas/backend/app/services/agents/embeddings.py @@ -0,0 +1,98 @@ +""" +Vector Embeddings & RAG Engine +Handles text embedding and semantic search using pgvector. +""" + +import logging +from typing import List, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import text +from app.config import get_settings + +logger = logging.getLogger("dealix.agents.embeddings") +settings = get_settings() + +class EmbeddingsEngine: + """Generates embeddings and performs vector search against knowledge base.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_embedding(self, text: str) -> List[float]: + """Generate vector embedding for text using configured provider.""" + if settings.EMBEDDING_PROVIDER == "openai": + import openai + client = openai.AsyncOpenAI(api_key=settings.OPENAI_API_KEY) + response = await client.embeddings.create( + input=text, + model=settings.EMBEDDING_MODEL + ) + return response.data[0].embedding + else: + # Fallback for local models + raise NotImplementedError(f"Embedding provider {settings.EMBEDDING_PROVIDER} not fully implemented yet.") + + async def add_knowledge(self, tenant_id: str, title: str, content: str, metadata: dict = None) -> str: + """Embed document and store in database vector index.""" + try: + vector = await self.get_embedding(f"{title}\n\n{content}") + + # Using pgvector to insert knowledge. + query = text(""" + INSERT INTO knowledge_articles (id, tenant_id, title, content, embedding, metadata) + VALUES (gen_random_uuid(), :tenant_id, :title, :content, :embedding, :metadata) + RETURNING id + """) + + # Note: The knowledge_articles model needs to have the vector column added + # We'll use raw SQL here to interface directly with pgvector + # We assume the column `embedding` exists as vector(1536) + import json + result = await self.db.execute(query, { + "tenant_id": tenant_id, + "title": title, + "content": content, + "embedding": str(vector), # pgvector parses strings of arrays directly + "metadata": json.dumps(metadata or {}) + }) + await self.db.flush() + + return str(result.scalar()) + except Exception as e: + logger.error(f"Failed to add knowledge: {e}") + raise + + async def search_knowledge(self, tenant_id: str, query_text: str, limit: int = 3) -> List[dict]: + """Semantic search using L2 distance (or cosine similarity via pgvector).""" + try: + query_vector = await self.get_embedding(query_text) + + # Using pgvector cosine distance `<=>` operator to find closest rows + query = text(""" + SELECT id, title, content, metadata, 1 - (embedding <=> :query_vector) as similarity + FROM knowledge_articles + WHERE tenant_id = :tenant_id + ORDER BY embedding <=> :query_vector + LIMIT :limit + """) + + result = await self.db.execute(query, { + "tenant_id": tenant_id, + "query_vector": str(query_vector), + "limit": limit + }) + + rows = result.fetchall() + return [ + { + "id": str(row.id), + "title": row.title, + "content": row.content, + "metadata": row.metadata, + "similarity": float(row.similarity) + } + for row in rows + ] + except Exception as e: + logger.error(f"Failed to search knowledge: {e}") + return [] diff --git a/salesflow-saas/backend/app/services/agents/executor.py b/salesflow-saas/backend/app/services/agents/executor.py new file mode 100644 index 00000000..c40134d6 --- /dev/null +++ b/salesflow-saas/backend/app/services/agents/executor.py @@ -0,0 +1,339 @@ +""" +Agent Executor — Runs AI agents with LLM calls, input validation, +output parsing, escalation checks, and action dispatch. + +This is the engine that powers every single AI agent in Dealix. +""" + +import time +import uuid +import json +import logging +from typing import Optional +from pathlib import Path + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.llm.provider import get_llm, LLMResponse +from app.services.agents.router import AgentRouter + +logger = logging.getLogger("dealix.agents.executor") + +# Load prompt files path +PROMPTS_DIR = Path(__file__).parent.parent.parent.parent.parent / "ai-agents" / "prompts" + + +class AgentResult: + """Standardized agent execution result.""" + def __init__(self, agent_type: str, output: dict, tokens_used: int = 0, + latency_ms: int = 0, status: str = "success", + escalation: dict = None, actions: list = None): + self.agent_type = agent_type + self.output = output + self.tokens_used = tokens_used + self.latency_ms = latency_ms + self.status = status # success, error, escalated + self.escalation = escalation # {needed: bool, reason: str, target: str} + self.actions = actions or [] # [{type: "send_message", ...}, ...] + + def to_dict(self) -> dict: + return { + "agent_type": self.agent_type, + "output": self.output, + "tokens_used": self.tokens_used, + "latency_ms": self.latency_ms, + "status": self.status, + "escalation": self.escalation, + "actions": self.actions, + } + + +class AgentExecutor: + """ + Executes AI agents by: + 1. Loading the agent's system prompt + 2. Building context from input data + 3. Calling the LLM + 4. Parsing structured output + 5. Checking escalation rules + 6. Dispatching actions (DB updates, messages, bookings) + 7. Logging to ai_conversations + """ + + def __init__(self, db: AsyncSession): + self.db = db + self.llm = get_llm() + self.router = AgentRouter() + + async def execute(self, agent_type: str, input_data: dict, + tenant_id: str = None, lead_id: str = None, + conversation_id: str = None) -> AgentResult: + """Execute an AI agent and return structured result.""" + start = time.time() + + try: + # 1. Load system prompt + system_prompt = self._load_prompt(agent_type) + + # 2. Build user message from input data + user_message = self._build_user_message(agent_type, input_data) + + # 3. Call LLM + llm_response = await self.llm.complete( + system_prompt=system_prompt, + user_message=user_message, + json_mode=True, + temperature=self._get_temperature(agent_type), + max_tokens=self._get_max_tokens(agent_type), + ) + + # 4. Parse output + output = llm_response.parse_json() + if output is None: + output = {"raw_response": llm_response.content} + + # 5. Check escalation + escalation = self._check_escalation(agent_type, output, input_data) + + # 6. Build actions + actions = self._build_actions(agent_type, output, input_data) + + latency = int((time.time() - start) * 1000) + + result = AgentResult( + agent_type=agent_type, + output=output, + tokens_used=llm_response.tokens_used, + latency_ms=latency, + status="escalated" if escalation and escalation.get("needed") else "success", + escalation=escalation, + actions=actions, + ) + + # 7. Log to database + await self._log_conversation( + tenant_id=tenant_id, + agent_type=agent_type, + lead_id=lead_id, + input_data=input_data, + output=result.to_dict(), + tokens_used=llm_response.tokens_used, + latency_ms=latency, + status=result.status, + ) + + logger.info( + f"Agent {agent_type} executed: " + f"tokens={llm_response.tokens_used} " + f"latency={latency}ms " + f"status={result.status}" + ) + + return result + + except Exception as e: + latency = int((time.time() - start) * 1000) + logger.error(f"Agent {agent_type} failed: {e}") + + result = AgentResult( + agent_type=agent_type, + output={"error": str(e)}, + latency_ms=latency, + status="error", + ) + + await self._log_conversation( + tenant_id=tenant_id, + agent_type=agent_type, + lead_id=lead_id, + input_data=input_data, + output=result.to_dict(), + tokens_used=0, + latency_ms=latency, + status="error", + ) + + return result + + async def execute_event(self, event_type: str, input_data: dict, + tenant_id: str = None, **kwargs) -> list[AgentResult]: + """Execute all agents registered for an event type.""" + agent_ids = self.router.get_agents_for_event(event_type) + results = [] + + for agent_id in agent_ids: + result = await self.execute( + agent_type=agent_id, + input_data=input_data, + tenant_id=tenant_id, + **kwargs, + ) + results.append(result) + + # Stop chain if escalation needed + if result.escalation and result.escalation.get("needed"): + logger.info(f"Agent chain stopped at {agent_id} due to escalation") + break + + return results + + # ── Prompt Loading ────────────────────────────── + + def _load_prompt(self, agent_type: str) -> str: + """Load system prompt from the ai-agents/prompts directory.""" + # Map agent_type to filename + filename_map = { + "lead_qualification": "lead-qualification-agent.md", + "arabic_whatsapp": "arabic-whatsapp-agent.md", + "english_conversation": "english-conversation-agent.md", + "outreach_writer": "outreach-message-writer.md", + "meeting_booking": "meeting-booking-agent.md", + "objection_handler": "objection-handling-agent.md", + "proposal_drafter": "proposal-drafting-agent.md", + "sector_strategist": "sector-sales-strategist.md", + "knowledge_retrieval": "knowledge-retrieval-agent.md", + "compliance_reviewer": "compliance-reviewer.md", + "fraud_reviewer": "fraud-reviewer.md", + "revenue_attribution": "revenue-attribution-agent.md", + "management_summary": "management-summary-agent.md", + "qa_reviewer": "conversation-qa-reviewer.md", + "affiliate_evaluator": "affiliate-recruitment-evaluator.md", + "onboarding_coach": "affiliate-onboarding-coach.md", + "guarantee_reviewer": "guarantee-claim-reviewer.md", + "voice_call": "voice-call-flow-agent.md", + } + + filename = filename_map.get(agent_type) + if not filename: + return f"You are the {agent_type} agent for Dealix. Respond with structured JSON." + + prompt_path = PROMPTS_DIR / filename + if prompt_path.exists(): + return prompt_path.read_text(encoding="utf-8") + else: + logger.warning(f"Prompt file not found: {prompt_path}") + return f"You are the {agent_type} agent for Dealix. Respond with structured JSON." + + def _build_user_message(self, agent_type: str, input_data: dict) -> str: + """Build the user message from input data.""" + # General format: JSON dump of input data with clear instructions + context = json.dumps(input_data, ensure_ascii=False, indent=2, default=str) + + return f"""## Input Data +{context} + +## Instructions +Process this input according to your role and return a structured JSON response. +Include all required output fields as defined in your schema. +Use Arabic where appropriate (especially for client-facing content). +Respond ONLY with valid JSON.""" + + # ── Configuration per Agent ──────────────────── + + def _get_temperature(self, agent_type: str) -> float: + """Agent-specific temperature settings.""" + # Creative agents need higher temperature + creative = {"outreach_writer": 0.7, "proposal_drafter": 0.5, "sector_strategist": 0.5} + # Analytical agents need low temperature + analytical = { + "lead_qualification": 0.1, "compliance_reviewer": 0.1, + "fraud_reviewer": 0.1, "revenue_attribution": 0.1, + } + return creative.get(agent_type, analytical.get(agent_type, 0.3)) + + def _get_max_tokens(self, agent_type: str) -> int: + """Agent-specific max token settings.""" + verbose = {"proposal_drafter": 4096, "management_summary": 4096, "sector_strategist": 3000} + return verbose.get(agent_type, 2048) + + # ── Escalation Rules ────────────────────────── + + def _check_escalation(self, agent_type: str, output: dict, input_data: dict) -> Optional[dict]: + """Check if the agent output requires escalation to a human.""" + escalation = output.get("escalation", {}) + if isinstance(escalation, dict) and escalation.get("needed"): + return escalation + + # Agent-specific checks + if agent_type == "arabic_whatsapp": + confidence = output.get("confidence", 1.0) + if confidence < 0.5: + return {"needed": True, "reason": "Low confidence response", "target": "human_agent"} + + if agent_type == "lead_qualification": + score = output.get("score", 50) + if 40 <= score <= 60: + return {"needed": True, "reason": "Ambiguous qualification score", "target": "sales_manager"} + + if agent_type == "fraud_reviewer": + risk_score = output.get("risk_score", 0) + if risk_score > 80: + return {"needed": True, "reason": "High fraud risk detected", "target": "admin"} + + return None + + # ── Action Building ─────────────────────────── + + def _build_actions(self, agent_type: str, output: dict, input_data: dict) -> list: + """Build a list of actions to execute based on agent output.""" + actions = [] + + if agent_type == "arabic_whatsapp" and output.get("response_message_ar"): + actions.append({ + "type": "send_whatsapp", + "message": output["response_message_ar"], + "phone": input_data.get("contact_phone", ""), + }) + + if agent_type == "meeting_booking" and output.get("meeting_booked", {}).get("confirmed"): + actions.append({ + "type": "create_meeting", + "datetime": output["meeting_booked"].get("datetime"), + "lead_id": input_data.get("lead_id"), + }) + + if agent_type == "outreach_writer" and output.get("draft_message"): + actions.append({ + "type": "queue_message", + "channel": input_data.get("channel", "whatsapp"), + "message": output["draft_message"], + }) + + if agent_type == "lead_qualification": + actions.append({ + "type": "update_lead_score", + "lead_id": input_data.get("lead_id"), + "score": output.get("score", 0), + "status": output.get("status_recommendation", "contacted"), + }) + + return actions + + # ── Database Logging ────────────────────────── + + async def _log_conversation(self, tenant_id: str, agent_type: str, + lead_id: str, input_data: dict, output: dict, + tokens_used: int, latency_ms: int, status: str): + """Log agent execution to ai_conversations table.""" + try: + from app.models.ai_conversation import AIConversation + + log_entry = AIConversation( + tenant_id=uuid.UUID(tenant_id) if tenant_id else None, + contact_name=input_data.get("contact_name"), + contact_phone=input_data.get("contact_phone"), + channel="system", + status=status, + lead_id=uuid.UUID(lead_id) if lead_id else None, + context={ + "agent_type": agent_type, + "input": input_data, + "output": output, + "tokens_used": tokens_used, + "latency_ms": latency_ms, + }, + ) + self.db.add(log_entry) + await self.db.flush() + except Exception as e: + logger.error(f"Failed to log agent conversation: {e}") diff --git a/salesflow-saas/backend/app/services/agents/router.py b/salesflow-saas/backend/app/services/agents/router.py new file mode 100644 index 00000000..9bf9f293 --- /dev/null +++ b/salesflow-saas/backend/app/services/agents/router.py @@ -0,0 +1,86 @@ +""" +Agent Router — Determines which AI agent handles which event. +The central nervous system of Dealix's AI engine. +""" + +import logging +from typing import Optional +from uuid import UUID + +logger = logging.getLogger("dealix.agents") + + +# ── Event → Agent Mapping ───────────────────────────────────── + +AGENT_REGISTRY = { + # Lead lifecycle + "lead_created": ["lead_qualification"], + "lead_score_updated": ["lead_qualification"], + "lead_qualified": ["outreach_writer", "meeting_booking"], + + # Communication + "whatsapp_inbound": ["arabic_whatsapp"], + "whatsapp_outbound": ["outreach_writer"], + "email_inbound": ["english_conversation"], + "email_outbound": ["outreach_writer"], + "voice_call_completed": ["voice_call"], + + # Meeting lifecycle + "meeting_requested": ["meeting_booking"], + "meeting_confirmed": ["ai_rehearsal"], + "meeting_upcoming": ["ai_rehearsal"], + + # Deal lifecycle + "deal_created": ["sector_strategist"], + "deal_stage_changed": ["proposal_drafter"], + "deal_proposal_requested": ["proposal_drafter"], + + # Quality & Compliance + "content_review": ["qa_reviewer"], + "compliance_check": ["compliance_reviewer"], + "objection_detected": ["objection_handler"], + + # Affiliate lifecycle + "affiliate_applied": ["affiliate_evaluator"], + "affiliate_approved": ["onboarding_coach"], + + # Analytics + "revenue_attribution": ["revenue_attribution"], + "fraud_check": ["fraud_reviewer"], + "guarantee_claim": ["guarantee_reviewer"], + "management_report": ["management_summary"], + + # Knowledge + "knowledge_query": ["knowledge_retrieval"], + "sector_strategy": ["sector_strategist"], +} + + +class AgentRouter: + """Routes events to the appropriate AI agent(s).""" + + def get_agents_for_event(self, event_type: str) -> list[str]: + """Return list of agent IDs that should handle this event.""" + agents = AGENT_REGISTRY.get(event_type, []) + if not agents: + logger.warning(f"No agent registered for event: {event_type}") + return agents + + def get_primary_agent(self, event_type: str) -> Optional[str]: + """Return the primary (first) agent for an event.""" + agents = self.get_agents_for_event(event_type) + return agents[0] if agents else None + + def list_all_agents(self) -> list[dict]: + """List all registered agents with their event triggers.""" + agent_events = {} + for event, agents in AGENT_REGISTRY.items(): + for agent in agents: + if agent not in agent_events: + agent_events[agent] = [] + agent_events[agent].append(event) + + return [ + {"agent_id": agent_id, "events": events} + for agent_id, events in agent_events.items() + ] diff --git a/salesflow-saas/backend/app/services/analytics_service.py b/salesflow-saas/backend/app/services/analytics_service.py new file mode 100644 index 00000000..8ea62d33 --- /dev/null +++ b/salesflow-saas/backend/app/services/analytics_service.py @@ -0,0 +1,220 @@ +""" +Analytics Service — ROI tracking, conversion funnels, channel performance. +""" + +import uuid +from datetime import datetime, timedelta, timezone +from typing import Optional + +from sqlalchemy import select, func, and_, case, extract +from sqlalchemy.ext.asyncio import AsyncSession + + +class AnalyticsService: + """Platform-wide analytics and ROI tracking for B2B clients.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_kpi_summary(self, tenant_id: str, days: int = 30) -> dict: + from app.models.lead import Lead + from app.models.deal import Deal + + tid = uuid.UUID(tenant_id) + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + + # Leads + total_leads = await self._count(Lead, tid) + new_leads = await self._count(Lead, tid, Lead.created_at >= cutoff) + qualified = await self._count(Lead, tid, Lead.status == "qualified") + converted = await self._count(Lead, tid, Lead.status == "converted") + + # Deals + total_deals = await self._count(Deal, tid) + won_deals = await self._count(Deal, tid, Deal.stage == "closed_won") + total_revenue = await self._sum(Deal, Deal.value, tid, Deal.stage == "closed_won") + pipeline_value = await self._sum( + Deal, Deal.value, tid, + Deal.stage.in_(["discovery", "proposal", "negotiation"]) + ) + + # Rates + conversion_rate = (converted / total_leads * 100) if total_leads > 0 else 0 + win_rate = (won_deals / total_deals * 100) if total_deals > 0 else 0 + + return { + "period_days": days, + "leads": { + "total": total_leads, + "new": new_leads, + "qualified": qualified, + "converted": converted, + "conversion_rate": round(conversion_rate, 1), + }, + "deals": { + "total": total_deals, + "won": won_deals, + "win_rate": round(win_rate, 1), + "total_revenue": total_revenue, + "pipeline_value": pipeline_value, + }, + "roi": { + "revenue": total_revenue, + "cost_per_lead": 0, # Calculated when billing is active + "cost_per_meeting": 0, + "cost_per_deal": 0, + }, + } + + async def get_conversion_funnel(self, tenant_id: str) -> dict: + from app.models.lead import Lead + + tid = uuid.UUID(tenant_id) + stages = { + "total_leads": await self._count(Lead, tid), + "contacted": await self._count(Lead, tid, Lead.status.in_(["contacted", "qualified", "converted"])), + "qualified": await self._count(Lead, tid, Lead.status.in_(["qualified", "converted"])), + "converted": await self._count(Lead, tid, Lead.status == "converted"), + } + + total = stages["total_leads"] or 1 + funnel = [ + {"stage": "العملاء المحتملين", "stage_en": "Leads", "count": stages["total_leads"], "rate": 100}, + {"stage": "تم التواصل", "stage_en": "Contacted", "count": stages["contacted"], "rate": round(stages["contacted"] / total * 100, 1)}, + {"stage": "مؤهل", "stage_en": "Qualified", "count": stages["qualified"], "rate": round(stages["qualified"] / total * 100, 1)}, + {"stage": "تم التحويل", "stage_en": "Converted", "count": stages["converted"], "rate": round(stages["converted"] / total * 100, 1)}, + ] + + return {"funnel": funnel} + + async def get_channel_performance(self, tenant_id: str) -> dict: + from app.models.lead import Lead + + tid = uuid.UUID(tenant_id) + q = ( + select( + Lead.source, + func.count().label("count"), + func.avg(Lead.score).label("avg_score"), + ) + .where(Lead.tenant_id == tid) + .group_by(Lead.source) + .order_by(func.count().desc()) + ) + rows = (await self.db.execute(q)).all() + + channels = [] + for row in rows: + converted_q = select(func.count()).where( + Lead.tenant_id == tid, + Lead.source == row.source, + Lead.status == "converted", + ) + converted = (await self.db.execute(converted_q)).scalar() or 0 + channels.append({ + "channel": row.source, + "leads": row.count, + "avg_score": round(float(row.avg_score or 0), 1), + "converted": converted, + "conversion_rate": round(converted / row.count * 100, 1) if row.count > 0 else 0, + }) + + return {"channels": channels} + + async def get_sector_performance(self, tenant_id: str) -> dict: + from app.models.lead import Lead + + tid = uuid.UUID(tenant_id) + q = ( + select( + Lead.sector, + func.count().label("total"), + func.avg(Lead.score).label("avg_score"), + ) + .where(Lead.tenant_id == tid, Lead.sector != "") + .group_by(Lead.sector) + .order_by(func.count().desc()) + ) + rows = (await self.db.execute(q)).all() + + sectors = [] + for row in rows: + converted_q = select(func.count()).where( + Lead.tenant_id == tid, + Lead.sector == row.sector, + Lead.status == "converted", + ) + converted = (await self.db.execute(converted_q)).scalar() or 0 + sectors.append({ + "sector": row.sector, + "total_leads": row.total, + "avg_score": round(float(row.avg_score or 0), 1), + "converted": converted, + "conversion_rate": round(converted / row.total * 100, 1) if row.total > 0 else 0, + }) + + return {"sectors": sectors} + + async def get_agent_performance(self, tenant_id: str) -> dict: + from app.models.lead import Lead + from app.models.user import User + + tid = uuid.UUID(tenant_id) + agents_q = select(User).where( + User.tenant_id == tid, + User.role.in_(["agent", "manager"]), + User.is_active == True, + ) + agents = (await self.db.execute(agents_q)).scalars().all() + + performance = [] + for agent in agents: + total = await self._count(Lead, tid, Lead.assigned_to == agent.id) + converted = await self._count( + Lead, tid, Lead.assigned_to == agent.id, Lead.status == "converted" + ) + performance.append({ + "agent_id": str(agent.id), + "name": agent.full_name, + "total_leads": total, + "converted": converted, + "conversion_rate": round(converted / total * 100, 1) if total > 0 else 0, + }) + + performance.sort(key=lambda x: x["converted"], reverse=True) + return {"agents": performance} + + async def get_trends(self, tenant_id: str, days: int = 90) -> dict: + from app.models.lead import Lead + + tid = uuid.UUID(tenant_id) + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + + q = ( + select( + func.date_trunc("day", Lead.created_at).label("day"), + func.count().label("count"), + ) + .where(Lead.tenant_id == tid, Lead.created_at >= cutoff) + .group_by("day") + .order_by("day") + ) + rows = (await self.db.execute(q)).all() + + return { + "daily_leads": [ + {"date": str(row.day), "count": row.count} for row in rows + ], + } + + # ── Helpers ─────────────────────────────────── + + async def _count(self, model, tenant_id, *filters): + q = select(func.count()).where(model.tenant_id == tenant_id, *filters) + return (await self.db.execute(q)).scalar() or 0 + + async def _sum(self, model, field, tenant_id, *filters): + q = select(func.coalesce(func.sum(field), 0)).where( + model.tenant_id == tenant_id, *filters + ) + return float((await self.db.execute(q)).scalar() or 0) diff --git a/salesflow-saas/backend/app/services/auth_service.py b/salesflow-saas/backend/app/services/auth_service.py new file mode 100644 index 00000000..31953fe7 --- /dev/null +++ b/salesflow-saas/backend/app/services/auth_service.py @@ -0,0 +1,205 @@ +""" +Auth Service — JWT tokens, RBAC, OTP, multi-tenant authentication. +""" + +import secrets +import string +from datetime import datetime, timedelta, timezone +from typing import Optional +from uuid import UUID + +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import get_settings + +settings = get_settings() +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +class AuthService: + """Handles authentication, authorization, and tenant isolation.""" + + def __init__(self, db: AsyncSession): + self.db = db + + # ── Password Hashing ────────────────────────── + + @staticmethod + def hash_password(password: str) -> str: + return pwd_context.hash(password) + + @staticmethod + def verify_password(plain: str, hashed: str) -> bool: + return pwd_context.verify(plain, hashed) + + # ── JWT Tokens ──────────────────────────────── + + @staticmethod + def create_access_token( + user_id: str, + tenant_id: str, + role: str, + extra: dict = None, + ) -> str: + payload = { + "sub": user_id, + "tenant_id": tenant_id, + "role": role, + "type": "access", + "exp": datetime.now(timezone.utc) + + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES), + "iat": datetime.now(timezone.utc), + } + if extra: + payload.update(extra) + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + @staticmethod + def create_refresh_token(user_id: str, tenant_id: str) -> str: + payload = { + "sub": user_id, + "tenant_id": tenant_id, + "type": "refresh", + "exp": datetime.now(timezone.utc) + + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS), + "iat": datetime.now(timezone.utc), + } + return jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + + @staticmethod + def decode_token(token: str) -> Optional[dict]: + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + return payload + except JWTError: + return None + + # ── OTP ─────────────────────────────────────── + + @staticmethod + def generate_otp() -> str: + return "".join( + secrets.choice(string.digits) for _ in range(settings.OTP_LENGTH) + ) + + @staticmethod + def verify_otp(stored_otp: str, provided_otp: str, created_at: datetime) -> bool: + if stored_otp != provided_otp: + return False + expiry = created_at + timedelta(minutes=settings.OTP_EXPIRE_MINUTES) + return datetime.now(timezone.utc) <= expiry + + # ── Registration ────────────────────────────── + + async def register_tenant( + self, + name: str, + email: str, + password: str, + phone: str = "", + plan: str = "free", + ) -> dict: + """Register a new tenant with an owner user.""" + from app.models.tenant import Tenant + from app.models.user import User + import uuid + + tenant_id = uuid.uuid4() + user_id = uuid.uuid4() + slug = name.lower().replace(" ", "-").replace(".", "")[:50] + + tenant = Tenant( + id=tenant_id, + name=name, + slug=slug, + plan=plan, + is_active=True, + ) + self.db.add(tenant) + + user = User( + id=user_id, + tenant_id=tenant_id, + email=email, + phone=phone, + hashed_password=self.hash_password(password), + full_name=name, + role="owner", + language="ar", + is_active=True, + ) + self.db.add(user) + await self.db.flush() + + access = self.create_access_token(str(user_id), str(tenant_id), "owner") + refresh = self.create_refresh_token(str(user_id), str(tenant_id)) + + return { + "user_id": str(user_id), + "tenant_id": str(tenant_id), + "access_token": access, + "refresh_token": refresh, + "token_type": "bearer", + } + + async def login(self, email: str, password: str) -> Optional[dict]: + """Authenticate user and return tokens.""" + from app.models.user import User + + result = await self.db.execute( + select(User).where(User.email == email, User.is_active == True) + ) + user = result.scalar_one_or_none() + + if not user or not self.verify_password(password, user.hashed_password): + return None + + user.last_login_at = datetime.now(timezone.utc) + await self.db.flush() + + access = self.create_access_token( + str(user.id), str(user.tenant_id), user.role + ) + refresh = self.create_refresh_token(str(user.id), str(user.tenant_id)) + + return { + "user_id": str(user.id), + "tenant_id": str(user.tenant_id), + "role": user.role, + "access_token": access, + "refresh_token": refresh, + "token_type": "bearer", + } + + async def get_current_user(self, token: str) -> Optional[dict]: + """Validate token and return user info.""" + payload = self.decode_token(token) + if not payload or payload.get("type") != "access": + return None + return { + "user_id": payload["sub"], + "tenant_id": payload["tenant_id"], + "role": payload["role"], + } + + # ── RBAC Helpers ────────────────────────────── + + ROLE_HIERARCHY = { + "viewer": 0, + "affiliate": 1, + "agent": 2, + "manager": 3, + "admin": 4, + "owner": 5, + } + + @classmethod + def has_permission(cls, user_role: str, required_role: str) -> bool: + return cls.ROLE_HIERARCHY.get(user_role, 0) >= cls.ROLE_HIERARCHY.get( + required_role, 0 + ) diff --git a/salesflow-saas/backend/app/services/company_service.py b/salesflow-saas/backend/app/services/company_service.py new file mode 100644 index 00000000..8b9d94a7 --- /dev/null +++ b/salesflow-saas/backend/app/services/company_service.py @@ -0,0 +1,201 @@ +""" +Company Service — B2B company management, enrichment, CR validation. +""" + +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession + + +class CompanyService: + """Manages B2B company profiles and account intelligence.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def create_company( + self, + tenant_id: str, + name: str, + name_ar: str = "", + sector: str = "", + size: str = "small", + city: str = "", + region: str = "", + cr_number: str = "", + website: str = "", + ) -> dict: + from app.models.company import Company + + company = Company( + id=uuid.uuid4(), + tenant_id=uuid.UUID(tenant_id), + name=name, + name_ar=name_ar, + sector=sector, + size=size, + city=city, + region=region, + cr_number=cr_number, + website=website, + is_active=True, + ) + self.db.add(company) + await self.db.flush() + return self._to_dict(company) + + async def get_company(self, tenant_id: str, company_id: str) -> Optional[dict]: + from app.models.company import Company + + result = await self.db.execute( + select(Company).where( + Company.id == uuid.UUID(company_id), + Company.tenant_id == uuid.UUID(tenant_id), + ) + ) + c = result.scalar_one_or_none() + return self._to_dict(c) if c else None + + async def list_companies( + self, + tenant_id: str, + sector: str = None, + size: str = None, + city: str = None, + search: str = None, + page: int = 1, + per_page: int = 25, + ) -> dict: + from app.models.company import Company + + query = select(Company).where( + Company.tenant_id == uuid.UUID(tenant_id), + Company.is_active == True, + ) + + if sector: + query = query.where(Company.sector == sector) + if size: + query = query.where(Company.size == size) + if city: + query = query.where(Company.city == city) + if search: + pattern = f"%{search}%" + query = query.where( + or_( + Company.name.ilike(pattern), + Company.name_ar.ilike(pattern), + Company.cr_number.ilike(pattern), + ) + ) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await self.db.execute(count_q)).scalar() or 0 + + query = query.order_by(Company.created_at.desc()) + query = query.offset((page - 1) * per_page).limit(per_page) + result = await self.db.execute(query) + companies = [self._to_dict(c) for c in result.scalars().all()] + + return {"items": companies, "total": total, "page": page, "per_page": per_page} + + async def update_company(self, tenant_id: str, company_id: str, **updates) -> Optional[dict]: + from app.models.company import Company + + result = await self.db.execute( + select(Company).where( + Company.id == uuid.UUID(company_id), + Company.tenant_id == uuid.UUID(tenant_id), + ) + ) + company = result.scalar_one_or_none() + if not company: + return None + + for key, value in updates.items(): + if hasattr(company, key) and value is not None: + setattr(company, key, value) + + company.updated_at = datetime.now(timezone.utc) + await self.db.flush() + return self._to_dict(company) + + async def get_company_contacts(self, tenant_id: str, company_id: str) -> list: + from app.models.company import Contact + + result = await self.db.execute( + select(Contact).where( + Contact.company_id == uuid.UUID(company_id), + Contact.tenant_id == uuid.UUID(tenant_id), + ) + ) + return [ + { + "id": str(c.id), + "full_name": c.full_name, + "job_title": c.job_title, + "email": c.email, + "phone": c.phone, + } + for c in result.scalars().all() + ] + + async def get_company_deals(self, tenant_id: str, company_id: str) -> list: + from app.models.deal import Deal + from app.models.lead import Lead + + result = await self.db.execute( + select(Deal) + .join(Lead, Deal.lead_id == Lead.id) + .where( + Deal.tenant_id == uuid.UUID(tenant_id), + Lead.company_name != "", + ) + ) + return [ + { + "id": str(d.id), + "title": d.title, + "stage": d.stage, + "value": float(d.value) if d.value else 0, + } + for d in result.scalars().all() + ] + + async def get_sector_breakdown(self, tenant_id: str) -> dict: + from app.models.company import Company + + q = ( + select(Company.sector, func.count().label("count")) + .where( + Company.tenant_id == uuid.UUID(tenant_id), + Company.is_active == True, + Company.sector != "", + ) + .group_by(Company.sector) + .order_by(func.count().desc()) + ) + rows = (await self.db.execute(q)).all() + return {row.sector: row.count for row in rows} + + @staticmethod + def _to_dict(company) -> dict: + if not company: + return {} + return { + "id": str(company.id), + "tenant_id": str(company.tenant_id), + "name": company.name, + "name_ar": company.name_ar, + "sector": company.sector, + "size": company.size, + "city": company.city, + "region": company.region, + "cr_number": company.cr_number, + "website": company.website, + "is_active": company.is_active, + "created_at": company.created_at.isoformat() if company.created_at else None, + } diff --git a/salesflow-saas/backend/app/services/crm_sync_service.py b/salesflow-saas/backend/app/services/crm_sync_service.py new file mode 100644 index 00000000..99058ea1 --- /dev/null +++ b/salesflow-saas/backend/app/services/crm_sync_service.py @@ -0,0 +1,269 @@ +""" +CRM Sync Service — Bidirectional sync with Salesforce, HubSpot, and generic CRMs. +""" + +import uuid +from datetime import datetime, timezone +from typing import Optional + +import httpx +from sqlalchemy.ext.asyncio import AsyncSession +from app.config import get_settings + +settings = get_settings() + + +class CRMSyncService: + """ + Manages bidirectional data sync between Dealix and external CRM systems. + Supports Salesforce, HubSpot, and generic webhook-based CRMs. + """ + + def __init__(self, db: AsyncSession): + self.db = db + + # ── Salesforce ──────────────────────────────── + + async def salesforce_push_lead(self, lead: dict, credentials: dict) -> dict: + """Push a lead from Dealix to Salesforce.""" + access_token = credentials.get("access_token") + instance_url = credentials.get("instance_url") + + if not access_token or not instance_url: + return {"status": "error", "message": "Invalid Salesforce credentials"} + + sf_lead = { + "FirstName": lead.get("full_name", "").split()[0] if lead.get("full_name") else "", + "LastName": lead.get("full_name", "").split()[-1] if lead.get("full_name") else "Unknown", + "Phone": lead.get("phone", ""), + "Email": lead.get("email", ""), + "Company": lead.get("company_name", "Unknown"), + "Industry": lead.get("sector", ""), + "City": lead.get("city", ""), + "LeadSource": f"Dealix - {lead.get('source', 'web')}", + "Description": lead.get("notes", ""), + "Rating": self._score_to_sf_rating(lead.get("score", 0)), + } + + async with httpx.AsyncClient() as client: + response = await client.post( + f"{instance_url}/services/data/{settings.SALESFORCE_API_VERSION}/sobjects/Lead/", + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + json=sf_lead, + ) + + if response.status_code in (200, 201): + sf_id = response.json().get("id") + return {"status": "success", "salesforce_id": sf_id} + return {"status": "error", "message": response.text} + + async def salesforce_pull_leads(self, credentials: dict, since: str = None) -> list: + """Pull leads from Salesforce into Dealix.""" + access_token = credentials.get("access_token") + instance_url = credentials.get("instance_url") + + query = "SELECT Id, FirstName, LastName, Phone, Email, Company, Industry, City, Rating FROM Lead" + if since: + query += f" WHERE LastModifiedDate > {since}" + query += " ORDER BY LastModifiedDate DESC LIMIT 100" + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{instance_url}/services/data/{settings.SALESFORCE_API_VERSION}/query/", + params={"q": query}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + if response.status_code != 200: + return [] + + records = response.json().get("records", []) + return [ + { + "external_id": r["Id"], + "full_name": f"{r.get('FirstName', '')} {r.get('LastName', '')}".strip(), + "phone": r.get("Phone", ""), + "email": r.get("Email", ""), + "company_name": r.get("Company", ""), + "sector": r.get("Industry", ""), + "city": r.get("City", ""), + } + for r in records + ] + + # ── HubSpot ─────────────────────────────────── + + async def hubspot_push_contact(self, lead: dict, api_key: str) -> dict: + """Push a contact from Dealix to HubSpot.""" + hs_contact = { + "properties": { + "firstname": lead.get("full_name", "").split()[0] if lead.get("full_name") else "", + "lastname": lead.get("full_name", "").split()[-1] if lead.get("full_name") else "", + "phone": lead.get("phone", ""), + "email": lead.get("email", ""), + "company": lead.get("company_name", ""), + "industry": lead.get("sector", ""), + "city": lead.get("city", ""), + "leadsource": f"Dealix - {lead.get('source', 'web')}", + "hs_lead_status": self._status_to_hs(lead.get("status", "new")), + } + } + + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.hubapi.com/crm/v3/objects/contacts", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=hs_contact, + ) + + if response.status_code in (200, 201): + hs_id = response.json().get("id") + return {"status": "success", "hubspot_id": hs_id} + return {"status": "error", "message": response.text} + + async def hubspot_pull_contacts(self, api_key: str, after: str = None) -> list: + """Pull contacts from HubSpot into Dealix.""" + params = { + "limit": 100, + "properties": "firstname,lastname,phone,email,company,industry,city", + } + if after: + params["after"] = after + + async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.hubapi.com/crm/v3/objects/contacts", + params=params, + headers={"Authorization": f"Bearer {api_key}"}, + ) + + if response.status_code != 200: + return [] + + results = response.json().get("results", []) + return [ + { + "external_id": r["id"], + "full_name": f"{r['properties'].get('firstname', '')} {r['properties'].get('lastname', '')}".strip(), + "phone": r["properties"].get("phone", ""), + "email": r["properties"].get("email", ""), + "company_name": r["properties"].get("company", ""), + "sector": r["properties"].get("industry", ""), + "city": r["properties"].get("city", ""), + } + for r in results + ] + + # ── Generic Sync ────────────────────────────── + + async def sync_lead_to_crm( + self, tenant_id: str, lead_id: str, provider: str + ) -> dict: + """Sync a lead to the configured CRM for this tenant.""" + from app.services.lead_service import LeadService + + lead_svc = LeadService(self.db) + lead = await lead_svc.get_lead(tenant_id, lead_id) + if not lead: + return {"status": "error", "message": "Lead not found"} + + # Get CRM credentials for tenant (from tenant settings) + credentials = await self._get_crm_credentials(tenant_id, provider) + if not credentials: + return {"status": "error", "message": f"No {provider} credentials configured"} + + if provider == "salesforce": + return await self.salesforce_push_lead(lead, credentials) + elif provider == "hubspot": + return await self.hubspot_push_contact(lead, credentials.get("api_key", "")) + + return {"status": "error", "message": f"Unsupported provider: {provider}"} + + async def full_sync(self, tenant_id: str, provider: str) -> dict: + """Full bidirectional sync with CRM.""" + pushed = 0 + pulled = 0 + errors = [] + + credentials = await self._get_crm_credentials(tenant_id, provider) + if not credentials: + return {"status": "error", "message": "No credentials configured"} + + # Pull from CRM + try: + if provider == "salesforce": + external_leads = await self.salesforce_pull_leads(credentials) + elif provider == "hubspot": + external_leads = await self.hubspot_pull_contacts(credentials.get("api_key", "")) + else: + external_leads = [] + + from app.services.lead_service import LeadService + lead_svc = LeadService(self.db) + + for ext_lead in external_leads: + try: + await lead_svc.create_lead( + tenant_id=tenant_id, + full_name=ext_lead["full_name"], + phone=ext_lead.get("phone", ""), + email=ext_lead.get("email", ""), + company_name=ext_lead.get("company_name", ""), + sector=ext_lead.get("sector", ""), + city=ext_lead.get("city", ""), + source=provider, + ) + pulled += 1 + except Exception as e: + errors.append({"type": "pull", "error": str(e)}) + + except Exception as e: + errors.append({"type": "pull_batch", "error": str(e)}) + + return { + "status": "completed", + "pushed": pushed, + "pulled": pulled, + "errors": errors, + } + + # ── Helpers ─────────────────────────────────── + + async def _get_crm_credentials(self, tenant_id: str, provider: str) -> Optional[dict]: + """Get CRM credentials from tenant settings.""" + # In production, this would fetch from encrypted tenant settings + if provider == "salesforce": + return { + "access_token": settings.SALESFORCE_CLIENT_SECRET, + "instance_url": "", + } if settings.SALESFORCE_CLIENT_ID else None + elif provider == "hubspot": + return { + "api_key": settings.HUBSPOT_API_KEY, + } if settings.HUBSPOT_API_KEY else None + return None + + @staticmethod + def _score_to_sf_rating(score: int) -> str: + if score >= 80: + return "Hot" + elif score >= 50: + return "Warm" + return "Cold" + + @staticmethod + def _status_to_hs(status: str) -> str: + mapping = { + "new": "NEW", + "contacted": "IN_PROGRESS", + "qualified": "QUALIFIED", + "converted": "CUSTOMER", + "lost": "UNQUALIFIED", + } + return mapping.get(status, "NEW") diff --git a/salesflow-saas/backend/app/services/deal_service.py b/salesflow-saas/backend/app/services/deal_service.py new file mode 100644 index 00000000..0127db62 --- /dev/null +++ b/salesflow-saas/backend/app/services/deal_service.py @@ -0,0 +1,237 @@ +""" +Deal Service — Pipeline management, stage transitions, forecasting. +""" + +import uuid +from datetime import datetime, timezone +from decimal import Decimal +from typing import Optional + +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + + +VALID_STAGES = ["discovery", "proposal", "negotiation", "closed_won", "closed_lost"] +STAGE_PROBABILITIES = { + "discovery": 20, + "proposal": 40, + "negotiation": 60, + "closed_won": 100, + "closed_lost": 0, +} + + +class DealService: + """Manages the deal pipeline from discovery to close.""" + + def __init__(self, db: AsyncSession): + self.db = db + + # ── CRUD ────────────────────────────────────── + + async def create_deal( + self, + tenant_id: str, + lead_id: str = None, + assigned_to: str = None, + title: str = "", + stage: str = "discovery", + value: float = 0, + currency: str = "SAR", + expected_close: str = None, + ) -> dict: + from app.models.deal import Deal + + deal = Deal( + id=uuid.uuid4(), + tenant_id=uuid.UUID(tenant_id), + lead_id=uuid.UUID(lead_id) if lead_id else None, + assigned_to=uuid.UUID(assigned_to) if assigned_to else None, + title=title, + stage=stage, + value=Decimal(str(value)), + currency=currency, + probability=STAGE_PROBABILITIES.get(stage, 20), + expected_close=datetime.fromisoformat(expected_close) if expected_close else None, + ) + self.db.add(deal) + await self.db.flush() + return self._to_dict(deal) + + async def get_deal(self, tenant_id: str, deal_id: str) -> Optional[dict]: + from app.models.deal import Deal + + result = await self.db.execute( + select(Deal).where( + Deal.id == uuid.UUID(deal_id), + Deal.tenant_id == uuid.UUID(tenant_id), + ) + ) + deal = result.scalar_one_or_none() + return self._to_dict(deal) if deal else None + + async def list_deals( + self, + tenant_id: str, + stage: str = None, + assigned_to: str = None, + min_value: float = None, + page: int = 1, + per_page: int = 25, + ) -> dict: + from app.models.deal import Deal + + query = select(Deal).where(Deal.tenant_id == uuid.UUID(tenant_id)) + + if stage: + query = query.where(Deal.stage == stage) + if assigned_to: + query = query.where(Deal.assigned_to == uuid.UUID(assigned_to)) + if min_value is not None: + query = query.where(Deal.value >= Decimal(str(min_value))) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await self.db.execute(count_q)).scalar() or 0 + + query = query.order_by(Deal.created_at.desc()) + query = query.offset((page - 1) * per_page).limit(per_page) + result = await self.db.execute(query) + deals = [self._to_dict(d) for d in result.scalars().all()] + + return { + "items": deals, + "total": total, + "page": page, + "per_page": per_page, + } + + async def update_deal(self, tenant_id: str, deal_id: str, **updates) -> Optional[dict]: + from app.models.deal import Deal + + result = await self.db.execute( + select(Deal).where( + Deal.id == uuid.UUID(deal_id), + Deal.tenant_id == uuid.UUID(tenant_id), + ) + ) + deal = result.scalar_one_or_none() + if not deal: + return None + + for key, value in updates.items(): + if hasattr(deal, key) and value is not None: + setattr(deal, key, value) + + deal.updated_at = datetime.now(timezone.utc) + await self.db.flush() + return self._to_dict(deal) + + # ── Stage Management ────────────────────────── + + async def move_stage( + self, + tenant_id: str, + deal_id: str, + new_stage: str, + lost_reason: str = None, + ) -> Optional[dict]: + if new_stage not in VALID_STAGES: + return None + + updates = { + "stage": new_stage, + "probability": STAGE_PROBABILITIES[new_stage], + } + + if new_stage == "closed_won": + updates["closed_at"] = datetime.now(timezone.utc) + elif new_stage == "closed_lost": + updates["closed_at"] = datetime.now(timezone.utc) + if lost_reason: + updates["lost_reason"] = lost_reason + + return await self.update_deal(tenant_id, deal_id, **updates) + + # ── Pipeline Analytics ──────────────────────── + + async def get_pipeline(self, tenant_id: str) -> dict: + from app.models.deal import Deal + + pipeline = {} + for stage in VALID_STAGES: + count_q = select(func.count()).where( + Deal.tenant_id == uuid.UUID(tenant_id), + Deal.stage == stage, + ) + value_q = select(func.coalesce(func.sum(Deal.value), 0)).where( + Deal.tenant_id == uuid.UUID(tenant_id), + Deal.stage == stage, + ) + count = (await self.db.execute(count_q)).scalar() or 0 + value = (await self.db.execute(value_q)).scalar() or 0 + pipeline[stage] = { + "count": count, + "value": float(value), + "weighted": float(value) * STAGE_PROBABILITIES[stage] / 100, + } + + total_value = sum(s["value"] for s in pipeline.values()) + total_weighted = sum(s["weighted"] for s in pipeline.values()) + + return { + "stages": pipeline, + "total_deals": sum(s["count"] for s in pipeline.values()), + "total_value": total_value, + "weighted_value": total_weighted, + } + + async def get_forecast(self, tenant_id: str) -> dict: + from app.models.deal import Deal + + open_stages = ["discovery", "proposal", "negotiation"] + monthly = {} + + for stage in open_stages: + q = select( + func.date_trunc("month", Deal.expected_close).label("month"), + func.sum(Deal.value).label("value"), + func.count().label("count"), + ).where( + Deal.tenant_id == uuid.UUID(tenant_id), + Deal.stage == stage, + Deal.expected_close.isnot(None), + ).group_by("month") + + rows = (await self.db.execute(q)).all() + for row in rows: + key = str(row.month) + if key not in monthly: + monthly[key] = {"value": 0, "weighted": 0, "count": 0} + monthly[key]["value"] += float(row.value or 0) + monthly[key]["weighted"] += float(row.value or 0) * STAGE_PROBABILITIES[stage] / 100 + monthly[key]["count"] += row.count + + return {"monthly_forecast": monthly} + + # ── Helpers ─────────────────────────────────── + + @staticmethod + def _to_dict(deal) -> dict: + if not deal: + return {} + return { + "id": str(deal.id), + "tenant_id": str(deal.tenant_id), + "lead_id": str(deal.lead_id) if deal.lead_id else None, + "assigned_to": str(deal.assigned_to) if deal.assigned_to else None, + "title": deal.title, + "stage": deal.stage, + "value": float(deal.value) if deal.value else 0, + "currency": deal.currency, + "probability": deal.probability, + "expected_close": deal.expected_close.isoformat() if deal.expected_close else None, + "closed_at": deal.closed_at.isoformat() if deal.closed_at else None, + "lost_reason": deal.lost_reason, + "created_at": deal.created_at.isoformat() if deal.created_at else None, + "updated_at": deal.updated_at.isoformat() if deal.updated_at else None, + } diff --git a/salesflow-saas/backend/app/services/lead_service.py b/salesflow-saas/backend/app/services/lead_service.py new file mode 100644 index 00000000..654d8086 --- /dev/null +++ b/salesflow-saas/backend/app/services/lead_service.py @@ -0,0 +1,377 @@ +""" +Lead Service — CRUD, qualification, scoring, assignment, import/export. +The heart of the sales pipeline. +""" + +import csv +import io +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import select, func, and_, or_, update +from sqlalchemy.ext.asyncio import AsyncSession + + +class LeadService: + """Manages the full lifecycle of leads from creation to conversion.""" + + def __init__(self, db: AsyncSession): + self.db = db + + # ── CRUD ────────────────────────────────────── + + async def create_lead( + self, + tenant_id: str, + full_name: str, + phone: str = "", + email: str = "", + company_name: str = "", + sector: str = "", + city: str = "", + source: str = "web", + notes: str = "", + assigned_to: str = None, + ) -> dict: + from app.models.lead import Lead + + lead = Lead( + id=uuid.uuid4(), + tenant_id=uuid.UUID(tenant_id), + full_name=full_name, + phone=phone, + email=email, + company_name=company_name, + sector=sector, + city=city, + source=source, + status="new", + score=0, + notes=notes, + assigned_to=uuid.UUID(assigned_to) if assigned_to else None, + ) + self.db.add(lead) + await self.db.flush() + return self._to_dict(lead) + + async def get_lead(self, tenant_id: str, lead_id: str) -> Optional[dict]: + from app.models.lead import Lead + + result = await self.db.execute( + select(Lead).where( + Lead.id == uuid.UUID(lead_id), + Lead.tenant_id == uuid.UUID(tenant_id), + ) + ) + lead = result.scalar_one_or_none() + return self._to_dict(lead) if lead else None + + async def list_leads( + self, + tenant_id: str, + status: str = None, + source: str = None, + sector: str = None, + city: str = None, + assigned_to: str = None, + min_score: int = None, + search: str = None, + page: int = 1, + per_page: int = 25, + sort_by: str = "created_at", + sort_dir: str = "desc", + ) -> dict: + from app.models.lead import Lead + + query = select(Lead).where(Lead.tenant_id == uuid.UUID(tenant_id)) + + if status: + query = query.where(Lead.status == status) + if source: + query = query.where(Lead.source == source) + if sector: + query = query.where(Lead.sector == sector) + if city: + query = query.where(Lead.city == city) + if assigned_to: + query = query.where(Lead.assigned_to == uuid.UUID(assigned_to)) + if min_score is not None: + query = query.where(Lead.score >= min_score) + if search: + pattern = f"%{search}%" + query = query.where( + or_( + Lead.full_name.ilike(pattern), + Lead.email.ilike(pattern), + Lead.phone.ilike(pattern), + Lead.company_name.ilike(pattern), + ) + ) + + # Count + count_q = select(func.count()).select_from(query.subquery()) + total = (await self.db.execute(count_q)).scalar() or 0 + + # Sort + sort_col = getattr(Lead, sort_by, Lead.created_at) + if sort_dir == "asc": + query = query.order_by(sort_col.asc()) + else: + query = query.order_by(sort_col.desc()) + + # Paginate + query = query.offset((page - 1) * per_page).limit(per_page) + result = await self.db.execute(query) + leads = [self._to_dict(l) for l in result.scalars().all()] + + return { + "items": leads, + "total": total, + "page": page, + "per_page": per_page, + "pages": (total + per_page - 1) // per_page, + } + + async def update_lead( + self, tenant_id: str, lead_id: str, **updates + ) -> Optional[dict]: + from app.models.lead import Lead + + result = await self.db.execute( + select(Lead).where( + Lead.id == uuid.UUID(lead_id), + Lead.tenant_id == uuid.UUID(tenant_id), + ) + ) + lead = result.scalar_one_or_none() + if not lead: + return None + + for key, value in updates.items(): + if hasattr(lead, key) and value is not None: + setattr(lead, key, value) + + lead.updated_at = datetime.now(timezone.utc) + await self.db.flush() + return self._to_dict(lead) + + async def delete_lead(self, tenant_id: str, lead_id: str) -> bool: + from app.models.lead import Lead + + result = await self.db.execute( + select(Lead).where( + Lead.id == uuid.UUID(lead_id), + Lead.tenant_id == uuid.UUID(tenant_id), + ) + ) + lead = result.scalar_one_or_none() + if not lead: + return False + + lead.status = "deleted" + lead.updated_at = datetime.now(timezone.utc) + await self.db.flush() + return True + + # ── Assignment ──────────────────────────────── + + async def assign_lead( + self, + tenant_id: str, + lead_id: str, + agent_id: str, + ) -> Optional[dict]: + return await self.update_lead( + tenant_id, lead_id, assigned_to=uuid.UUID(agent_id) + ) + + async def auto_assign_round_robin(self, tenant_id: str, lead_id: str) -> Optional[dict]: + """Assign lead to the agent with the fewest active leads.""" + from app.models.user import User + from app.models.lead import Lead + + # Get active agents + agents_q = select(User.id).where( + User.tenant_id == uuid.UUID(tenant_id), + User.role.in_(["agent", "manager"]), + User.is_active == True, + ) + agents = (await self.db.execute(agents_q)).scalars().all() + if not agents: + return None + + # Count active leads per agent + best_agent = None + min_leads = float("inf") + for agent_id in agents: + count_q = select(func.count()).where( + Lead.tenant_id == uuid.UUID(tenant_id), + Lead.assigned_to == agent_id, + Lead.status.in_(["new", "contacted", "qualified"]), + ) + count = (await self.db.execute(count_q)).scalar() or 0 + if count < min_leads: + min_leads = count + best_agent = agent_id + + if best_agent: + return await self.assign_lead(tenant_id, lead_id, str(best_agent)) + return None + + # ── Qualification ───────────────────────────── + + async def qualify_lead( + self, + tenant_id: str, + lead_id: str, + score: int, + status: str = None, + reasoning: str = "", + ) -> Optional[dict]: + updates = {"score": score} + if status: + updates["status"] = status + if score >= 70: + updates["status"] = "qualified" + updates["qualified_at"] = datetime.now(timezone.utc) + elif score < 30: + updates["status"] = "lost" + else: + updates["status"] = "contacted" + + return await self.update_lead(tenant_id, lead_id, **updates) + + # ── Conversion ──────────────────────────────── + + async def convert_to_deal( + self, + tenant_id: str, + lead_id: str, + deal_title: str = "", + deal_value: float = 0, + ) -> Optional[dict]: + from app.models.deal import Deal + + lead = await self.get_lead(tenant_id, lead_id) + if not lead: + return None + + deal = Deal( + id=uuid.uuid4(), + tenant_id=uuid.UUID(tenant_id), + lead_id=uuid.UUID(lead_id), + assigned_to=uuid.UUID(lead["assigned_to"]) if lead.get("assigned_to") else None, + title=deal_title or f"Deal - {lead['full_name']}", + stage="discovery", + value=deal_value, + currency="SAR", + probability=20, + ) + self.db.add(deal) + + await self.update_lead( + tenant_id, + lead_id, + status="converted", + converted_at=datetime.now(timezone.utc), + ) + await self.db.flush() + + return { + "deal_id": str(deal.id), + "lead_id": lead_id, + "title": deal.title, + "stage": deal.stage, + "value": float(deal.value), + } + + # ── Import/Export ───────────────────────────── + + async def import_from_csv(self, tenant_id: str, csv_content: str) -> dict: + reader = csv.DictReader(io.StringIO(csv_content)) + created = 0 + errors = [] + + for i, row in enumerate(reader, 1): + try: + await self.create_lead( + tenant_id=tenant_id, + full_name=row.get("name", row.get("full_name", "")), + phone=row.get("phone", ""), + email=row.get("email", ""), + company_name=row.get("company", row.get("company_name", "")), + sector=row.get("sector", row.get("industry", "")), + city=row.get("city", ""), + source="import", + ) + created += 1 + except Exception as e: + errors.append({"row": i, "error": str(e)}) + + return {"created": created, "errors": errors, "total_rows": created + len(errors)} + + async def export_to_csv(self, tenant_id: str, **filters) -> str: + data = await self.list_leads(tenant_id, per_page=10000, **filters) + output = io.StringIO() + if not data["items"]: + return "" + + writer = csv.DictWriter(output, fieldnames=data["items"][0].keys()) + writer.writeheader() + writer.writerows(data["items"]) + return output.getvalue() + + # ── Stats ───────────────────────────────────── + + async def get_stats(self, tenant_id: str) -> dict: + from app.models.lead import Lead + + base = select(func.count()).where(Lead.tenant_id == uuid.UUID(tenant_id)) + total = (await self.db.execute(base)).scalar() or 0 + + statuses = {} + for s in ["new", "contacted", "qualified", "converted", "lost"]: + q = base.where(Lead.status == s) + statuses[s] = (await self.db.execute(q)).scalar() or 0 + + avg_score_q = select(func.avg(Lead.score)).where( + Lead.tenant_id == uuid.UUID(tenant_id), + Lead.score > 0, + ) + avg_score = (await self.db.execute(avg_score_q)).scalar() or 0 + + return { + "total": total, + "by_status": statuses, + "avg_score": round(float(avg_score), 1), + "conversion_rate": round( + (statuses.get("converted", 0) / total * 100) if total > 0 else 0, 1 + ), + } + + # ── Helpers ─────────────────────────────────── + + @staticmethod + def _to_dict(lead) -> dict: + if not lead: + return {} + return { + "id": str(lead.id), + "tenant_id": str(lead.tenant_id), + "assigned_to": str(lead.assigned_to) if lead.assigned_to else None, + "source": lead.source, + "status": lead.status, + "score": lead.score, + "full_name": lead.full_name, + "phone": lead.phone, + "email": lead.email, + "company_name": lead.company_name, + "sector": lead.sector, + "city": lead.city, + "notes": lead.notes, + "qualified_at": lead.qualified_at.isoformat() if lead.qualified_at else None, + "converted_at": lead.converted_at.isoformat() if lead.converted_at else None, + "created_at": lead.created_at.isoformat() if lead.created_at else None, + "updated_at": lead.updated_at.isoformat() if lead.updated_at else None, + } diff --git a/salesflow-saas/backend/app/services/llm/__init__.py b/salesflow-saas/backend/app/services/llm/__init__.py new file mode 100644 index 00000000..a6871a0b --- /dev/null +++ b/salesflow-saas/backend/app/services/llm/__init__.py @@ -0,0 +1,4 @@ +"""LLM services package.""" +from app.services.llm.provider import LLMRouter, get_llm, LLMResponse + +__all__ = ["LLMRouter", "get_llm", "LLMResponse"] diff --git a/salesflow-saas/backend/app/services/llm/provider.py b/salesflow-saas/backend/app/services/llm/provider.py new file mode 100644 index 00000000..1e18f8e8 --- /dev/null +++ b/salesflow-saas/backend/app/services/llm/provider.py @@ -0,0 +1,249 @@ +""" +LLM Provider Abstraction Layer +Supports Groq (primary) and OpenAI (fallback) with automatic failover. +""" + +import time +import json +import logging +from typing import Optional, AsyncGenerator +from abc import ABC, abstractmethod + +from app.config import get_settings + +settings = get_settings() +logger = logging.getLogger("dealix.llm") + + +class LLMResponse: + """Standardized LLM response across providers.""" + def __init__(self, content: str, tokens_used: int = 0, latency_ms: int = 0, + provider: str = "", model: str = "", raw: dict = None): + self.content = content + self.tokens_used = tokens_used + self.latency_ms = latency_ms + self.provider = provider + self.model = model + self.raw = raw or {} + + def to_dict(self) -> dict: + return { + "content": self.content, + "tokens_used": self.tokens_used, + "latency_ms": self.latency_ms, + "provider": self.provider, + "model": self.model, + } + + def parse_json(self) -> Optional[dict]: + """Try to parse content as JSON.""" + try: + # Handle markdown code blocks + text = self.content.strip() + if text.startswith("```json"): + text = text[7:] + if text.startswith("```"): + text = text[3:] + if text.endswith("```"): + text = text[:-3] + return json.loads(text.strip()) + except (json.JSONDecodeError, ValueError): + return None + + +class BaseLLMProvider(ABC): + """Abstract base for LLM providers.""" + + @abstractmethod + async def complete(self, system_prompt: str, user_message: str, + temperature: float = None, max_tokens: int = None, + json_mode: bool = False) -> LLMResponse: + pass + + @abstractmethod + async def is_available(self) -> bool: + pass + + +class GroqProvider(BaseLLMProvider): + """Groq API provider — ultra-fast inference.""" + + def __init__(self): + from groq import AsyncGroq + self.client = AsyncGroq(api_key=settings.GROQ_API_KEY) if settings.GROQ_API_KEY else None + self.model = settings.GROQ_MODEL + self.fast_model = settings.GROQ_FAST_MODEL + + async def is_available(self) -> bool: + return bool(settings.GROQ_API_KEY and self.client) + + async def complete(self, system_prompt: str, user_message: str, + temperature: float = None, max_tokens: int = None, + json_mode: bool = False, fast: bool = False) -> LLMResponse: + if not self.client: + raise RuntimeError("Groq API key not configured") + + model = self.fast_model if fast else self.model + start = time.time() + + kwargs = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message}, + ], + "temperature": temperature or settings.LLM_TEMPERATURE, + "max_tokens": max_tokens or settings.LLM_MAX_TOKENS, + } + + if json_mode: + kwargs["response_format"] = {"type": "json_object"} + + response = await self.client.chat.completions.create(**kwargs) + latency = int((time.time() - start) * 1000) + + return LLMResponse( + content=response.choices[0].message.content or "", + tokens_used=response.usage.total_tokens if response.usage else 0, + latency_ms=latency, + provider="groq", + model=model, + raw=response.model_dump() if hasattr(response, "model_dump") else {}, + ) + + +class OpenAIProvider(BaseLLMProvider): + """OpenAI API provider — highest quality, fallback.""" + + def __init__(self): + from openai import AsyncOpenAI + self.client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) if settings.OPENAI_API_KEY else None + self.model = settings.OPENAI_MODEL + self.mini_model = settings.OPENAI_MINI_MODEL + + async def is_available(self) -> bool: + return bool(settings.OPENAI_API_KEY and self.client) + + async def complete(self, system_prompt: str, user_message: str, + temperature: float = None, max_tokens: int = None, + json_mode: bool = False, mini: bool = False) -> LLMResponse: + if not self.client: + raise RuntimeError("OpenAI API key not configured") + + model = self.mini_model if mini else self.model + start = time.time() + + kwargs = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_message}, + ], + "temperature": temperature or settings.LLM_TEMPERATURE, + "max_tokens": max_tokens or settings.LLM_MAX_TOKENS, + } + + if json_mode: + kwargs["response_format"] = {"type": "json_object"} + + response = await self.client.chat.completions.create(**kwargs) + latency = int((time.time() - start) * 1000) + + return LLMResponse( + content=response.choices[0].message.content or "", + tokens_used=response.usage.total_tokens if response.usage else 0, + latency_ms=latency, + provider="openai", + model=model, + raw=response.model_dump() if hasattr(response, "model_dump") else {}, + ) + + +class LLMRouter: + """ + Intelligent LLM routing with automatic failover. + Primary: Groq (fast, free/cheap) + Fallback: OpenAI (reliable, high quality) + """ + + def __init__(self): + self.groq = GroqProvider() + self.openai = OpenAIProvider() + self._primary = settings.LLM_PRIMARY_PROVIDER + + async def complete(self, system_prompt: str, user_message: str, + temperature: float = None, max_tokens: int = None, + json_mode: bool = False, provider: str = None, + fast: bool = False) -> LLMResponse: + """ + Send a completion request to the best available provider. + + Args: + system_prompt: System instructions + user_message: User input + temperature: Override default temperature + max_tokens: Override default max tokens + json_mode: Request JSON output + provider: Force specific provider ("groq" or "openai") + fast: Use faster/smaller model variant + """ + # Determine provider order + if provider == "openai": + providers = [("openai", self.openai)] + elif provider == "groq": + providers = [("groq", self.groq)] + elif self._primary == "groq": + providers = [("groq", self.groq), ("openai", self.openai)] + else: + providers = [("openai", self.openai), ("groq", self.groq)] + + last_error = None + for name, prov in providers: + if not await prov.is_available(): + logger.warning(f"LLM provider {name} not available, trying next...") + continue + try: + kwargs = { + "system_prompt": system_prompt, + "user_message": user_message, + "temperature": temperature, + "max_tokens": max_tokens, + "json_mode": json_mode, + } + if name == "groq": + kwargs["fast"] = fast + elif name == "openai": + kwargs["mini"] = fast + + result = await prov.complete(**kwargs) + logger.info( + f"LLM call: provider={name} model={result.model} " + f"tokens={result.tokens_used} latency={result.latency_ms}ms" + ) + return result + except Exception as e: + last_error = e + logger.warning(f"LLM provider {name} failed: {e}, trying next...") + continue + + raise RuntimeError(f"All LLM providers failed. Last error: {last_error}") + + async def complete_json(self, system_prompt: str, user_message: str, + **kwargs) -> dict: + """Shortcut: complete and parse as JSON.""" + response = await self.complete(system_prompt, user_message, + json_mode=True, **kwargs) + parsed = response.parse_json() + if parsed is None: + raise ValueError(f"Failed to parse LLM response as JSON: {response.content[:200]}") + return parsed + + +# Singleton +_router: Optional[LLMRouter] = None + +def get_llm() -> LLMRouter: + global _router + if _router is None: + _router = LLMRouter() + return _router diff --git a/salesflow-saas/backend/app/services/meeting_service.py b/salesflow-saas/backend/app/services/meeting_service.py new file mode 100644 index 00000000..77ed7f1d --- /dev/null +++ b/salesflow-saas/backend/app/services/meeting_service.py @@ -0,0 +1,247 @@ +""" +Meeting Service — AI-driven scheduling, calendar sync, preparation packages. +""" + +import uuid +from datetime import datetime, timedelta, timezone +from typing import Optional + +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession + + +class MeetingService: + """Manages meeting lifecycle: schedule, confirm, prepare, remind.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def create_meeting( + self, + tenant_id: str, + lead_id: str, + agent_id: str, + proposed_time: str, + channel: str = "whatsapp", + notes: str = "", + ) -> dict: + from app.models.ai_conversation import AutoBooking + + booking = AutoBooking( + id=uuid.uuid4(), + tenant_id=uuid.UUID(tenant_id), + lead_id=uuid.UUID(lead_id), + agent_id=uuid.UUID(agent_id), + proposed_time=datetime.fromisoformat(proposed_time), + status="proposed", + channel=channel, + ) + self.db.add(booking) + await self.db.flush() + return self._to_dict(booking) + + async def confirm_meeting( + self, tenant_id: str, meeting_id: str, confirmed_time: str = None + ) -> Optional[dict]: + from app.models.ai_conversation import AutoBooking + + result = await self.db.execute( + select(AutoBooking).where( + AutoBooking.id == uuid.UUID(meeting_id), + AutoBooking.tenant_id == uuid.UUID(tenant_id), + ) + ) + booking = result.scalar_one_or_none() + if not booking: + return None + + booking.status = "confirmed" + if confirmed_time: + booking.confirmed_time = datetime.fromisoformat(confirmed_time) + else: + booking.confirmed_time = booking.proposed_time + booking.updated_at = datetime.now(timezone.utc) + await self.db.flush() + return self._to_dict(booking) + + async def cancel_meeting( + self, tenant_id: str, meeting_id: str, reason: str = "" + ) -> Optional[dict]: + from app.models.ai_conversation import AutoBooking + + result = await self.db.execute( + select(AutoBooking).where( + AutoBooking.id == uuid.UUID(meeting_id), + AutoBooking.tenant_id == uuid.UUID(tenant_id), + ) + ) + booking = result.scalar_one_or_none() + if not booking: + return None + + booking.status = "cancelled" + booking.updated_at = datetime.now(timezone.utc) + await self.db.flush() + return self._to_dict(booking) + + async def reschedule_meeting( + self, tenant_id: str, meeting_id: str, new_time: str + ) -> Optional[dict]: + from app.models.ai_conversation import AutoBooking + + result = await self.db.execute( + select(AutoBooking).where( + AutoBooking.id == uuid.UUID(meeting_id), + AutoBooking.tenant_id == uuid.UUID(tenant_id), + ) + ) + booking = result.scalar_one_or_none() + if not booking: + return None + + booking.proposed_time = datetime.fromisoformat(new_time) + booking.confirmed_time = None + booking.status = "rescheduled" + booking.updated_at = datetime.now(timezone.utc) + await self.db.flush() + return self._to_dict(booking) + + async def list_meetings( + self, + tenant_id: str, + agent_id: str = None, + status: str = None, + from_date: str = None, + to_date: str = None, + page: int = 1, + per_page: int = 25, + ) -> dict: + from app.models.ai_conversation import AutoBooking + + query = select(AutoBooking).where( + AutoBooking.tenant_id == uuid.UUID(tenant_id) + ) + + if agent_id: + query = query.where(AutoBooking.agent_id == uuid.UUID(agent_id)) + if status: + query = query.where(AutoBooking.status == status) + if from_date: + query = query.where(AutoBooking.proposed_time >= datetime.fromisoformat(from_date)) + if to_date: + query = query.where(AutoBooking.proposed_time <= datetime.fromisoformat(to_date)) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await self.db.execute(count_q)).scalar() or 0 + + query = query.order_by(AutoBooking.proposed_time.asc()) + query = query.offset((page - 1) * per_page).limit(per_page) + result = await self.db.execute(query) + meetings = [self._to_dict(m) for m in result.scalars().all()] + + return {"items": meetings, "total": total, "page": page, "per_page": per_page} + + async def get_availability( + self, + tenant_id: str, + agent_id: str, + date: str, + slot_duration_minutes: int = 30, + ) -> list: + """Get available time slots for an agent on a given date.""" + from app.models.ai_conversation import AutoBooking + + target_date = datetime.fromisoformat(date).date() + start = datetime.combine(target_date, datetime.min.time().replace(hour=8)) + end = datetime.combine(target_date, datetime.min.time().replace(hour=18)) + + # Get booked slots + booked_q = select(AutoBooking.proposed_time, AutoBooking.confirmed_time).where( + AutoBooking.tenant_id == uuid.UUID(tenant_id), + AutoBooking.agent_id == uuid.UUID(agent_id), + AutoBooking.status.in_(["proposed", "confirmed"]), + AutoBooking.proposed_time >= start, + AutoBooking.proposed_time < end, + ) + booked = (await self.db.execute(booked_q)).all() + booked_times = set() + for b in booked: + t = b.confirmed_time or b.proposed_time + booked_times.add(t.replace(minute=(t.minute // slot_duration_minutes) * slot_duration_minutes, second=0)) + + # Generate slots + slots = [] + current = start.replace(tzinfo=timezone.utc) + end = end.replace(tzinfo=timezone.utc) + while current < end: + if current not in booked_times: + slots.append({ + "time": current.isoformat(), + "available": True, + }) + current += timedelta(minutes=slot_duration_minutes) + + return slots + + async def prepare_meeting_package( + self, tenant_id: str, meeting_id: str + ) -> dict: + """Generate a meeting preparation package (AI-powered).""" + from app.models.ai_conversation import AutoBooking + + result = await self.db.execute( + select(AutoBooking).where( + AutoBooking.id == uuid.UUID(meeting_id), + AutoBooking.tenant_id == uuid.UUID(tenant_id), + ) + ) + booking = result.scalar_one_or_none() + if not booking: + return {} + + # Get lead info for context + from app.services.lead_service import LeadService + lead_svc = LeadService(self.db) + lead = await lead_svc.get_lead(tenant_id, str(booking.lead_id)) + + return { + "meeting_id": str(booking.id), + "lead": lead, + "prep_items": { + "company_brief": f"Prepare brief for {lead.get('company_name', 'Unknown')}", + "sector": lead.get("sector", ""), + "talking_points": [], # AI will fill this + "predicted_objections": [], # AI will fill this + "recommended_presentation": None, # Will match to sector + }, + "status": "pending_ai_enrichment", + } + + async def get_today_schedule(self, tenant_id: str, agent_id: str) -> list: + today = datetime.now(timezone.utc).date() + tomorrow = today + timedelta(days=1) + data = await self.list_meetings( + tenant_id, + agent_id=agent_id, + from_date=datetime.combine(today, datetime.min.time()).isoformat(), + to_date=datetime.combine(tomorrow, datetime.min.time()).isoformat(), + per_page=50, + ) + return data["items"] + + @staticmethod + def _to_dict(booking) -> dict: + if not booking: + return {} + return { + "id": str(booking.id), + "tenant_id": str(booking.tenant_id), + "lead_id": str(booking.lead_id), + "agent_id": str(booking.agent_id), + "proposed_time": booking.proposed_time.isoformat() if booking.proposed_time else None, + "confirmed_time": booking.confirmed_time.isoformat() if booking.confirmed_time else None, + "status": booking.status, + "channel": booking.channel, + "calendar_event_id": booking.calendar_event_id, + "created_at": booking.created_at.isoformat() if booking.created_at else None, + } diff --git a/salesflow-saas/backend/app/services/notification_service.py b/salesflow-saas/backend/app/services/notification_service.py new file mode 100644 index 00000000..88425c68 --- /dev/null +++ b/salesflow-saas/backend/app/services/notification_service.py @@ -0,0 +1,226 @@ +""" +Notification Service — Multi-channel delivery (in-app, WhatsApp, email, SMS). +""" + +import uuid +from datetime import datetime, timezone +from typing import Optional + +from sqlalchemy import select, func, update +from sqlalchemy.ext.asyncio import AsyncSession + + +class NotificationService: + """Manages notifications across all channels.""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def send( + self, + tenant_id: str, + user_id: str, + title: str, + body: str, + notification_type: str = "info", + channel: str = "in_app", + data: dict = None, + ) -> dict: + from app.models.notification import Notification + + notif = Notification( + id=uuid.uuid4(), + tenant_id=uuid.UUID(tenant_id), + user_id=uuid.UUID(user_id), + type=notification_type, + title=title, + body=body, + channel=channel, + is_read=False, + ) + self.db.add(notif) + await self.db.flush() + + # Dispatch to external channels + if channel == "whatsapp": + await self._send_whatsapp(user_id, body) + elif channel == "email": + await self._send_email(user_id, title, body) + elif channel == "sms": + await self._send_sms(user_id, body) + + return { + "id": str(notif.id), + "channel": channel, + "status": "sent", + } + + async def send_bulk( + self, + tenant_id: str, + user_ids: list, + title: str, + body: str, + notification_type: str = "info", + channel: str = "in_app", + ) -> dict: + results = [] + for uid in user_ids: + result = await self.send(tenant_id, uid, title, body, notification_type, channel) + results.append(result) + return {"sent": len(results), "results": results} + + async def get_unread(self, tenant_id: str, user_id: str) -> list: + from app.models.notification import Notification + + result = await self.db.execute( + select(Notification) + .where( + Notification.tenant_id == uuid.UUID(tenant_id), + Notification.user_id == uuid.UUID(user_id), + Notification.is_read == False, + ) + .order_by(Notification.created_at.desc()) + .limit(50) + ) + return [self._to_dict(n) for n in result.scalars().all()] + + async def get_all( + self, tenant_id: str, user_id: str, page: int = 1, per_page: int = 20 + ) -> dict: + from app.models.notification import Notification + + query = select(Notification).where( + Notification.tenant_id == uuid.UUID(tenant_id), + Notification.user_id == uuid.UUID(user_id), + ).order_by(Notification.created_at.desc()) + + count_q = select(func.count()).select_from(query.subquery()) + total = (await self.db.execute(count_q)).scalar() or 0 + + query = query.offset((page - 1) * per_page).limit(per_page) + result = await self.db.execute(query) + + return { + "items": [self._to_dict(n) for n in result.scalars().all()], + "total": total, + "unread_count": await self._count_unread(tenant_id, user_id), + } + + async def mark_read(self, tenant_id: str, notification_id: str) -> bool: + from app.models.notification import Notification + + result = await self.db.execute( + select(Notification).where( + Notification.id == uuid.UUID(notification_id), + Notification.tenant_id == uuid.UUID(tenant_id), + ) + ) + notif = result.scalar_one_or_none() + if not notif: + return False + + notif.is_read = True + notif.read_at = datetime.now(timezone.utc) + await self.db.flush() + return True + + async def mark_all_read(self, tenant_id: str, user_id: str) -> int: + from app.models.notification import Notification + + result = await self.db.execute( + update(Notification) + .where( + Notification.tenant_id == uuid.UUID(tenant_id), + Notification.user_id == uuid.UUID(user_id), + Notification.is_read == False, + ) + .values(is_read=True, read_at=datetime.now(timezone.utc)) + ) + return result.rowcount + + # ── Alert Templates ─────────────────────────── + + async def notify_new_lead(self, tenant_id: str, agent_id: str, lead_name: str): + await self.send( + tenant_id, agent_id, + title="عميل محتمل جديد 🔔", + body=f"تم تعيين عميل جديد لك: {lead_name}", + notification_type="lead", + channel="in_app", + ) + + async def notify_meeting_booked(self, tenant_id: str, agent_id: str, lead_name: str, time: str): + await self.send( + tenant_id, agent_id, + title="موعد جديد مؤكد 📅", + body=f"تم حجز موعد مع {lead_name} في {time}", + notification_type="meeting", + channel="in_app", + ) + + async def notify_deal_won(self, tenant_id: str, agent_id: str, deal_title: str, value: float): + await self.send( + tenant_id, agent_id, + title="صفقة ناجحة! 🎉", + body=f"تم إغلاق صفقة {deal_title} بقيمة {value:,.0f} ريال", + notification_type="deal", + channel="in_app", + ) + + async def notify_commission_earned(self, tenant_id: str, affiliate_id: str, amount: float): + await self.send( + tenant_id, affiliate_id, + title="عمولة جديدة 💰", + body=f"تم إضافة عمولة {amount:,.0f} ريال إلى حسابك", + notification_type="commission", + channel="in_app", + ) + + async def notify_escalation(self, tenant_id: str, manager_id: str, reason: str): + await self.send( + tenant_id, manager_id, + title="تصعيد يتطلب انتباهك ⚠️", + body=reason, + notification_type="escalation", + channel="in_app", + ) + + # ── Channel Dispatchers ─────────────────────── + + async def _send_whatsapp(self, user_id: str, message: str): + # Will be implemented with WhatsApp integration + pass + + async def _send_email(self, user_id: str, subject: str, body: str): + # Will be implemented with email integration + pass + + async def _send_sms(self, user_id: str, message: str): + # Will be implemented with SMS integration + pass + + async def _count_unread(self, tenant_id: str, user_id: str) -> int: + from app.models.notification import Notification + + q = select(func.count()).where( + Notification.tenant_id == uuid.UUID(tenant_id), + Notification.user_id == uuid.UUID(user_id), + Notification.is_read == False, + ) + return (await self.db.execute(q)).scalar() or 0 + + @staticmethod + def _to_dict(notif) -> dict: + if not notif: + return {} + return { + "id": str(notif.id), + "type": notif.type, + "title": notif.title, + "body": notif.body, + "channel": notif.channel, + "is_read": notif.is_read, + "read_at": notif.read_at.isoformat() if notif.read_at else None, + "created_at": notif.created_at.isoformat() if notif.created_at else None, + } diff --git a/salesflow-saas/backend/app/services/trust_score_service.py b/salesflow-saas/backend/app/services/trust_score_service.py new file mode 100644 index 00000000..a5d312a9 --- /dev/null +++ b/salesflow-saas/backend/app/services/trust_score_service.py @@ -0,0 +1,247 @@ +""" +Trust Score Service — AI-powered scoring for leads and affiliates. +""" + +import uuid +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + + +class TrustScoreService: + """Calculates trust scores for leads and affiliates to prioritize high-quality opportunities.""" + + def __init__(self, db: AsyncSession): + self.db = db + + # ── Lead Trust Score (0-100) ────────────────── + + async def calculate_lead_score(self, tenant_id: str, lead_id: str) -> dict: + from app.models.lead import Lead + from app.models.message import Message + + result = await self.db.execute( + select(Lead).where( + Lead.id == uuid.UUID(lead_id), + Lead.tenant_id == uuid.UUID(tenant_id), + ) + ) + lead = result.scalar_one_or_none() + if not lead: + return {} + + score = 0 + breakdown = {} + + # 1. Contact info completeness (+20 max) + contact_score = 0 + if lead.phone and len(lead.phone) >= 9: + contact_score += 10 + if lead.email and "@" in lead.email: + contact_score += 10 + breakdown["contact_info"] = contact_score + score += contact_score + + # 2. Company info (+25 max) + company_score = 0 + if lead.company_name: + company_score += 10 + if lead.sector: + company_score += 5 + if lead.city: + company_score += 5 + # CR number verification would add +5 more + breakdown["company_info"] = company_score + score += company_score + + # 3. Engagement level (+25 max) + msg_count_q = select(func.count()).where( + Message.lead_id == uuid.UUID(lead_id), + Message.direction == "inbound", + ) + msg_count = (await self.db.execute(msg_count_q)).scalar() or 0 + engagement_score = min(25, msg_count * 5) + breakdown["engagement"] = engagement_score + score += engagement_score + + # 4. Response speed (+15 max) + if msg_count > 0: + # Has responded = good sign + response_score = 15 + else: + response_score = 0 + breakdown["responsiveness"] = response_score + score += response_score + + # 5. Source quality (+15 max) + source_scores = { + "referral": 15, "affiliate": 12, "web": 10, + "whatsapp": 8, "import": 5, "cold": 3, + } + source_score = source_scores.get(lead.source, 5) + breakdown["source_quality"] = source_score + score += source_score + + # Normalize to 0-100 + score = min(100, score) + + # Classification + if score >= 70: + classification = "hot" + classification_ar = "ساخن 🔥" + elif score >= 40: + classification = "warm" + classification_ar = "دافئ ☀️" + else: + classification = "cold" + classification_ar = "بارد ❄️" + + # Update lead score + lead.score = score + await self.db.flush() + + return { + "lead_id": str(lead_id), + "trust_score": score, + "classification": classification, + "classification_ar": classification_ar, + "breakdown": breakdown, + "recommendation": self._get_lead_recommendation(classification), + } + + # ── Affiliate Trust Score (0-100) ───────────── + + async def calculate_affiliate_score(self, tenant_id: str, affiliate_id: str) -> dict: + from app.models.affiliate import Affiliate, AffiliatePerformance + + result = await self.db.execute( + select(Affiliate).where( + Affiliate.id == uuid.UUID(affiliate_id), + Affiliate.tenant_id == uuid.UUID(tenant_id), + ) + ) + aff = result.scalar_one_or_none() + if not aff: + return {} + + score = 0 + breakdown = {} + + # 1. Lead Quality — conversion rate (40% weight) + perf_q = select( + func.coalesce(func.sum(AffiliatePerformance.leads_generated), 0), + func.coalesce(func.sum(AffiliatePerformance.deals_closed), 0), + ).where(AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id)) + + perf = (await self.db.execute(perf_q)).first() + total_leads = int(perf[0]) if perf else 0 + total_deals = int(perf[1]) if perf else 0 + + if total_leads > 0: + conv_rate = total_deals / total_leads + quality_score = min(40, int(conv_rate * 200)) + else: + quality_score = 0 + breakdown["lead_quality"] = quality_score + score += quality_score + + # 2. Activity Consistency (20% weight) + recent_q = select(func.count()).where( + AffiliatePerformance.affiliate_id == uuid.UUID(affiliate_id), + AffiliatePerformance.leads_generated > 0, + ) + active_months = (await self.db.execute(recent_q)).scalar() or 0 + consistency_score = min(20, active_months * 4) + breakdown["consistency"] = consistency_score + score += consistency_score + + # 3. Volume (20% weight) + volume_score = min(20, total_deals * 2) + breakdown["volume"] = volume_score + score += volume_score + + # 4. Tier bonus (10% weight) + tier_scores = {"bronze": 2, "silver": 5, "gold": 8, "platinum": 10} + tier_score = tier_scores.get(aff.tier, 0) + breakdown["tier_bonus"] = tier_score + score += tier_score + + # 5. Longevity (10% weight) + months_active = 0 + if aff.approved_at: + delta = datetime.now(timezone.utc) - aff.approved_at.replace(tzinfo=timezone.utc) + months_active = delta.days // 30 + longevity = min(10, months_active) + breakdown["longevity"] = longevity + score += longevity + + score = min(100, score) + + if score >= 75: + tier_label = "Elite ⭐" + elif score >= 50: + tier_label = "Trusted ✅" + elif score >= 25: + tier_label = "Growing 📈" + else: + tier_label = "New 🆕" + + return { + "affiliate_id": str(affiliate_id), + "trust_score": score, + "label": tier_label, + "breakdown": breakdown, + "stats": { + "total_leads": total_leads, + "total_deals": total_deals, + "months_active": months_active, + "conversion_rate": round(total_deals / total_leads * 100, 1) if total_leads > 0 else 0, + }, + } + + # ── Batch Scoring ───────────────────────────── + + async def score_all_leads(self, tenant_id: str) -> dict: + from app.models.lead import Lead + + result = await self.db.execute( + select(Lead.id).where( + Lead.tenant_id == uuid.UUID(tenant_id), + Lead.status.in_(["new", "contacted"]), + ) + ) + lead_ids = [str(lid) for lid in result.scalars().all()] + + scored = 0 + for lid in lead_ids: + await self.calculate_lead_score(tenant_id, lid) + scored += 1 + + return {"scored": scored, "total": len(lead_ids)} + + # ── Helpers ─────────────────────────────────── + + @staticmethod + def _get_lead_recommendation(classification: str) -> dict: + recommendations = { + "hot": { + "action": "book_meeting", + "action_ar": "احجز موعد فوراً", + "priority": "critical", + "message": "This lead shows strong buying signals. Book a meeting immediately.", + }, + "warm": { + "action": "nurture", + "action_ar": "تابع التواصل", + "priority": "high", + "message": "Engage with targeted content and schedule a follow-up.", + }, + "cold": { + "action": "drip_campaign", + "action_ar": "أضف لحملة المتابعة", + "priority": "low", + "message": "Add to drip campaign. Re-evaluate in 2 weeks.", + }, + } + return recommendations.get(classification, recommendations["cold"]) diff --git a/salesflow-saas/backend/app/workers/agent_tasks.py b/salesflow-saas/backend/app/workers/agent_tasks.py new file mode 100644 index 00000000..981ec11a --- /dev/null +++ b/salesflow-saas/backend/app/workers/agent_tasks.py @@ -0,0 +1,80 @@ +""" +AI Agent Async Tasks — Celery +Executes agents asynchronously in the background. +""" + +import asyncio +import logging +from celery import shared_task +from celery.utils.log import get_task_logger + +logger = get_task_logger(__name__) + +def execute_agent_sync(agent_type: str, input_data: dict, tenant_id: str = None, + lead_id: str = None, conversation_id: str = None): + """Synchronous wrapper for async true agent executor.""" + from app.database import async_session + from app.services.agents.executor import AgentExecutor + import json + + async def run(): + async with async_session() as db: + executor = AgentExecutor(db) + result = await executor.execute( + agent_type=agent_type, + input_data=input_data, + tenant_id=tenant_id, + lead_id=lead_id, + conversation_id=conversation_id + ) + # Ensure DB updates are committed + await db.commit() + return result.to_dict() + + return asyncio.run(run()) + + +def execute_event_sync(event_type: str, input_data: dict, tenant_id: str = None, + lead_id: str = None, conversation_id: str = None): + """Synchronous wrapper for async event executor.""" + from app.database import async_session + from app.services.agents.executor import AgentExecutor + + async def run(): + async with async_session() as db: + executor = AgentExecutor(db) + results = await executor.execute_event( + event_type=event_type, + input_data=input_data, + tenant_id=tenant_id, + lead_id=lead_id, + conversation_id=conversation_id + ) + await db.commit() + return [r.to_dict() for r in results] + + return asyncio.run(run()) + + +@shared_task(bind=True, max_retries=3, default_retry_delay=60) +def run_ai_agent(self, agent_type: str, input_data: dict, tenant_id: str = None, + lead_id: str = None, conversation_id: str = None): + """Run a specific AI agent in the background.""" + try: + logger.info(f"Starting agent {agent_type} for tenant {tenant_id}") + return execute_agent_sync(agent_type, input_data, tenant_id, lead_id, conversation_id) + except Exception as exc: + logger.error(f"Agent {agent_type} failed: {exc}") + self.retry(exc=exc) + + +@shared_task(bind=True, max_retries=3) +def process_agent_event(self, event_type: str, input_data: dict, tenant_id: str = None, + lead_id: str = None, conversation_id: str = None): + """Process an event by triggering the appropriate AI agent chain.""" + try: + logger.info(f"Processing agent event {event_type} for tenant {tenant_id}") + return execute_event_sync(event_type, input_data, tenant_id, lead_id, conversation_id) + except Exception as exc: + logger.error(f"Event {event_type} failed: {exc}") + self.retry(exc=exc) diff --git a/salesflow-saas/backend/requirements.txt b/salesflow-saas/backend/requirements.txt index 641fafbc..162622d2 100644 --- a/salesflow-saas/backend/requirements.txt +++ b/salesflow-saas/backend/requirements.txt @@ -1,21 +1,47 @@ +# ── Core Framework ────────────────────────────────────── fastapi==0.115.6 uvicorn[standard]==0.34.0 +python-multipart==0.0.19 + +# ── Database ──────────────────────────────────────────── sqlalchemy[asyncio]==2.0.36 asyncpg==0.30.0 alembic==1.14.1 +pgvector==0.3.6 + +# ── Validation / Settings ────────────────────────────── pydantic==2.10.4 pydantic-settings==2.7.1 + +# ── Authentication ────────────────────────────────────── python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 -python-multipart==0.0.19 + +# ── Task Queue ────────────────────────────────────────── celery[redis]==5.4.0 redis==5.2.1 + +# ── HTTP Client ───────────────────────────────────────── httpx==0.28.1 + +# ── Templating & Emails ──────────────────────────────── jinja2==3.1.5 -python-dateutil==2.9.0 emails==0.6 + +# ── LLM Providers ────────────────────────────────────── +openai==1.58.1 +groq==0.13.0 + +# ── Data Processing ──────────────────────────────────── +python-dateutil==2.9.0 +openpyxl==3.1.5 aiofiles==24.1.0 pillow==11.1.0 -openpyxl==3.1.5 + +# ── Utilities ────────────────────────────────────────── +python-slugify==8.0.4 +phonenumbers==8.13.50 + +# ── Testing ──────────────────────────────────────────── pytest==8.3.4 pytest-asyncio==0.25.0 diff --git a/salesflow-saas/dealix-frontend.zip b/salesflow-saas/dealix-frontend.zip new file mode 100644 index 00000000..22a0a46e Binary files /dev/null and b/salesflow-saas/dealix-frontend.zip differ diff --git a/salesflow-saas/frontend/package.json b/salesflow-saas/frontend/package.json index 35868f27..daedca25 100644 --- a/salesflow-saas/frontend/package.json +++ b/salesflow-saas/frontend/package.json @@ -13,7 +13,11 @@ "react": "19.0.0", "react-dom": "19.0.0", "lucide-react": "0.469.0", - "clsx": "2.1.1" + "clsx": "2.1.1", + "tailwind-merge": "^2.5.5", + "framer-motion": "^11.15.0", + "date-fns": "^4.1.0", + "recharts": "^2.15.0" }, "devDependencies": { "@types/node": "22.10.5", diff --git a/salesflow-saas/frontend/src/app/globals.css b/salesflow-saas/frontend/src/app/globals.css index f7edd2f1..acc72b45 100644 --- a/salesflow-saas/frontend/src/app/globals.css +++ b/salesflow-saas/frontend/src/app/globals.css @@ -1,28 +1,117 @@ -@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@300;400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=Tajawal:wght@700;800;900&display=swap'); - @tailwind base; @tailwind components; @tailwind utilities; -html { - scroll-behavior: smooth; +@layer base { + :root { + --background: 220 20% 97%; + --foreground: 220 30% 15%; + + --card: 0 0% 100%; + --card-foreground: 220 30% 15%; + + --popover: 0 0% 100%; + --popover-foreground: 220 30% 15%; + + --primary: 240 70% 50%; + --primary-foreground: 0 0% 100%; + + --secondary: 220 15% 90%; + --secondary-foreground: 220 30% 15%; + + --muted: 220 15% 90%; + --muted-foreground: 220 10% 45%; + + --accent: 260 80% 60%; + --accent-foreground: 0 0% 100%; + + --destructive: 0 85% 60%; + --destructive-foreground: 0 0% 100%; + + --success: 140 70% 40%; + --success-foreground: 0 0% 100%; + + --border: 220 15% 85%; + --input: 220 15% 85%; + --ring: 240 70% 50%; + + --radius: 0.75rem; + } + + .dark { + --background: 224 30% 8%; /* Extremely sleek dark blue/black */ + --foreground: 210 20% 90%; + + --card: 224 30% 12%; + --card-foreground: 210 20% 90%; + + --popover: 224 30% 12%; + --popover-foreground: 210 20% 90%; + + --primary: 240 80% 65%; /* Vibrant glowing blue */ + --primary-foreground: 224 30% 8%; + + --secondary: 224 30% 15%; + --secondary-foreground: 210 20% 90%; + + --muted: 224 30% 15%; + --muted-foreground: 215 15% 65%; + + --accent: 270 80% 65%; /* Vibrant purple accent */ + --accent-foreground: 0 0% 100%; + + --destructive: 0 70% 50%; + --destructive-foreground: 0 0% 100%; + + --success: 140 60% 50%; + --success-foreground: 0 0% 100%; + + --border: 224 30% 20%; + --input: 224 30% 20%; + --ring: 240 80% 65%; + } } -body { - font-family: 'IBM Plex Sans Arabic', 'Inter', sans-serif; +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground bg-gradient-to-tr from-background to-background/80 min-h-screen bg-fixed antialiased selection:bg-primary/20 selection:text-primary; + } } +/* Glassmorphism Utilities */ @layer utilities { - .text-gradient { - @apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-secondary; + .glass { + @apply bg-white/10 dark:bg-black/20 backdrop-blur-md border border-white/20 dark:border-white/10 shadow-lg; } - .bg-hero-gradient { - background: linear-gradient(135deg, #0F4C81 0%, #1A1A2E 50%, #0F4C81 100%); - } - .bg-grid { - background-image: - linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px), - linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px); - background-size: 60px 60px; + .glass-card { + @apply bg-card/80 backdrop-blur-xl border border-border shadow-xl rounded-2xl transition-all duration-300 hover:shadow-primary/5 hover:border-primary/20; + } +} + +/* Animations & Scrollbar */ +@layer utilities { + .animate-float { + animation: float 6s ease-in-out infinite; + } + + @keyframes float { + 0% { transform: translateY(0px); } + 50% { transform: translateY(-10px); } + 100% { transform: translateY(0px); } + } + + /* Custom Scrollbar */ + ::-webkit-scrollbar { + width: 6px; + height: 6px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + @apply bg-muted-foreground/30 rounded-full hover:bg-muted-foreground/50 transition-colors; } } diff --git a/salesflow-saas/frontend/src/app/layout.tsx b/salesflow-saas/frontend/src/app/layout.tsx index 1e81edfa..edfbb407 100644 --- a/salesflow-saas/frontend/src/app/layout.tsx +++ b/salesflow-saas/frontend/src/app/layout.tsx @@ -1,23 +1,32 @@ import type { Metadata } from "next"; +import { Noto_Kufi_Arabic } from "next/font/google"; import "./globals.css"; +const kufi = Noto_Kufi_Arabic({ + subsets: ["arabic", "latin"], + weight: ["300", "400", "500", "600", "700"], + variable: "--font-kufi", +}); + export const metadata: Metadata = { - title: "Dealix - ديل اي اكس | منصة مبيعات ذكية للشركات", - description: "منصة ذكاء اصطناعي لأتمتة المبيعات. تدير عملاءك، تتابعهم تلقائياً، وتغلق الصفقات. مصممة للسوق السعودي.", - keywords: "مبيعات, CRM, SaaS, ذكاء اصطناعي, أتمتة, عيادات, عقارات, الرياض, السعودية", + title: "ديل اي اكس - Dealix OS", + description: "The autonomous AI sales engine for the Saudi market.", }; export default function RootLayout({ children, -}: { +}: Readonly<{ children: React.ReactNode; -}) { +}>) { return ( - - - - - {children} + + + {/* Background Gradients for depth */} +
+
+ + {children} + ); } diff --git a/salesflow-saas/frontend/src/app/page.tsx b/salesflow-saas/frontend/src/app/page.tsx index d313823e..8e042b84 100644 --- a/salesflow-saas/frontend/src/app/page.tsx +++ b/salesflow-saas/frontend/src/app/page.tsx @@ -1,505 +1,137 @@ -import { - Users, MessageSquare, BarChart3, Target, Zap, Phone, - CheckCircle2, ArrowLeft, Star, ChevronDown, Building2, - Stethoscope, Home, Clock, Shield, Globe, Award, Briefcase, - Bot, UserPlus +"use client"; + +import { useState } from "react"; +import { + BarChart3, + Users, + Target, + MessageSquare, + Zap, + Bell, + Search, + BrainCircuit, + Settings, + BookOpen, + MonitorPlay, + FileSignature, + ShieldCheck, + Phone } from "lucide-react"; -const features = [ - { icon: Users, title: "إدارة العملاء المحتملين", titleEn: "Lead Management", desc: "التقط العملاء من واتساب، الموقع، ووسائل التواصل تلقائياً" }, - { icon: MessageSquare, title: "المتابعة التلقائية", titleEn: "Auto Follow-up", desc: "الذكاء الاصطناعي يرسل رسائل وتذكيرات بدون تدخلك" }, - { icon: Target, title: "خط أنابيب المبيعات", titleEn: "Sales Pipeline", desc: "تابع صفقاتك بصرياً وحرّك كل صفقة بين المراحل بسهولة" }, - { icon: Zap, title: "عروض أسعار ذكية", titleEn: "Smart Proposals", desc: "أنشئ وأرسل عروض أسعار احترافية في دقائق" }, - { icon: BarChart3, title: "تقارير وتحليلات", titleEn: "Reports & Analytics", desc: "لوحات بيانات فورية تتابع إيراداتك وأداء فريقك" }, - { icon: Phone, title: "واتساب بزنس", titleEn: "WhatsApp Business", desc: "أرسل واستقبل الرسائل مباشرة من المنصة" }, -]; +import { DashboardView } from "../components/dealix/dashboard-view"; +import { AffiliatesView } from "../components/dealix/affiliates-view"; +import { ChatbotView } from "../components/dealix/chatbot-view"; +import { PresentationsView } from "../components/dealix/presentations-view"; +import { ScriptsView } from "../components/dealix/scripts-view"; +import { AgreementsView } from "../components/dealix/agreements-view"; +import { GuaranteesView } from "../components/dealix/guarantees-view"; +import { OnboardingView } from "../components/dealix/onboarding-view"; -const painPoints = [ - { emoji: "😰", text: "تضيع عملاء لأن المتابعة متأخرة؟" }, - { emoji: "😵", text: "فريقك يشتغل بدون نظام واضح؟" }, - { emoji: "💸", text: "ما تعرف وين فلوسك رايحة؟" }, -]; +export default function AppLayout() { + const [activeTab, setActiveTab] = useState("overview"); -const steps = [ - { num: "01", title: "سجّل شركتك", desc: "في دقيقتين فقط", icon: Building2 }, - { num: "02", title: "اختر قالب قطاعك", desc: "عيادات، عقارات، أو غيرها", icon: Globe }, - { num: "03", title: "المنصة تبدأ تبيع لك", desc: "المتابعة التلقائية تبدأ فوراً", icon: Zap }, -]; + const NAV_ITEMS = [ + { id: "overview", label: "نظرة عامة", icon: BarChart3 }, + { id: "affiliates", label: "المسوقين والموظفين", icon: Users }, + { id: "agents", label: "الوكلاء الأذكياء (Agents)", icon: BrainCircuit }, + { id: "presentations", label: "البرزنتيشنات القطاعية", icon: MonitorPlay }, + { id: "scripts", label: "سكربتات المبيعات", icon: Phone }, + { id: "agreements", label: "الاتفاقيات واHR", icon: FileSignature }, + { id: "guarantee", label: "الضمان الذهبي", icon: ShieldCheck }, + { id: "onboarding", label: "ديل المسوق وتأهيله", icon: BookOpen }, + ]; -const plans = [ - { - name: "أساسي", nameEn: "Basic", price: "299", popular: false, - features: ["2 مستخدمين", "100 عميل محتمل/شهر", "500 رسالة واتساب", "3 أتمتة", "تقارير أساسية", "دعم بالإيميل"], - }, - { - name: "احترافي", nameEn: "Professional", price: "699", popular: true, - features: ["10 مستخدمين", "1,000 عميل محتمل/شهر", "5,000 رسالة واتساب", "20 أتمتة", "تقارير متقدمة", "دعم أولوية", "قوالب قطاعية"], - }, - { - name: "مؤسسات", nameEn: "Enterprise", price: "1,499", popular: false, - features: ["مستخدمين بلا حدود", "عملاء بلا حدود", "رسائل بلا حدود", "أتمتة بلا حدود", "تقارير مخصصة", "دعم مخصص", "API كامل", "مدير حساب خاص"], - }, -]; + const renderContent = () => { + switch (activeTab) { + case "overview": return ; + case "affiliates": return ; + case "agents": return ; + case "presentations": return ; + case "scripts": return ; + case "agreements": return ; + case "guarantee": return ; + case "onboarding": return ; + default: return ; + } + }; -const faqs = [ - { q: "هل يدعم الواتساب؟", a: "نعم، نربط مع واتساب بزنس API مباشرة. ترسل وتستقبل الرسائل من داخل المنصة." }, - { q: "هل بياناتي آمنة؟", a: "تشفير كامل لكل البيانات. سيرفرات آمنة مع نسخ احتياطية يومية." }, - { q: "كم يوم التجربة المجانية؟", a: "14 يوم كاملة بكل المميزات بدون بطاقة ائتمان." }, - { q: "هل يدعم العربي؟", a: "المنصة كاملة بالعربي والإنجليزي. مصممة للسوق السعودي." }, - { q: "أقدر ألغي أي وقت؟", a: "نعم، بدون أي التزام أو رسوم إلغاء." }, -]; - -const stats = [ - { value: "+500", label: "شركة تثق بنا" }, - { value: "+10,000", label: "صفقة تم إغلاقها" }, - { value: "+2M", label: "رسالة تم إرسالها" }, - { value: "24/7", label: "أتمتة مستمرة" }, -]; - -export default function LandingPage() { return ( -
- {/* Navigation */} - - {/* Hero Section */} -
-
-
-
-
- - صنع في السعودية للسوق السعودي -
-

- حوّل مبيعاتك إلى -
- - ماكينة أرباح تعمل 24/7 - -

-

- منصة ذكاء اصطناعي تدير عملاءك، تتابعهم تلقائياً، وتغلق الصفقات بدون تدخل. مصممة للشركات الصغيرة والمتوسطة. -

- -

بدون بطاقة ائتمان • إلغاء أي وقت

-
+ - {/* Dashboard Mockup */} -
-
-
-
-
-
-
-
- {[ - { label: "عملاء جدد اليوم", value: "23", color: "text-secondary" }, - { label: "صفقات مفتوحة", value: "47", color: "text-accent" }, - { label: "إيرادات الشهر", value: "185K", color: "text-emerald-400" }, - { label: "معدل التحويل", value: "34%", color: "text-purple-400" }, - ].map((stat, i) => ( -
-
{stat.value}
-
{stat.label}
-
- ))} -
-
- {["جديد", "تم التواصل", "موعد محجوز", "عرض سعر", "تم الإغلاق"].map((stage, i) => ( -
-
{stage}
- {Array.from({ length: 3 - Math.floor(i * 0.5) }).map((_, j) => ( -
- ))} -
- ))} -
-
-
+
+
-
+ - {/* Pain Points */} -
-
-
- {painPoints.map((p, i) => ( -
-
{p.emoji}
-

{p.text}

-

Dealix يحل هذي المشكلة

+ {/* ── Main Content ────────────────────────────────────────────── */} +
+ {/* Header */} +
+
+ + +
+ +
+ +
+
+

سالم الدوسري

+

المدير العام (Founder)

- ))} -
-
-
- - {/* Features */} -
-
-
-

كل اللي تحتاجه لزيادة مبيعاتك

-

منصة متكاملة تجمع كل أدوات المبيعات في مكان واحد

-
-
- {features.map((f, i) => ( -
-
- +
+
+ SD
-

{f.title}

-

{f.titleEn}

-

{f.desc}

- ))} +
-
-
+ - {/* How It Works */} -
-
-
-

ابدأ في 3 خطوات

-

من التسجيل إلى أول صفقة في دقائق

-
-
- {steps.map((s, i) => ( -
-
- -
-
الخطوة {s.num}
-

{s.title}

-

{s.desc}

-
- ))} -
+ {/* Dynamic View Injection */} +
+ {renderContent()}
-
- - {/* Industry Templates */} -
-
-
-

قوالب جاهزة لقطاعك

-

اختر قالب قطاعك وابدأ فوراً

-
-
-
- -

العيادات والصحة

-

إدارة المرضى والمواعيد والمتابعة

-
متاح الآن
-
-
- -

عقارات الرياض

-

عقارات، جولات، عروض، أحياء الرياض

-
متاح الآن
-
-
- -

المقاولات

-

إدارة المشاريع والعملاء

-
قريباً
-
-
- -

الصالونات

-

حجوزات ومتابعة العملاء

-
قريباً
-
-
-
-
- - {/* Stats */} -
-
-
- {stats.map((s, i) => ( -
-
{s.value}
-
{s.label}
-
- ))} -
-
-
- - {/* Pricing */} -
-
-
-

خطط تناسب حجم شركتك

-

ابدأ مجاناً 14 يوم • بدون بطاقة ائتمان

-
-
- {plans.map((plan, i) => ( -
- {plan.popular && ( -
- الأكثر شعبية -
- )} -
-

{plan.name}

-

{plan.nameEn}

-
- {plan.price} - ر.س/شهر -
-
-
    - {plan.features.map((f, j) => ( -
  • - - {f} -
  • - ))} -
- - ابدأ تجربة مجانية - -
- ))} -
-
-
- - {/* FAQ */} -
-
-
-

أسئلة شائعة

-
-
- {faqs.map((faq, i) => ( -
- - {faq.q} - - -
{faq.a}
-
- ))} -
-
-
- - {/* Gold Guarantee */} -
-
-
- -
-

الضمان الذهبي

-

ما تستفيد؟ نرجع لك فلوسك كاملة

-
-

- نحن واثقين من جودة Dealix. إذا استخدمت المنصة لمدة 30 يوم وما شفت نتائج حقيقية في مبيعاتك، نرجع لك المبلغ كامل بدون أي سؤال. -

-
-
-
30 يوم
-
فترة الضمان
-
-
-
100%
-
استرجاع كامل
-
-
-
بدون تعقيد
-
عملية سهلة وسريعة
-
-
-
-
-
- - {/* Affiliate / Join Our Team */} -
-
-
-
- - فرصة عمل حقيقية -
-

انضم لفريق Dealix كمستشار مبيعات

-

اشتغل من أي مكان، في أي وقت يناسبك، واكسب عمولات شهرية متكررة. حقق التارقت وتوظف رسمياً!

-
-
-
-
- -
-

سجّل كمسوّق

-

تعبئة بياناتك واستلام حزمة التدريب الكاملة والأدوات الاحترافية

-
-
-
- -
-

استهدف واكسب

-

استخدم السكربتات والبرزنتيشنات الجاهزة لجذب العملاء واكسب عمولة على كل صفقة

-
-
-
- -
-

حقق التارقت وتوظف

-

10 شركات بالشهر = توظيف رسمي براتب ثابت + عمولات أعلى + تأمين صحي

-
-
-
- - انضم الآن كمستشار مبيعات - - -

بدون رسوم انضمام • تدريب مجاني • أدوات احترافية مجانية

-
-
-
- - {/* AI Agents Section */} -
-
-
-
- - ذكاء اصطناعي متقدم -
-

وكلاء الذكاء الاصطناعي يشتغلون لك 24/7

-

ما تحتاج توظف فريق مبيعات ضخم. وكلاء Dealix الأذكياء يلقون العملاء، يتواصلون معهم، ويحجزون لك الاجتماعات تلقائياً.

-
-
-
-
🔍
-

البحث الذكي

-

يبحث عن عملاء محتملين من قوقل، لينكدن، والأدلة التجارية تلقائياً

-
-
-
💬
-

واتساب ذكي

-

بوت واتساب متقدم يتواصل مع العملاء، يجاوب أسئلتهم، ويتابع معهم

-
-
-
📞
-

مكالمات صوتية

-

مكالمات آلية ذكية بالعربي تعرّف العميل بالمنصة وتحجز له موعد

-
-
-
📅
-

حجز تلقائي

-

يحجز اجتماعات مع فريق المبيعات تلقائياً للعملاء المؤهلين

-
-
-
-
- - {/* Final CTA */} -
-
-

جاهز تزيد مبيعاتك؟

-

انضم لمئات الشركات اللي زادت مبيعاتها مع Dealix

- -
-
- - {/* Footer */} - +
); } diff --git a/salesflow-saas/frontend/src/components/dealix/affiliates-view.tsx b/salesflow-saas/frontend/src/components/dealix/affiliates-view.tsx new file mode 100644 index 00000000..64ebdca6 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/affiliates-view.tsx @@ -0,0 +1,125 @@ +import { Users, Award, TrendingUp, AlertCircle, Building2, UserPlus, Filter, Download } from "lucide-react"; + +export function AffiliatesView() { + const affiliates = [ + { id: "A-101", name: "أحمد عبدالله", status: "نشط", sales: 12, rev: "450K ر.س", comm: "45K ر.س", level: "Senior", eligibleForHire: true }, + { id: "A-102", name: "سارة خالد", status: "نشط", sales: 4, rev: "120K ر.س", comm: "9.6K ر.س", level: "Mid", eligibleForHire: false }, + { id: "A-103", name: "محمد ياسر", status: "إنذار", sales: 0, rev: "0 ر.س", comm: "0 ر.س", level: "New", eligibleForHire: false }, + { id: "A-104", name: "فهد عبدالرحمن", status: "نشط", sales: 8, rev: "240K ر.س", comm: "24K ر.س", level: "Mid", eligibleForHire: false }, + { id: "A-105", name: "لينا العتيبي", status: "مرشح للتعيين", sales: 15, rev: "600K ر.س", comm: "60K ر.س", level: "Senior", eligibleForHire: true }, + ]; + + return ( +
+
+
+

👥 إدارة الشركاء والمسوقين (Affiliates)

+

مراقبة أداء المسوقين بالعمولة ومراحلة التوظيف الآلية (Auto-Hire).

+
+
+ + +
+
+ +
+
+
+
+ +
+ +12% +
+

124 مسوق

+

المسوقين النشطين

+
+ +
+
+
+ +
+ +24% +
+

2.4M ر.س

+

إيرادات فريق التسويق (الشهر)

+
+ +
+
+
+ +
+
+

3 مسوقين

+

استوفوا شروط التوظيف الفوري (10+ شركات)

+
+
+ +
+
+

قائمة المسوقين بالعمولة

+ +
+
+ + + + + + + + + + + + + + {affiliates.map((aff, i) => ( + + + + + + + + + + ))} + +
الرقمالاسمالمستوىالإغلاقات (الشهر)المبيعات المُدخلةالعمولة المكتسبةالإجراء
{aff.id} +
{aff.name}
+
{aff.status}
+
+ + {aff.level} + + {aff.sales}{aff.rev}{aff.comm} + {aff.eligibleForHire ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/agreements-view.tsx b/salesflow-saas/frontend/src/components/dealix/agreements-view.tsx new file mode 100644 index 00000000..0850babd --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/agreements-view.tsx @@ -0,0 +1,99 @@ +import { FileSignature, ShieldCheck, MailPlus, AlertCircle, Building2, Download } from "lucide-react"; + +export function AgreementsView() { + return ( +
+
+
+

📋 الاتفاقيات والموارد البشرية (Legal & HR)

+

توليد وإدارة عقود المسوقين بالعمولة ومسار الترقية للتوظيف الرسمي.

+
+
+ +
+ {/* Tier 1: Affiliate Agreement */} +
+
+
+ +
+
+

1. اتفاقية تسويق بالعمولة (عمل حر)

+ للمسوقين الجدد +
+
+
+

+ اتفاقية مبدئية تحفظ حقوق المسوق والشركة وتحدد نسب العمولة من (8% إلى 12%). +

+
    +
  • + + حماية الخصوصية و NDA لعدم إفشاء أسرار العملاء. +
  • +
  • + + شروط دورة الدفع واستحقاق العمولة عند الإغلاق. +
  • +
  • + + قواعد تمثيل الهوية التجارية لـ Dealix بأمانة. +
  • +
+ + + +
+
+ + {/* Tier 2: Formal Employment Workflow */} +
+
+
+ +
+
+
+

2. مسار التوظيف الرسمي (قوى Qiwa)

+ آلي بعد إغلاق 10 شركات / شهر +
+
+
+
+ +

+ يتم تفعيل هذا المسار تلقائياً عند تحقيق مستهدفات المبيعات المستمرة. النظام يقوم بأتمتة رفع تذكرة لإدارة الموارد البشرية لإنشاء عرض وظيفي رسمي عبر "قوى". +

+
+ +
    +
  • + + تسجيل في التأمينات الاجتماعية وعقد رسمي (Qiwa). +
  • +
  • + + راتب ثابت يبدأ من 5,000 ر.س + عمولة 5%. +
  • +
  • + + ترقية صلاحيات في Dealix لمدير حسابات أقدم. +
  • +
+ + +
+
+
+
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/chatbot-view.tsx b/salesflow-saas/frontend/src/components/dealix/chatbot-view.tsx new file mode 100644 index 00000000..6e634f2a --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/chatbot-view.tsx @@ -0,0 +1,86 @@ +import { Bot, Mic, MessageSquare, Plus, Activity, AlertCircle, Phone } from "lucide-react"; + +export function ChatbotView() { + const agents = [ + { name: "وكيل التأهيل (WhatsApp)", type: "Qualification", lang: "العربية (السعودية)", status: "Active", volume: 1450 }, + { name: "وكيل الاتصال الصوتي", type: "Voice Calls", lang: "العربية (السعودية)", status: "Training", volume: 320 }, + { name: "الوكيل العقاري الخاص", type: "Sector Specific", lang: "Bilingual", status: "Active", volume: 890 }, + ]; + + return ( +
+
+
+

🤖 مركز تحكم وكلاء الذكاء الاصطناعي

+

صناعة وتوجيه وكلاء المبيعات، المحادثة النصية (WhatsApp) والاتصال الصوتي (Voice Agents).

+
+ +
+ +
+ {agents.map((agent, i) => ( +
+
+
+
+ {agent.type.includes('Voice') ? : } +
+
+

{agent.name}

+

{agent.type}

+
+
+
+
+ +
+
+ اللغة المدعومة: + {agent.lang} +
+
+ إجمالي المحادثات: + {agent.volume} +
+
+ نسبة التسليم للبشر (Handoff): + 12% +
+ +
+ + +
+
+
+ ))} +
+ + {/* Voice Demo Panel */} +
+
+
+ +
+
+

تجربة الوكيل الصوتي المباشر (Realtime SA)

+

تحدث مباشرة مع وكيلك الذكي لتختبر اللهجة السعودية وسرعة الرد.

+
+
+ +
+
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/dashboard-view.tsx b/salesflow-saas/frontend/src/components/dealix/dashboard-view.tsx new file mode 100644 index 00000000..92cdea92 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/dashboard-view.tsx @@ -0,0 +1,131 @@ +import { BarChart3, Users, Target, TrendingUp, Calendar, ArrowUpRight, BrainCircuit, Zap } from "lucide-react"; + +export function DashboardView() { + const stats = [ + { label: "العملاء المحتملين", value: "2,450", trend: "+12.5%", icon: Users, color: "text-blue-500", bg: "bg-blue-500/10" }, + { label: "الاجتماعات المجدولة", value: "145", trend: "+24.3%", icon: Calendar, color: "text-purple-500", bg: "bg-purple-500/10" }, + { label: "المبيعات المغلقة", value: "89", trend: "+8.2%", icon: Target, color: "text-emerald-500", bg: "bg-emerald-500/10" }, + { label: "إيرادات الشهر", value: "1.2M ر.س", trend: "+18.4%", icon: TrendingUp, color: "text-amber-500", bg: "bg-amber-500/10" }, + ]; + + const pipeline = [ + { name: "شركة الأفق التقنية", stage: "تفاوض", value: "125,000 ر.س", prob: "80%", agent: "وكيل الإغلاق" }, + { name: "مجموعة الرواد", stage: "عرض سعر", value: "450,000 ر.س", prob: "60%", agent: "متدرب الذكاء الاصطناعي" }, + { name: "مصنع الشرق الأوسط", stage: "اجتماع أولي", value: "85,000 ر.س", prob: "30%", agent: "مجدول المواعيد" }, + { name: "مؤسسة النور", stage: "تأهيل", value: "غير محدد", prob: "10%", agent: "وكيل التأهيل" }, + ]; + + return ( +
+ {/* Welcome Intro */} +
+
+

أهلاً بك، سالم 👋

+

نظرة عامة على أداء نظام المبيعات الذكي اليوم.

+
+
+ + +
+
+ + {/* Stats Grid */} +
+ {stats.map((stat, i) => ( +
+
+
+
+ +
+ + + {stat.trend} + +
+
+

{stat.value}

+

{stat.label}

+
+
+ ))} +
+ +
+ {/* Pipeline Table */} +
+
+

أحدث الصفقات في المسار

+ +
+
+ + + + + + + + + + + + {pipeline.map((deal, i) => ( + + + + + + + + ))} + +
العميلالمرحلةالقيمةاحتمالية الإغلاقالوكيل المسؤول
{deal.name} + + {deal.stage} + + {deal.value} +
+
+
+
+ {deal.prob} +
+
+ + {deal.agent} +
+
+
+ + {/* Action Panel */} +
+
+

تنبيهات الإدارة العُليا

+ +
+
+
+ مراجعة شكوى +

شركة "التطوير الذكي" تطلب تفعيل الضمان الذهبي لعدم الوصول للمستهدف التفاعلي.

+ +
+
+ تفعيل توظيف مسوق +

المسوق "أحمد عبدالله" أكمل 12 إغلاق، يحتاج لتحويل عقده إلى رسمي عبر Qiwa.

+ +
+
+
+
+
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/guarantees-view.tsx b/salesflow-saas/frontend/src/components/dealix/guarantees-view.tsx new file mode 100644 index 00000000..45428a4a --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/guarantees-view.tsx @@ -0,0 +1,96 @@ +import { ShieldAlert, Info, AlertTriangle, FileCheck, CheckCircle2, RotateCcw } from "lucide-react"; + +export function GuaranteesView() { + return ( +
+
+
+

🛡️ الضمان الذهبي لـ Dealix (الاسترجاع)

+

سياسة الضمان والشروط وإدارة المطالبات لضمان حق الشركة والعميل معاً.

+
+
+ +
+ {/* Policy Brief */} +
+
+
+ +
+
+

ملخص سياسة الضمان لمدة 30 يوماً

+

يُشترط للوفاء بالضمان الالتزام بخطة تشغيل الوكيل الذكي بالكامل.

+
+
+ +
+

شروط الاستحقاق الرئيسية:

+
    +
  • + + أن يكون العميل قد وفر بيانات التدريب اللازمة (منتجات، أسعار، PDF معرفي) خلال أول 3 أيام من الاشتراك. +
  • +
  • + + تفعيل الوكيل أو الشات بوت على قنوات حية (واتساب، انستقرام) وأن يكون قد استلم ما لا يقل عن 100 رسالة حقيقية من العملاء. +
  • +
  • + + عدم إيقاف تشغيل الوكيل لأكثر من 48 ساعة متواصلة خلال فترة الشهر الأولى. +
  • +
  • + + فشل تقني مثبت (أخطاء جسيمة في الرد، تسريب عملاء، ردود هلوسة) ولم يقم فريق الدعم بحلها. +
  • +
+
+ +
+ +

+ تحذير للمسوقين: لا تقدم ضماناً قطيعاً بدون عرض هذي الشروط الـ 4 للعميل. البيع التضليلي أو المبالغ فيه قد يوقف حسابك تلقائياً في Dealix. +

+
+
+ + {/* Claim workflow & Status */} +
+
+

+ مركز المطالبات + Claims Management +

+
+ +
+
+
+ حالة المراجعة (قيد الانتظار) + مراجعة 3 مطالبات +
+

شركة الأفق الطبي (رفض دفع)

+
+ + +
+
+ +
+
+ حالة السداد (مدفوعة ومسترجعة) + 1 مطالبة +
+

مصنع التمور العصرية

+ تم إرجاع 21,500 ر.س +
+
+ + +
+
+
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/onboarding-view.tsx b/salesflow-saas/frontend/src/components/dealix/onboarding-view.tsx new file mode 100644 index 00000000..2d624701 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/onboarding-view.tsx @@ -0,0 +1,88 @@ +import { BookOpen, Map, Target, Award, Rocket, FileText, Smartphone, Megaphone } from "lucide-react"; + +export function OnboardingView() { + const steps = [ + { num: 1, title: "فهم المنتج (Dealix)", desc: "شركة سعودية للذكاء الاصطناعي موجهة لقطاع الأعمال؛ تصنع موظفين AI للمبيعات والدعم الفني." }, + { num: 2, title: "تحديد الفئة", desc: "شركات B2B/B2C اللي تعاني من نقص في الرد السريع، أو تسرب المبيعات." }, + { num: 3, title: "اختيار القطاع", desc: "اختر قطاع تفهمه جيداً (العقارات، العيادات، أو المتاجر) واستخدم الترسانة القطاعية." }, + { num: 4, title: "الاستهداف", desc: "ابحث في LinkedIn لمعرفة صناع القرار، أو خرائط جوجل (Google Maps) للأنشطة المحلية." }, + { num: 5, title: "التواصل الأولي", desc: "استخدم سكربت 'المكالمة الباردة' أو 'الواتساب البارد' المتوفر في قسم السكربتات." }, + { num: 6, title: "حجز الديمو", desc: "هدفك الوحيد هو إقناع العميل بتجربة ديمو مجاني للـ AI عن طريق الواتساب." }, + { num: 7, title: "الإغلاق", desc: "يقوم فريقنا وخبرائنا (أو أنت إذا كنت محترفاً) بإغلاق الصفقة وتوقيع العقود، لتستلم عمولتك." }, + ]; + + return ( +
+
+
+

📖 دليل المسوق الشامل (Onboarding)

+

خطوتك الأولى لفهم Dealix وكيف تبدأ بتحقيق المبيعات والعمولات من اليوم الأول.

+
+
+ +
+ {/* Core Steps */} +
+
+
+ +
+
+

خارطة الطريق (7 خطوات نجاح)

+
+
+ +
+ {steps.map((step, i) => ( +
+
+ {step.num} +
+
+

{step.title}

+

{step.desc}

+
+
+ ))} +
+
+ + {/* Side Panel: Targets & Strategies */} +
+
+
+ +

استراتيجيات البحث

+
+
    +
  • + Google Maps للشركات والعيادات +
  • +
  • + LinkedIn Sales Navigator +
  • +
  • + إعلانات إنستغرام الممولة كمخابئ للعملاء +
  • +
+
+ +
+
+ +

الترقية التلقائية (Qiwa)

+
+

+ إذا حققت 10 إغلاقات بمبالغ أعلى من 5,000 ريال للشهر الواحد، + يتم ترقيتك فوراً لمسار "المبيعات التنفيذية" بعقد رسمي وراتب ثابت + عمولة 5%. +

+
+ + متبقي لك 10 شركات للتأهل! +
+
+
+
+
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/presentations-view.tsx b/salesflow-saas/frontend/src/components/dealix/presentations-view.tsx new file mode 100644 index 00000000..afc59ede --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/presentations-view.tsx @@ -0,0 +1,117 @@ +import { FileBarChart, MonitorPlay, Activity, Stethoscope, Car, Home, ShoppingBag, BookOpen } from "lucide-react"; + +const SECTORS = [ + { + icon: Stethoscope, + name: "العيادات الطبية", + color: "text-rose-500", + bg: "bg-rose-500/10", + pain: "ضياع حجوزات بسبب التأخر في الرد على الواتساب وعدم التذكير بالمواعيد.", + solution: "حجز تلقائي وتأكيد مواعيد، إجابة عن أسئلة القسم والعيادات 24/7.", + stats: "٣٠٪ معدل فشل حضور المرضى بسبب سوء المتابعة اليدوية.", + deckUrl: "#deck-clinics" + }, + { + icon: Home, + name: "العقارات وإدارة الأملاك", + color: "text-blue-500", + bg: "bg-blue-500/10", + pain: "مئات الاستفسارات عن الأسعار والمواقع والفلترة تضيع وقت الوكلاء.", + solution: "وكيل عقاري ذكي يفلتر العملاء، يسأل عن الميزانية، ويرسل عروض.", + stats: "٧٠٪ من الاستفسارات العقارية غير جادة وتضيع وقت المبيعات.", + deckUrl: "#deck-realestate" + }, + { + icon: Car, + name: "قطاع السيارات وصيانتها", + color: "text-slate-500", + bg: "bg-slate-500/10", + pain: "صعوبة في جدولة مواعيد الصيانة واستفسارات قطع الغيار المملة.", + solution: "حجز مواعيد الصيانة فورياً عبر الواتساب وتذكير العميل عند الانتهاء.", + stats: "السوق يحتاج ٥٠٪ سرعة أكبر في المبيعات بعد طلب تجربة القيادة.", + deckUrl: "#deck-auto" + }, + { + icon: ShoppingBag, + name: "المتاجر الإلكترونية", + color: "text-purple-500", + bg: "bg-purple-500/10", + pain: "استفسارات تتبع الطلب متكررة والسلال المتروكة تكلف أموال.", + solution: "تتبع آلي، إرسال تذكيرات ذكية للسلال المتروكة، دعم ما بعد البيع.", + stats: "٦٨٪ معدل ترك السلال الشرائية حول العالم.", + deckUrl: "#deck-ecommerce" + }, + { + icon: BookOpen, + name: "التعليم والتدريب", + color: "text-emerald-500", + bg: "bg-emerald-500/10", + pain: "استفسارات عن جداول الدورات والأسعار تأخذ وقت طويل من خدمة العملاء.", + solution: "مستشار تعليمي آلي يجيب على شروط التسجيل، ويسجل الطلاب.", + stats: "الطلاب يتوقعون ردود فورية للتسجيل وإلا يذهبون لمعاهد أخرى.", + deckUrl: "#deck-education" + }, + { + icon: Activity, + name: "شركات التقنية والخدمات B2B", + color: "text-amber-500", + bg: "bg-amber-500/10", + pain: "دورة المبيعات طويلة جداً واجتماعات مع أشخاص غير مؤهلين.", + solution: "تأهيل صارم للعميل (BANT) قبل حجز أي الديمو.", + stats: "٥٠٪ من اجتماعات B2B تكون مع عملاء خارج نطاق الخدمة.", + deckUrl: "#deck-b2b" + } +]; + +export function PresentationsView() { + return ( +
+
+
+

📊 الترسانة القطاعية (Sector Sales Arsenal)

+

عروض تقديمية وملفات ROI مخصصة لكل قطاع تستخدمها للإغلاق السريع.

+
+
+ +
+ {SECTORS.map((sector, idx) => ( +
+
+
+ +
+

{sector.name}

+
+ +
+
+

نقاط الألم (Pain Points):

+

{sector.pain}

+
+ +
+

كيف نحل المشكلة (Dealix Solution):

+

{sector.solution}

+
+ +
+ إحصائية للإغلاق: + {sector.stats} +
+ + + + +
+
+ ))} +
+
+ ); +} diff --git a/salesflow-saas/frontend/src/components/dealix/scripts-view.tsx b/salesflow-saas/frontend/src/components/dealix/scripts-view.tsx new file mode 100644 index 00000000..eec14346 --- /dev/null +++ b/salesflow-saas/frontend/src/components/dealix/scripts-view.tsx @@ -0,0 +1,127 @@ +import { useState } from "react"; +import { Copy, CheckCircle2, ChevronDown, MessageCircle, Phone, FileText } from "lucide-react"; + +const SCRIPTS = { + "cold-call": { + title: "مكالمة باردة (Cold Call)", + icon: Phone, + color: "text-blue-500", + bg: "bg-blue-500/10", + script: `مرحباً [اسم العميل]، معك [اسمك] من منصة Dealix للذكاء الاصطناعي. + +أنا أتابع قطاع [قطاع العميل] وملاحظ إن التحدي الأكبر حالياً هو تسرب العملاء المحتملين وصعوبة الرد الفوري على كل الاستفسارات. + +نحن في Dealix طورنا "موظف ذكاء اصطناعي" بلهجة سعودية يتحدث مع عملائك 24/7، يفلترهم، ويحجز المواعيد لك مباشرة. + +هل عندك 3 دقائق الأسبوع القادم أوريك ديمو حي كيف ممكن نضاعف مبيعاتك؟` + }, + "whatsapp-intro": { + title: "تواصل واتساب (WhatsApp Intro)", + icon: MessageCircle, + color: "text-emerald-500", + bg: "bg-emerald-500/10", + script: `أهلاً [اسم العميل] 👋 +معك [اسمك] من شركة Dealix للذكاء الاصطناعي. + +بصفتك مدير في [قطاع العميل]، أكيد تعرف إن سرعة الرد تصنع فارق كبير في المبيعات. 🚀 +صممنا لك وكيل ذكاء اصطناعي بلهجتنا السعودية 🇸🇦 يرد، يقنع، ويحجز المواعيد 24/7. + +متى يناسبك أرسل لك رابط لتجربة النظام فعلياً؟ (التجربة مجانية)` + }, + "follow-up": { + title: "متابعة (Follow-up)", + icon: FileText, + color: "text-purple-500", + bg: "bg-purple-500/10", + script: `أهلاً [اسم العميل]، مساك الله بالخير. + +أتمنى تكون بخير. حبيت أذكرك بخصوص وكيل المبيعات الذكي من Dealix. +أرفقت لك ملف سريع يوضح كيف قدرنا نرفع مبيعات شركات في نفس مجالكم بنسبة 40% خلال أول شهر. + +هل تحب نحدد موعد سريع 10 دقائق نتناقش فيه؟` + }, + "objections": { + title: "الرد على الاعتراضات (Objections)", + icon: FileText, + color: "text-amber-500", + bg: "bg-amber-500/10", + script: `الاعتراض: "السعر غالي" +الرد: "أتفهم وجهة نظرك [اسم العميل]. لكن لو حسبناها، الموظف البشري يكلف راتب، تأمين، ومكتب، وإجازات، ولا يقدر يشتغل 24/7. نظام Dealix يشتغل بدون توقف وبجزء بسيط من هذي التكلفة. والأهم، عندنا (الضمان الذهبي)، إذا ما حققنا لك نتائج خلال 30 يوم نرجع فلوسك كاملة." + +الاعتراض: "الذكاء الاصطناعي يخوف/مو دقيق" +الرد: "صحيح البدايات كانت كذا، لكن وكلاء Dealix مدربين على منتجاتك فقط ولا يجاوبون من راسهم مطلقاً. والأهم إنهم مبرمجين يحولون المحادثة لموظف بشري فوراً إذا السؤال كان معقد."` + }, + "closing": { + title: "إغلاق البيعة (Closing)", + icon: CheckCircle2, + color: "text-rose-500", + bg: "bg-rose-500/10", + script: `ممتاز جداً [اسم العميل]. + +بما إن النظام ناسبك، كل اللي نحتاجه منك أرقام التواصل وروابط منتجاتكم عشان ندرب الوكيل عليها، وخلال 48 ساعة بيكون جاهز يشتغل لصالحك. + +أرسلت لك رابط الدفع مع عقد التشغيل اللي يضمن حقك الكامل بالاسترجاع خلال 30 يوم في حال ما شفت القيمة المضافة اللي وعدتك فيها. +مبروك مقدماً انضمامك لـ Dealix!` + } +}; + +export function ScriptsView() { + const [copied, setCopied] = useState(null); + + const copyToClipboard = (id: string, text: string) => { + navigator.clipboard.writeText(text); + setCopied(id); + setTimeout(() => setCopied(null), 2000); + }; + + return ( +
+
+
+

📞 ترسانة السكربتات (Sales Scripts)

+

نماذج ومسودات بيعية مثبتة الفعالية للمسوقين لضمان أعلى نسبة تحويل.

+
+
+ +
+ {Object.entries(SCRIPTS).map(([id, data]) => ( +
+
+
+
+ +
+

{data.title}

+
+
+ +
+
+ {data.script} +
+ + +
+
+ ))} +
+
+ ); +}