chore: snapshot Dealix salesflow phase2 for audit worktree

Made-with: Cursor
This commit is contained in:
Sami Assiri 2026-04-04 18:04:21 +03:00
parent 7cc4fafd3b
commit 378ea5f742
317 changed files with 20676 additions and 439 deletions

30
.cursorrules Normal file
View File

@ -0,0 +1,30 @@
# Cursor Project Rules - Saudi AI OS & Deals Ecosystem
Welcome to the **System Prompts & AI Tools** master workspace. This project contains multiple high-level AI modules and the core **Dealix (SalesFlow SaaS)** engine.
## 🚀 Project Overview
- **Location:** Saudi Arabia (B2B Market)
- **Primary Tech:** FastAPI (Backend), Next.js (Frontend), Python Agents (AI Core)
- **Main Goal:** Building a 24/7/365 Autonomous Sales Machine.
## 📁 Key Directories to Manage
- `/salesflow-saas`: The core SaaS platform (Frontend + Backend).
- `/ai-agents`: Specialized agents (Closer, Prospector, Analyst).
- `/personal-brand-engine`: Content and branding automation.
- `/Cursor Prompts`: Specific prompts for this IDE.
## 🧠 AI Behavior Rules
1. **Context Awareness:** Always index the entire codebase using `@Codebase`.
2. **Language:** When building business logic for SalesFlow, prioritize Arabic (Saudi) localized strings for the UI.
3. **Architecture:** Maintain strict separation between `salesflow-saas/backend` and `salesflow-saas/frontend`.
4. **Agent Logic:** When editing agents in `/ai-agents`, ensure they align with the `Dealix` backend models.
5. **Knowledge / RAG:** Do **not** add Onyx or any external third-party RAG assistant stack. Use Dealix-native retrieval only: `KnowledgeService`, `SectorAsset` / pgvector embeddings, and `app/ai/orchestrator` knowledge hooks.
## 🛠 Command Center (Composer Ctrl+I)
- Use **Composer** to perform tasks across directories.
- Example: "Create a new agent in `/ai-agents` and add a matching API endpoint in `salesflow-saas/backend`."
## 🇸🇦 Localization
- Business hours: Sunday - Thursday.
- Currency: SAR.
- Tone: Professional, Saudi Business.

57
.github/workflows/dealix-ci.yml vendored Normal file
View File

@ -0,0 +1,57 @@
# Runs when salesflow-saas/ changes (monorepo root)
name: Dealix CI
on:
push:
branches: [main]
paths:
- "salesflow-saas/**"
pull_request:
branches: [main]
paths:
- "salesflow-saas/**"
jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: salesflow-saas/backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install -r requirements.txt -r requirements-dev.txt
- name: Pytest (full suite + launch scenarios)
env:
DATABASE_URL: sqlite+aiosqlite:///./ci_dealix.db
DEALIX_INTERNAL_API_TOKEN: ""
run: python -m pytest tests -q --tb=line
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: salesflow-saas/frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: salesflow-saas/frontend/package-lock.json
- name: Install
run: npm ci
- name: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Install Playwright Chromium
run: npx playwright install --with-deps chromium
- name: E2E smoke (auth shell)
env:
CI: true
run: npm run test:e2e

View File

@ -0,0 +1,35 @@
# Dealix AI: The Marketer's Strategic Handbook 📈
## Empowering Our Partners to Scale KSA's B2B Revolution.
### 🌟 Why Dealix?
1. **34 specialized agents** - Not a simple chatbot; a full digital workforce.
2. **Super Engine V3** - Industry-leading lead extraction and social OSINT.
3. **Native Salesforce/Agentforce 360** integration.
4. **ZATCA & Saudi Data Law** compliant.
### 🤝 The Commission Structure
* **Tier 1 (Silver)**: 10% lifetime recurring commission (1-10 clients).
* **Tier 2 (Gold)**: 15% lifetime recurring commission (11-50 clients).
* **Tier 3 (Platinum)**: 25% lifetime recurring commission (50+ clients) + dedicated support.
### 💬 Handling Objections
* **"It's too expensive"**: "Compared to a single human sales manager costing 8,000 SAR/month, Dealix covers 10x the ground for a fraction of the cost."
* **"Is my data safe?"**: "All data is hosted securely, and we follow NDAs for all enterprise clients. We are built for the Saudi security standard."
* **"What if it makes a mistake?"**: "Dealix includes a 'Human-in-the-Loop' path. Critical deals always get human approval before sending."
---
# Dealix: The Future of Autonomous Revenue 🏛️
## General Company Profile (2026 Edition)
### 🌍 Vision:
To be the **Supreme Revenue Operating System** for every B2B enterprise in the Middle East, starting with Saudi Arabia.
### 🚀 Core Pillars:
1. **Precision Discovery**: Find the right person at the right time.
2. **Autonomous Outreach**: Multi-channel (Email, WhatsApp, LinkedIn, Voice).
3. **Smart Relationship Management**: Native sync with existing CRMs.
4. **Memory Layer (Mem0)**: Systems that learn from every deal, success, and failure.
---
**Dealix: Revenue, Redefined.**

View File

@ -31,8 +31,15 @@ REFRESH_TOKEN_EXPIRE_DAYS=7
# ── URLs ──────────────────────────────────────
API_URL=http://localhost:8000
FRONTEND_URL=http://localhost:3000
# Next.js dev: proxy /dealix-marketing → API (see next.config.js)
NEXT_PUBLIC_INTERNAL_API_URL=http://127.0.0.1:8000
NEXT_PUBLIC_API_URL=http://localhost:8000
WEBHOOK_BASE_URL=http://localhost:8000/api/v1/webhooks
# Marketing static files (empty = auto path to repo salesflow-saas). Docker: MARKETING_STATIC_ROOT=/salesflow
MARKETING_STATIC_ENABLED=true
MARKETING_STATIC_ROOT=
# ── LLM Providers (AI Engine) ─────────────────
# Primary: OpenAI
OPENAI_API_KEY=sk-your-openai-key

View File

@ -45,3 +45,11 @@ nginx/ssl/*.crt
htmlcov/
.coverage
coverage/
# Local SQLite / CI DB artifacts (never commit)
backend/*.db
backend/ci_*.db
# Playwright / E2E output
frontend/test-results/
frontend/playwright-report/

View File

@ -0,0 +1,96 @@
---
title: Dealix MASTER-BLUEPRINT
version: Legendary Complete Edition v4.0
status: canonical
---
# Vision
Autonomous Revenue & Operations OS: a **Digital Full-Company Team** for B2B enterprises — Prospecting → Qualification → Proposal → Negotiation → Closing → Post-Sale/Upsell → Support → Marketing → Billing/Finance → Analytics/Forecasting.
> *North star:* not a single chat widget — a **measurable revenue machine** that runs 24/7, self-improves where enabled, and grounds actions in CRM + tenant-safe knowledge.
## Auditable target metrics (directional)
| Axis | Target (vs baseline / program goals) |
|------|--------------------------------------|
| Revenue growth | 35× annual lift (customer-specific baselines) |
| Sales efficiency | 7080% reduction in manual pipeline work |
| Forecast quality | Strong 30-day horizon accuracy (data-dependent) |
| Cycle time | ~40% shorter close cycle where automation applies |
| CAC | ~31% lower acquisition cost via automation + routing |
| Compliance | PDPL-aligned handling; SOC2-ready logging & controls posture |
| Expansion | Multi-region / vertical over 1836 months |
**Targets:** 35× revenue lift, 7080% manual work reduction, measurable ROI from day one.
## Design principles (non-negotiable)
1. **Value-first** — Every feature ties to a measurable customer or ops outcome.
2. **Compliance-by-design** — Approvals, audit trails, data minimization; not bolt-on.
3. **Self-evolving** — Self-improvement loop + experiments behind flags (see OpenClaw flows).
4. **Complexity hidden, simplicity exposed** — Rich orchestration; simple UX (chat + dashboards).
5. **Measurable everything** — Executive ROI, per-tenant LLM cost where enabled, SLOs.
6. **Zero-trust between tenants** — Strict isolation; sensitive actions behind hooks/approvals.
## Roadmap phases (036 months)
| Phase | Window | Focus |
|-------|--------|--------|
| 0 — Foundation | 090 days | Production hardening, health checks, pilot, unified GTM assets |
| 1 — MVP revenue | Mo 23 | Deeper qualification, proposals, baseline ROI, pilot→paid |
| 2 — Scale | Mo 49 | Enterprise patterns, voice GA path, predictive layer, API surface |
| 3 — Dominate | Mo 1036 | Regions, partnerships, vertical OS variants |
Arabic narrative + detail: `docs/ULTIMATE_EXECUTION_MASTER_AR.md`.
# Core Stack (non-negotiable)
| Layer | Technology |
|-------|------------|
| Planning | **LangGraph** — graphs, subgraphs, checkpointing, time travel |
| Durable runtime | **OpenClaw 2026.4.2** — Durable Task Flow, revision tracking, child tasks, `before_agent_reply`, plugin boundaries |
| Memory | **Mem0** (scoped per agent/deal/customer/tenant) + **Letta** (tiered) + Claude 3-layer self-healing — persisted in flow state |
| LLM | **Claude Opus 4.6** primary; intelligent routing to GPT / Kimi / Copilot-class models |
| Experience UI | **Dealix Next.js** frontend — dashboards, landing, analytics; calls FastAPI (`/api/v1/*`) |
| CRM / Agents | **Salesforce Agentforce 360** — native + A2A + Voice |
| Channels | WhatsApp Cloud, Email (Resend/SendGrid), Stripe, Apollo, Gong, LinkedIn, Slack |
| Governance | Zero-trust AI, SOC2 posture, immutable audit logs, explainability, **HITL** for pricing/closing/contracts |
| Multi-tenant | White-label SaaS, strict tenant isolation, plugin marketplace |
*Onyx and similar external RAG/chat stacks are **out of scope** — not used and must not be added. Product RAG is **in-app** only: PostgreSQL + pgvector, `KnowledgeService`, sector assets, and orchestrator-grounded context.*
# Five Architecture Layers
1. **Experience** — Dealix web app, dashboards, mobile field API
2. **Orchestration** — LangGraph supervisors, OpenClaw durable flows
3. **Intelligence** — 40+ agents (swarm + reflection), signal selling, predictive revenue
4. **Data & Memory** — CRM grounding, Mem0/Letta, audit store
5. **Integration** — OpenClaw plugins (Salesforce, WhatsApp, Stripe, Voice, Contracts)
# Self-Improvement Loop v2.0 (6 phases)
Observation → Analysis → Hypothesis → Shadow experiments → Canary + revision tracking → Meta-learning.
Feeds: app analytics, LangSmith traces (when enabled), OpenClaw outcomes — improves prompts, tools, memory schemas, agent behavior.
# Salesforce (grounding)
- CRM data via Agentforce / REST APIs; permission-aware access in production.
- Qualification, Proposal, Support, and Analytics agents consume grounded CRM context.
# Repository mapping
- `openclaw/openclaw-config.yaml` — durable flows + hooks + plugins
- `backend/app/openclaw/` — runtime helpers, hooks, plugins
- `backend/app/api/v1/autonomous_foundation.py` — autonomous flows, go-live gate, connectivity tests
- `backend/app/services/knowledge_service.py` — in-app RAG (pgvector / sector assets)
- `backend/app/ai/orchestrator.py` — lead/message routing + knowledge context
- `backend/app/agents/` — LangGraph / master agent wiring (evolves per release)
- `docs/ULTIMATE_EXECUTION_MASTER_AR.md` — full Arabic execution narrative aligned to this blueprint
- `frontend/` — Dealix UI (Next.js)
# Agent / crew map (conceptual — implementation in `backend/app/agents`, flows, services)
Prospecting → Qualification → Proposal → Negotiation → Closing → Post-sale/Support. Each stage may map to supervisors + tool plugins (Salesforce, WhatsApp, Stripe, voice, contracts) within OpenClaw boundaries — **no external RAG SaaS as the system of record**; use native knowledge + CRM grounding.
*This file is the single source of truth for architecture intent; implementation evolves in code beside it.*

View File

@ -7,7 +7,7 @@ AI-powered revenue operations platform built for the Saudi market. Dealix combin
| Layer | Technology |
|-------|-----------|
| Backend | FastAPI (Python 3.11+) |
| Frontend | Next.js 14 (React, TypeScript) |
| Frontend | Next.js 15 (React, TypeScript) |
| Database | PostgreSQL 15 |
| Cache / Broker | Redis 7 |
| Task Queue | Celery 5 |
@ -26,6 +26,14 @@ docker-compose up --build
Backend: `http://localhost:8000/docs`
Frontend: `http://localhost:3000`
**Customer onboarding (B2B):** `GET /api/v1/customer-onboarding/journey` and `docs/CUSTOMER_OS_ONBOARDING_AR.md`. Dashboard tab: **مسار التشغيل مع العميل**.
**Launch verification:** see `docs/LAUNCH_CHECKLIST.md`. From `salesflow-saas`: copy `frontend/.env.example` to `frontend/.env.local` and set `NEXT_PUBLIC_API_URL`. Run `.\verify-launch.ps1 -HttpCheck -SoftReady` (use `-BaseUrl` if the API is not on port 8000).
**CI:** GitHub Actions workflow `.github/workflows/dealix-ci.yml` (repo root) runs backend `pytest` and frontend `lint` + `build` when `salesflow-saas/**` changes.
**DB migrations:** from `backend`, set `PYTHONPATH` to the backend folder (e.g. `set PYTHONPATH=%CD%` on Windows), then `alembic upgrade head`. For Postgres schema evolution, prefer `alembic revision --autogenerate` against a dev database after the baseline revision.
## Project Structure
```

View File

@ -0,0 +1,40 @@
# Customer Integration Concierge / وكيل نجاح ربط العميل (B2B)
## Role
وكيل ذكاء اصطناعي يصاحب **العميل المدفوع** و**فريقه التقني** خطوة بخطوة حتى اكتمال ربط Dealix: بيئة الإنتاج، المتغيرات، Salesforce، Stripe، الواتساب، Webhooks، والفحوص الآلية (go-live gate). يعمل كطبقة تفسير فوق وثائق المشروع ولا يستبدل مدير نجاح العميل البشري — يكمّله.
The agent explains **what** each step requires, **who** owns it at the customer, **what** to paste or configure next, and **how** to verify success. It escalates to human CSM when credentials are wrong repeatedly or policy blocks automation.
## Allowed Inputs
- **Tenant / project context**: company name, sector, environment (prod/staging)
- **Current step id** from `customer-onboarding/journey` (e.g. `s3_1`)
- **Go-live matrix snapshot** (optional): missing env vars, FAIL lines
- **User message**: Arabic or English question about DNS, Meta, Stripe, Salesforce, WhatsApp verify token
- **Last error** from API or connectivity-test JSON
## Allowed Outputs
- **Next step** recommendation with checklist in Arabic (primary)
- **Plain-language explanation** of env vars (names only in chat; never echo secrets)
- **Verification commands** (curl / PowerShell) without embedding real tokens
- **Escalation**: "contact Dealix CSM" when human access to Meta Business or Salesforce org is required
```json
{
"step_id": "s2_1",
"message_ar": "string",
"message_en": "string",
"customer_actions": ["string"],
"dealix_owner": "dealix_success | self-serve",
"verification_hint_ar": "string",
"escalate_to_human": false
}
```
## Rules
- Never print API keys, passwords, or webhook signing secrets.
- Prefer linking to internal docs paths: `docs/INTEGRATION_MASTER_AR.md`, go-live gate API.
- For WhatsApp: always mention `WHATSAPP_MOCK_MODE=false` for real sends and public HTTPS webhook URL.
- Align terminology with `GET /api/v1/customer-onboarding/journey`.
## Tone
Professional, calm, Saudi-market aware — **coach**, not alarmist.

View File

@ -0,0 +1,82 @@
# ==========================================
# Dealix — إنتاج شامل (بيع وتشغيل فعلي)
# انسخ إلى backend/.env وعبّئ كل البنود الإلزامية
# مرجع: docs/INTEGRATION_MASTER_AR.md
# ==========================================
ENVIRONMENT=production
# ---------- أمان ----------
SECRET_KEY=replace-with-long-random-string-min-32-chars
# ---------- عناوين عامة (HTTPS في الإنتاج) ----------
API_URL=https://api.yourdomain.com
FRONTEND_URL=https://app.yourdomain.com
# فاصلة بين النطاقات الإضافية لـ CORS (واجهات staging، إلخ)
# CORS_EXTRA_ORIGINS=https://staging.yourdomain.com
# في الإنتاج: عطّل وثائق OpenAPI إن رغبت
# EXPOSE_OPENAPI=false
# حماية اختيارية: يتطلب Authorization: Bearer <رمز> لمسارات /api/v1 (ما عدا health، webhooks، marketing، strategy، value-proposition)
# DEALIX_INTERNAL_API_TOKEN=
# ---------- قاعدة البيانات ----------
DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/salesflow
# ---------- ذكاء (واحد على الأقل) ----------
GROQ_API_KEY=
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
DEEPSEEK_API_KEY=
ZAI_API_KEY=
GOOGLE_API_KEY=
# ---------- بريد صادر (SendGrid أو SMTP) ----------
SENDGRID_API_KEY=
# أو:
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
# ---------- Salesforce Agentforce ----------
SALESFORCE_DOMAIN=login.salesforce.com
SALESFORCE_API_VERSION=v60.0
SALESFORCE_CLIENT_ID=
SALESFORCE_CLIENT_SECRET=
SALESFORCE_REFRESH_TOKEN=
SALESFORCE_ACCESS_TOKEN=
# ---------- WhatsApp Cloud ----------
WHATSAPP_MOCK_MODE=false
WHATSAPP_API_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_BUSINESS_ACCOUNT_ID=
WHATSAPP_VERIFY_TOKEN=
# Webhook URL: https://api.yourdomain.com/api/v1/... (حسب إعداد Meta)
# ---------- Stripe ----------
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# ---------- صوت Twilio ----------
VOICE_PROVIDER=twilio
TWILIO_ACCOUNT_SID=
TWILIO_AUTH_TOKEN=
TWILIO_FROM_NUMBER=
# ---------- توقيع إلكتروني (واحد على الأقل) ----------
DOCUSIGN_API_URL=https://demo.docusign.net/restapi
DOCUSIGN_ACCESS_TOKEN=
ADOBE_SIGN_API_URL=https://api.na1.adobesign.com/api/rest/v6
ADOBE_SIGN_ACCESS_TOKEN=
# ---------- اختياري ----------
HUBSPOT_API_KEY=
UNIFONIC_APP_SID=
RAPIDAPI_KEY=
# ---------- حلقة مستقلة ----------
SELF_IMPROVEMENT_INTERVAL_SECONDS=900
# ---------- فحص البوابة (لا تلصق الرابط وحده في PowerShell) ----------
# Invoke-RestMethod -Uri "https://api.../api/v1/autonomous-foundation/integrations/go-live-gate" -Method Get

View File

@ -0,0 +1,61 @@
import os
import sys
from pathlib import Path
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
_backend = Path(__file__).resolve().parents[1]
if str(_backend) not in sys.path:
sys.path.insert(0, str(_backend))
from app.database import Base
import app.models # noqa: F401 — register all models on Base.metadata
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def get_sync_url() -> str:
url = os.environ.get("DATABASE_URL", "")
if not url:
url = config.get_main_option("sqlalchemy.url", "")
if "+asyncpg" in url:
url = url.replace("postgresql+asyncpg", "postgresql", 1)
return url
def run_migrations_offline() -> None:
url = get_sync_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
cfg = config.get_section(config.config_ini_section) or {}
cfg["sqlalchemy.url"] = get_sync_url()
connectable = engine_from_config(
cfg,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,25 @@
"""Baseline schema — use `alembic revision --autogenerate` against Postgres for real migrations.
Revision ID: 20260403_0001
Revises:
Create Date: 2026-04-03
"""
from typing import Sequence, Union
from alembic import op
revision: str = "20260403_0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""No-op: dev SQLite often uses `init_db()`; production should autogenerate from models."""
pass
def downgrade() -> None:
pass

View File

@ -14,6 +14,14 @@ from typing import Dict, List, Optional, Tuple
import httpx
from app.agents.base_agent import BaseAgent, AgentPriority
try:
from app.agents.discovery.prospecting_crew import ProspectingCrewRunner
CREW_AVAILABLE = True
except ImportError:
CREW_AVAILABLE = False
from app.services.osint_service import osint_service
logger = logging.getLogger("dealix.engine.leads")
@ -223,10 +231,13 @@ class LeadEngine(BaseAgent):
description="محرك متعدد المصادر لاستخراج عملاء حقيقيين بأرقام متحققة"
)
self.verifier = PhoneVerifier()
self.crew_runner = ProspectingCrewRunner() if CREW_AVAILABLE else None
self.osint = osint_service
self.leads_db: Dict[str, Dict] = {}
self.stats = {
"total_discovered": 0, "verified_phones": 0,
"whatsapp_ready": 0, "emails_found": 0,
"social_signals": 0, "enterprise_leads": 0,
}
def get_capabilities(self) -> List[str]:
@ -294,11 +305,21 @@ class LeadEngine(BaseAgent):
lead["emails"] = enriched["emails"]
sources_used.append("website_scraping")
# Source 3: AI enrichment for each lead
for lead in all_leads[:5]:
enriched = await self._waterfall_enrich(lead)
lead.update(enriched)
# Source 3: Social OSINT (NEW V3)
logger.info("🔥 [SUPER ENGINE] Executing Deep Social OSINT...")
for lead in all_leads[:8]:
signals = await self.osint.get_total_signals(lead["name"])
if signals:
lead["social_signals"] = signals
self.stats["social_signals"] += len(signals)
# Source 4: Enterprise Directory Tracking (NEW V3)
if sector in ["it", "manufacturing", "logistics"]:
logger.info("🏢 [SUPER ENGINE] Tracking MISA/Enterprise Portals...")
enterprise_data = await self._source_enterprise_directory(sector, city)
all_leads.extend(enterprise_data)
self.stats["enterprise_leads"] += len(enterprise_data)
# Verify all phones
for lead in all_leads:
phone = lead.get("phone", "")
@ -417,6 +438,24 @@ class LeadEngine(BaseAgent):
return {"leads": leads, "source": "google_maps", "count": len(leads)}
async def _source_enterprise_directory(self, sector: str, city: str) -> List[Dict]:
"""Simulates crawling Saudi MISA (Ministry of Investment) and Chamber of Commerce."""
logger.info(f"📁 [SuperEngine] Crawling Industrial Directories for: {sector}")
# Mocking official commercial data
return [
{
"name": "Advanced Saudi Manufacturing Co",
"cr_number": "1010XXXXXX",
"phone": "966112233445",
"website": "www.asmc.com.sa",
"category": sector,
"city": city,
"is_enterprise": True,
"source": "MISA_Official",
"intent_signal": "Expansion to Riyadh South"
}
]
async def _source_website_scrape(self, task: Dict) -> Dict:
"""Scrape company website for contact info."""
url = task.get("url", "")
@ -463,8 +502,22 @@ class LeadEngine(BaseAgent):
return {"phones": [], "emails": [], "error": str(e)}
async def _waterfall_enrich(self, lead: Dict) -> Dict:
"""Waterfall enrichment — try multiple sources sequentially."""
enriched = await self.think_json(f"""أثري بيانات هذا العميل المحتمل باستخدام معرفتك:
"""Waterfall enrichment — try advanced CrewAI first, fallback to simpler prompt."""
# 1. Try Advanced CrewAI Enrichment
if self.crew_runner:
crew_result = self.crew_runner.run_enrichment(lead)
if crew_result and "crew_enrichment_error" not in crew_result:
# Still use Groq to parse structured data since Crew returns a text paragraph primarily
# But we insert the Crew context into the thinking
crew_insight = crew_result.get("personalized_opener", "")
prompt_context = f"\nاستخدم هذه الرؤى من فريق البحث (CrewAI): {crew_insight}\n"
else:
prompt_context = ""
else:
prompt_context = ""
# 2. Legacy / Structured LLM parse
enriched = await self.think_json(f"""أثري بيانات هذا العميل المحتمل باستخدام معرفتك:{prompt_context}
الاسم: {lead.get('name', '')}
القطاع: {lead.get('category', lead.get('sector', ''))}
المدينة: {lead.get('city', '')}
@ -484,6 +537,10 @@ class LeadEngine(BaseAgent):
"best_outreach_time": "",
"personalized_opener": ""}}""", task_type="enrichment")
# Override personalized opener with CrewAI's superior version if available
if self.crew_runner and prompt_context and enriched:
enriched["personalized_opener"] = crew_insight
return enriched
def _calculate_lead_score(self, lead: Dict) -> int:

View File

@ -0,0 +1,133 @@
import os
import json
import logging
from typing import Dict, Any
try:
from crewai import Agent, Task, Crew, Process
from langchain_anthropic import ChatAnthropic
CREWAI_AVAILABLE = True
except ImportError:
CREWAI_AVAILABLE = False
from app.agents.memory_layer import empire_memory
logger = logging.getLogger("dealix.prospecting_crew")
class ProspectingCrewRunner:
"""
Layer 2: Specialized CrewAI Squad for deeply researching and qualifying leads.
"""
def __init__(self):
self.llm = self._init_llm()
def _init_llm(self):
if not CREWAI_AVAILABLE:
return None
# Default to Claude 3 Haiku for speed/cost, Opus/Sonnet if needed for complexity
api_key = os.getenv("ANTHROPIC_API_KEY", "")
if not api_key:
# Fallback to GROQ if Anthropic is not available, though langchain_anthropic specifically requires Anthropic
# We will return None and the Crew will fail gracefully or we handle it.
return None
try:
return ChatAnthropic(
model="claude-3-haiku-20240307",
temperature=0.4,
anthropic_api_key=api_key
)
except Exception as e:
logger.error(f"Failed to initialize ChatAnthropic: {e}")
return None
def run_enrichment(self, lead_data: Dict) -> Dict:
"""
Executes the Crew to enrich a lead.
"""
company_name = lead_data.get("name", "Unknown Company")
sector = lead_data.get("category", "")
city = lead_data.get("city", "")
if not CREWAI_AVAILABLE or not self.llm:
logger.warning("CrewAI or Anthropic LLM not available. Returning empty enrichment.")
return {}
# 1. Inject Mem0 Self-Healing Memory
mem_context = empire_memory.get_context(company_name, context_type="discovery")
# 2. Define Agents
intent_detector = Agent(
role="Enterprise Intent Detector",
goal=f"Analyze intent signals for {company_name} to see if they are ready to buy AI automation services.",
backstory="You are a brilliant data analyst who spots buying signals across public footprints.",
verbose=False,
allow_delegation=False,
llm=self.llm
)
researcher = Agent(
role="Enterprise Account Researcher",
goal=f"Deeply research {company_name} (Sector: {sector}, City: {city}) and extract key pain points and budget markers.",
backstory="You are an elite B2B SDR with a 99% accuracy rate. You look for inefficiencies.",
verbose=False,
allow_delegation=False,
llm=self.llm
)
personalizer = Agent(
role="Multi-Channel Personalizer",
goal="Craft the perfect outreach strategy and opener based on research and existing memory context.",
backstory="You are a persuasive copywriter mastering enterprise psychology. You write to C-levels.",
verbose=False,
allow_delegation=False,
llm=self.llm
)
# 3. Define Tasks
task1 = Task(
description=f"Analyze {company_name}. Is there growth or struggle? Return a short summary.",
agent=intent_detector,
expected_output="A short paragraph on company growth and potential intent."
)
task2 = Task(
description=f"Extract pain points for {company_name} given previous insights. Memory Context: {mem_context}",
agent=researcher,
expected_output="A bulleted list of 3 major pain points assuming they lack AI automation.",
context=[task1]
)
task3 = Task(
description=f"Write a highly personalized 2-sentence opener for WhatsApp/Email addressed to the CEO of {company_name}. Use the pain points extracted.",
agent=personalizer,
expected_output="A compelling personalized message opener.",
context=[task2]
)
# 4. Run the Crew
try:
crew = Crew(
agents=[intent_detector, researcher, personalizer],
tasks=[task1, task2, task3],
process=Process.sequential
)
result = crew.kickoff()
final_output = str(result)
# Store in Episodic Memory
empire_memory.add_insight(
company_name=company_name,
insight=f"Generated Personalized Opener: {final_output}"
)
return {
"personalized_opener": final_output,
"crew_enrichment_success": True
}
except Exception as e:
logger.error(f"CrewAI execution failed: {e}")
return {"crew_enrichment_error": str(e)}

View File

@ -17,6 +17,20 @@ import logging
from datetime import datetime, timezone
from typing import Dict, List
try:
from app.agents.master_langgraph import CEOLangGraphOrchestrator, build_ceo_deal_state
LANGGRAPH_MASTER_AVAILABLE = True
except ImportError:
CEOLangGraphOrchestrator = None # type: ignore[misc, assignment]
build_ceo_deal_state = None # type: ignore[misc, assignment]
LANGGRAPH_MASTER_AVAILABLE = False
try:
from app.services.salesforce_agentforce import agentforce_service
except ImportError:
agentforce_service = None
from app.agents.base_agent import BaseAgent, AgentPriority, get_message_bus
logger = logging.getLogger("dealix.agents.ceo")
@ -55,6 +69,11 @@ class CEOAgent(BaseAgent):
"message_style": "ceo_personal",
"budget_mode": "free_tier",
}
self.orchestrator = (
CEOLangGraphOrchestrator()
if LANGGRAPH_MASTER_AVAILABLE and CEOLangGraphOrchestrator
else None
)
def get_capabilities(self) -> List[str]:
return [
@ -66,6 +85,7 @@ class CEOAgent(BaseAgent):
"تحسين الاستراتيجية باستمرار",
"إرسال تقرير يومي للمدير التنفيذي",
"التعلم من النتائج والتكيّف",
"دورة صفقة كاملة عبر LangGraph (استكشاف، بوابة استراتيجية، امتثال، HITL، تفاعل)",
]
async def execute(self, task: Dict) -> Dict:
@ -77,6 +97,8 @@ class CEOAgent(BaseAgent):
return await self.morning_operations()
elif action == "afternoon_operations":
return await self.afternoon_operations()
elif action == "langgraph_deal_cycle":
return await self.run_langgraph_deal_cycle(task.get("deal_state", {}))
elif action == "evening_report":
return await self.evening_report()
elif action == "optimize_strategy":
@ -124,6 +146,34 @@ class CEOAgent(BaseAgent):
return results
async def run_langgraph_deal_cycle(self, initial_state: Dict) -> Dict:
"""Run a single deal through LangGraph (async ainvoke — correct for async nodes)."""
if not self.orchestrator or not build_ceo_deal_state:
return {"error": "LangGraph Orchestrator is not available."}
logger.info(
"🔄 [%s] LangGraph deal cycle start: %s",
self.name,
initial_state.get("company_name"),
)
seed = {
"tenant_id": initial_state.get("tenant_id", "default_tenant"),
"deal_id": initial_state.get("deal_id", "DEAL-001"),
"company_name": initial_state.get("company_name", "Unknown"),
"decision_maker": initial_state.get("decision_maker", "CEO"),
"industry": initial_state.get("industry", "enterprise"),
"city": initial_state.get("city", "Riyadh"),
}
result = await self.orchestrator.run_deal_cycle_async(build_ceo_deal_state(seed))
if agentforce_service and "error" not in result:
try:
await agentforce_service.sync_deal(result)
except Exception as e:
logger.warning("Salesforce sync after LangGraph skipped: %s", e)
return result
async def morning_operations(self) -> Dict:
"""06:00-12:00: Discovery + Campaign Launch."""
logger.info(f"☀️ [{self.name}] Morning operations starting")

View File

@ -0,0 +1,342 @@
"""
CEO deal orchestration LangGraph (async-first, merge-safe state).
Uses Annotated reducers so history_log appends across nodes instead of being
clobbered. Async nodes require graph.ainvoke (not invoke).
"""
from __future__ import annotations
import asyncio
import logging
import operator
from typing import Annotated, Any, Dict, List, Optional, TypedDict
try:
from langgraph.graph import END, StateGraph
LANGGRAPH_AVAILABLE = True
except ImportError:
END = None # type: ignore[misc, assignment]
StateGraph = None # type: ignore[misc, assignment]
LANGGRAPH_AVAILABLE = False
try:
from langchain_anthropic import ChatAnthropic
ANTHROPIC_CHAT_AVAILABLE = True
except ImportError:
ChatAnthropic = None # type: ignore[misc, assignment]
ANTHROPIC_CHAT_AVAILABLE = False
import os
from app.flows.self_improvement_flow import self_improvement_flow
try:
from app.agents.discovery.prospecting_crew import ProspectingCrewRunner
CREW_AVAILABLE = True
except ImportError:
CREW_AVAILABLE = False
logger = logging.getLogger("dealix.ceo.langgraph")
GRAPH_VERSION = "2.2.0"
class CEOState(TypedDict):
tenant_id: str
deal_id: str
company_name: str
decision_maker: str
industry: str
city: str
deal_stage: str
intent_score: float
next_action_payload: str
compliance_approved: bool
human_intervention_required: bool
email_sent: bool
linkedin_sent: bool
osint_signals: List[Any]
strategic_tier: str
history_log: Annotated[List[str], operator.add]
def build_ceo_deal_state(overrides: Optional[Dict[str, Any]] = None) -> CEOState:
"""Canonical initial state for a deal cycle (all keys LangGraph expects)."""
base: CEOState = {
"tenant_id": "default_tenant",
"deal_id": "DEAL-001",
"company_name": "Unknown",
"decision_maker": "CEO",
"industry": "enterprise",
"city": "Riyadh",
"deal_stage": "PROSPECTING",
"intent_score": 0.0,
"next_action_payload": "",
"compliance_approved": False,
"human_intervention_required": False,
"email_sent": False,
"linkedin_sent": False,
"osint_signals": [],
"strategic_tier": "",
"history_log": ["Deal initialized."],
}
if overrides:
for k, v in overrides.items():
if k in base:
base[k] = v # type: ignore[literal-required]
return base
class CEOLangGraphOrchestrator:
"""
Layer 1: Master orchestration LangGraph DAG with compliance, HITL, outreach,
CRM sync, and self-improvement tail.
"""
def __init__(self) -> None:
self.llm = None
self.crew_runner = None
self.graph = None
if LANGGRAPH_AVAILABLE:
self.llm = self._init_llm()
self.crew_runner = ProspectingCrewRunner() if CREW_AVAILABLE else None
self.graph = self._build_graph()
def _init_llm(self):
if not ANTHROPIC_CHAT_AVAILABLE or not ChatAnthropic:
return None
api_key = os.getenv("ANTHROPIC_API_KEY", "")
if not api_key:
return None
return ChatAnthropic(
model="claude-3-opus-20240229",
temperature=0.7,
anthropic_api_key=api_key,
)
async def prospecting_node(self, state: CEOState) -> Dict[str, Any]:
from app.agents.discovery.lead_engine import LeadEngine
company = state["company_name"]
logger.info("LangGraph node: prospecting for %s", company)
try:
engine = LeadEngine()
discovery_task = {
"action": "discover",
"sector": state.get("industry", "enterprise"),
"city": state.get("city", "Riyadh"),
"lead_name": company,
}
result = await engine.execute(discovery_task)
leads = result.get("leads") or []
matching = next((l for l in leads if l.get("name") == company), None)
if matching:
return {
"osint_signals": matching.get("social_signals") or [],
"intent_score": float(matching.get("discovery_score", 70.0)),
"next_action_payload": matching.get("personalized_opener") or "Ready to scale.",
"deal_stage": "QUALIFIED",
"history_log": [
f"Prospecting OK: {len(matching.get('social_signals') or [])} signals for {company}."
],
}
return {
"osint_signals": [],
"intent_score": 50.0,
"next_action_payload": f"Standard B2B outreach for {company}",
"deal_stage": "QUALIFIED",
"history_log": [f"Prospecting: no exact lead match; defaulting intent for {company}."],
}
except Exception as e:
logger.exception("prospecting_node failed")
return {
"osint_signals": [],
"intent_score": 45.0,
"next_action_payload": f"Fallback outreach plan for {company}",
"deal_stage": "QUALIFIED",
"history_log": [f"Prospecting error (continuing): {e}"],
}
def strategic_gate_node(self, state: CEOState) -> Dict[str, Any]:
score = float(state.get("intent_score") or 0.0)
if score < 35:
tier = "nurture"
elif score < 72:
tier = "engage"
else:
tier = "accelerate"
return {
"strategic_tier": tier,
"history_log": [f"Strategic gate: tier={tier} (intent={score:.1f})."],
}
def compliance_node(self, state: CEOState) -> Dict[str, Any]:
logger.info("LangGraph node: compliance")
payload = (state.get("next_action_payload") or "").lower()
blocked = any(
x in payload
for x in (
"free forever",
"guaranteed 100%",
"guaranteed win",
"unlimited money back",
)
)
if blocked:
return {
"compliance_approved": False,
"history_log": ["Compliance blocked: unauthorized claims in payload."],
}
return {
"compliance_approved": True,
"history_log": ["Compliance approved."],
}
def human_handoff_node(self, state: CEOState) -> Dict[str, Any]:
logger.info("LangGraph node: human handoff routing")
score = float(state.get("intent_score") or 0.0)
need_hitl = not state.get("compliance_approved", False) or score > 90.0
if need_hitl:
return {
"human_intervention_required": True,
"history_log": ["Human handoff: compliance block or very high intent (>90)."],
}
return {
"human_intervention_required": False,
"history_log": ["Human handoff: auto-proceed to outreach."],
}
def email_outreach_node(self, state: CEOState) -> Dict[str, Any]:
from app.services.email_service import email_service
company = state["company_name"]
logger.info("LangGraph node: email outreach -> %s", company)
try:
email_service.send_outreach_email(company)
return {"email_sent": True, "history_log": ["Email outreach executed."]}
except Exception as e:
logger.exception("email_outreach_node")
return {"email_sent": False, "history_log": [f"Email outreach failed: {e}"]}
def linkedin_outreach_node(self, state: CEOState) -> Dict[str, Any]:
from app.services.linkedin_service import linkedin_service
company = state["company_name"]
logger.info("LangGraph node: linkedin outreach -> %s", company)
try:
linkedin_service.send_connection_request(company)
return {"linkedin_sent": True, "history_log": ["LinkedIn connection request sent."]}
except Exception as e:
logger.exception("linkedin_outreach_node")
return {"linkedin_sent": False, "history_log": [f"LinkedIn outreach failed: {e}"]}
def sync_salesforce_node(self, state: CEOState) -> Dict[str, Any]:
logger.info("LangGraph node: salesforce sync (stub/log)")
return {"history_log": ["Synced to Salesforce Agentforce (log)."]}
def self_improve_node(self, state: CEOState) -> Dict[str, Any]:
try:
result = self_improvement_flow.run(
tenant_id=state.get("tenant_id", "default_tenant"),
input_state={
"signals": state.get("osint_signals", []),
"bottlenecks": [],
"experiments": [{"name": "subject-line-ab", "channel": "email"}],
"ab_results": {"winner": "variant_b"},
"governance_passed": True,
"promoted": True,
},
)
rid = result.get("run_id", "n/a")
return {"history_log": [f"Self-improve loop completed: run_id={rid}"]}
except Exception as e:
logger.exception("self_improve_node")
return {"history_log": [f"Self-improve loop error: {e}"]}
def _build_graph(self):
workflow = StateGraph(CEOState)
workflow.add_node("prospecting", self.prospecting_node)
workflow.add_node("strategic_gate", self.strategic_gate_node)
workflow.add_node("compliance", self.compliance_node)
workflow.add_node("human_handoff", self.human_handoff_node)
workflow.add_node("email_outreach", self.email_outreach_node)
workflow.add_node("linkedin_outreach", self.linkedin_outreach_node)
workflow.add_node("salesforce_sync", self.sync_salesforce_node)
workflow.add_node("self_improve", self.self_improve_node)
workflow.set_entry_point("prospecting")
workflow.add_edge("prospecting", "strategic_gate")
workflow.add_edge("strategic_gate", "compliance")
workflow.add_edge("compliance", "human_handoff")
def routing_logic(state: CEOState) -> str:
return "END" if state.get("human_intervention_required") else "outreach"
workflow.add_conditional_edges(
"human_handoff",
routing_logic,
{"outreach": "email_outreach", "END": END},
)
workflow.add_edge("email_outreach", "linkedin_outreach")
workflow.add_edge("linkedin_outreach", "salesforce_sync")
workflow.add_edge("salesforce_sync", "self_improve")
workflow.add_edge("self_improve", END)
return workflow.compile()
async def run_deal_cycle_async(self, initial_state: CEOState) -> Dict[str, Any]:
"""Execute full deal DAG (must use this from async code paths)."""
if not self.graph:
return {
"error": "LangGraph not available. Install langgraph and ensure imports succeed.",
"graph_engine": "none",
}
try:
final = await self.graph.ainvoke(initial_state)
out = dict(final) if isinstance(final, dict) else {"raw": final}
out["graph_engine"] = "langgraph"
out["graph_version"] = GRAPH_VERSION
return out
except Exception as e:
logger.exception("run_deal_cycle_async failed")
return {"error": str(e), "graph_engine": "langgraph", "graph_version": GRAPH_VERSION}
def run_deal_cycle(self, initial_state: CEOState) -> Dict[str, Any]:
"""Sync wrapper for CLI/tests only — uses asyncio.run when no loop is running."""
if not self.graph:
return {
"error": "LangGraph not available. Install langgraph and ensure imports succeed.",
"graph_engine": "none",
}
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(self.run_deal_cycle_async(initial_state))
raise RuntimeError(
"run_deal_cycle() cannot be used inside a running event loop; "
"await run_deal_cycle_async() instead."
)
def describe(self) -> Dict[str, Any]:
return {
"graph_version": GRAPH_VERSION,
"langgraph_available": LANGGRAPH_AVAILABLE,
"graph_compiled": self.graph is not None,
"anthropic_llm_configured": self.llm is not None,
"prospecting_crew": self.crew_runner is not None,
"nodes": [
"prospecting",
"strategic_gate",
"compliance",
"human_handoff",
"email_outreach",
"linkedin_outreach",
"salesforce_sync",
"self_improve",
],
}

View File

@ -0,0 +1,76 @@
import os
from typing import Dict, List, Any, Optional
import json
try:
from mem0 import Memory
except ImportError:
# Fallback mock for testing environments where mem0ai isn't available yet
class Memory:
def __init__(self, config=None):
self.store = []
def search(self, query: str, user_id: str, **kwargs):
return [{"text": "Mocked memory context."}]
def add(self, text: str, user_id: str, metadata: dict = None, **kwargs):
self.store.append({"text": text, "user_id": user_id, "metadata": metadata})
class SelfHealingMemory:
"""
Layer 3: Centralized Self-Healing Memory using Mem0 AI.
Provides episodic long-term memory for CrewAI Agents across the Dealix OS.
"""
def __init__(self, namespace="dealix_org"):
self.namespace = namespace
# Use simple configuration. Can be enhanced with Qdrant later.
self.config = {
"llm": {
"provider": "anthropic",
"config": {
"model": "claude-3-haiku-20240307",
"temperature": 0.1
}
}
}
self.memory = Memory(config=self.config)
def get_context(self, company_name: str, context_type: str = "general") -> str:
"""
Retrieves context about a company to inject into agent prompts.
"""
query = f"Provide all known {context_type} information regarding the company: {company_name}"
records = self.memory.search(query=query, user_id=self.namespace)
# Consolidate strings
if not records:
return "No prior context discovered in episodic memory."
context_str = "Memory Context:\n"
for rec in records:
# Mem0 records structure depends on version, usually contains 'memory' or 'text'
text_val = rec.get('text') or rec.get('memory') or str(rec)
context_str += f"- {text_val}\n"
return context_str
def add_insight(self, company_name: str, insight: str, tag: str = "discovery"):
"""
Stores episodic agent findings.
"""
metadata = {
"company": company_name,
"tag": tag,
"source": "autonomous_os"
}
self.memory.add(text=insight, user_id=self.namespace, metadata=metadata)
def consolidate(self, company_name: str):
"""
To be implemented by the Knowledge Distiller Node:
Cleans up redundant memories.
"""
pass
# Singleton instance for agents
empire_memory = SelfHealingMemory()

View File

@ -1,6 +1,6 @@
"""
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.
Agents are defined in ai-agents/prompts/ with a .md prompt file.
"""
import json
@ -22,7 +22,7 @@ settings = get_settings()
class AgentExecutor:
"""
Executes any of the 18 Dealix AI agents.
Executes Dealix AI agents registered in AGENT_REGISTRY.
Each agent has:
- A system prompt (from ai-agents/prompts/*.md)
@ -48,6 +48,11 @@ class AgentExecutor:
"description": "Guide new affiliates through onboarding",
"model_preference": "groq", # speed matters
},
"integration_concierge": {
"prompt_file": "customer-integration-concierge.md",
"description": "B2B customer integration and go-live step-by-step coach",
"model_preference": "groq",
},
"outreach_writer": {
"prompt_file": "outreach-message-writer.md",
"description": "Draft personalized outreach messages",

View File

@ -1,14 +1,43 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from uuid import UUID
from app.database import get_db
from app.database import IS_SQLITE, get_db
from app.utils.security import decode_token
from app.models.user import User
from app.models.tenant import Tenant
security = HTTPBearer()
optional_security = HTTPBearer(auto_error=False)
def _user_id_clause(user_id: str):
"""SQLite stores UUID PKs as str(36); Postgres uses native UUID — compare accordingly."""
uid = str(user_id)
return User.id == uid if IS_SQLITE else User.id == UUID(uid)
def _tenant_id_clause(tenant_id):
tid = str(tenant_id)
return Tenant.id == tid if IS_SQLITE else Tenant.id == UUID(tid)
async def get_optional_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(optional_security),
db: AsyncSession = Depends(get_db),
) -> Optional[User]:
if credentials is None:
return None
payload = decode_token(credentials.credentials)
if not payload or payload.get("type") != "access":
return None
user_id = payload.get("sub")
if not user_id:
return None
result = await db.execute(select(User).where(_user_id_clause(user_id), User.is_active == True))
return result.scalar_one_or_none()
async def get_current_user(
@ -23,7 +52,7 @@ async def get_current_user(
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token payload")
result = await db.execute(select(User).where(User.id == UUID(user_id), User.is_active == True))
result = await db.execute(select(User).where(_user_id_clause(user_id), User.is_active == True))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
@ -35,7 +64,9 @@ async def get_current_tenant(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Tenant:
result = await db.execute(select(Tenant).where(Tenant.id == current_user.tenant_id, Tenant.is_active == True))
result = await db.execute(
select(Tenant).where(_tenant_id_clause(current_user.tenant_id), Tenant.is_active == True)
)
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tenant not found or inactive")

View File

@ -1,9 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy import select
from typing import Optional
from datetime import datetime, timezone
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from uuid import UUID
import uuid
@ -36,8 +36,7 @@ class AffiliateResponse(BaseModel):
total_commission_earned: float
current_month_deals: int
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class AffiliateDealRequest(BaseModel):
@ -106,6 +105,45 @@ async def register_affiliate(data: AffiliateRegisterRequest, db: AsyncSession =
return affiliate
@router.get("/program")
async def affiliate_program_public():
"""رحلة المسوق + شرائح العمولة — للواجهة والتسويق (بدون DB)."""
return {
"title_ar": "برنامج الشراكة Dealix",
"journey_ar": [
{"step": 1, "title": "التسجيل", "detail_ar": "بياناتك ورمز إحالة فريد خلال دقائق."},
{"step": 2, "title": "التفعيل", "detail_ar": "موافقة فريقنا ثم تفعيل الحساب وربط القنوات."},
{"step": 3, "title": "أول عميل", "detail_ar": "مشاركة الرابط أو تسجيل صفقة من اللوحة."},
{"step": 4, "title": "تتبع العمولة", "detail_ar": "شفافية من الصفقة حتى الاعتماد والدفع."},
{"step": 5, "title": "نمو وترقية", "detail_ar": "مكافآت إضافية ومسار توظيف عند الأداء العالي."},
],
"commission_rates": COMMISSION_RATES,
"bonus_tiers": BONUS_TIERS,
"auto_employ_rule_ar": "عند 10 صفقات مُسجّلة في الشهر والحالة نشط — يُقيَّد كمرشح توظيف تلقائي.",
}
@router.get("/leaderboard/top")
async def get_leaderboard(limit: int = 10, db: AsyncSession = Depends(get_db)):
"""Get top performing affiliates."""
result = await db.execute(
select(AffiliateMarketer)
.where(AffiliateMarketer.status.in_([AffiliateStatus.ACTIVE, AffiliateStatus.EMPLOYED]))
.order_by(AffiliateMarketer.total_deals_closed.desc())
.limit(limit)
)
affiliates = result.scalars().all()
return [
{
"name": a.full_name_ar or a.full_name,
"deals": a.total_deals_closed,
"commission": a.total_commission_earned,
"status": a.status.value,
}
for a in affiliates
]
@router.get("/{affiliate_id}", response_model=AffiliateResponse)
async def get_affiliate(affiliate_id: UUID, db: AsyncSession = Depends(get_db)):
"""Get affiliate details."""
@ -191,24 +229,3 @@ async def get_performance(affiliate_id: UUID, db: AsyncSession = Depends(get_db)
.order_by(AffiliatePerformance.month.desc())
)
return result.scalars().all()
@router.get("/leaderboard/top")
async def get_leaderboard(limit: int = 10, db: AsyncSession = Depends(get_db)):
"""Get top performing affiliates."""
result = await db.execute(
select(AffiliateMarketer)
.where(AffiliateMarketer.status.in_([AffiliateStatus.ACTIVE, AffiliateStatus.EMPLOYED]))
.order_by(AffiliateMarketer.total_deals_closed.desc())
.limit(limit)
)
affiliates = result.scalars().all()
return [
{
"name": a.full_name,
"deals": a.total_deals_closed,
"commission": a.total_commission_earned,
"status": a.status.value,
}
for a in affiliates
]

View File

@ -37,6 +37,15 @@ class AnalyzeRequest(BaseModel):
lead: Dict = {}
class LangGraphDealCycleRequest(BaseModel):
company_name: str = Field(..., min_length=1, description="Target company for the deal cycle")
deal_id: str = Field("DEAL-LG-001")
tenant_id: str = Field("default_tenant")
decision_maker: str = Field("CEO")
industry: str = Field("enterprise")
city: str = Field("Riyadh")
# ═══ Empire Status ═════════════════════════════════════════
@router.get("/empire/status")
@ -275,6 +284,62 @@ async def forecast_revenue(pipeline_data: Dict = {}):
# ═══ CEO Agent Operations ════════════════════════════════
@router.get("/langgraph/health")
async def langgraph_orchestrator_health():
"""LangGraph compiler status — for launch checks and ops dashboards."""
try:
from app.agents import get_agent_system
from app.agents.master_langgraph import CEOLangGraphOrchestrator, GRAPH_VERSION, LANGGRAPH_AVAILABLE
bus = get_agent_system()
ceo = bus.get_agent("ceo_agent")
orch = getattr(ceo, "orchestrator", None) if ceo else None
if orch is not None:
detail = orch.describe()
else:
detail = CEOLangGraphOrchestrator().describe()
detail["langgraph_import_ok"] = LANGGRAPH_AVAILABLE
detail["graph_version_constant"] = GRAPH_VERSION
return detail
except Exception as e:
logger.exception("langgraph health")
return {"error": str(e), "langgraph_import_ok": False}
@router.post("/ceo/langgraph-deal-cycle")
async def ceo_langgraph_deal_cycle(body: LangGraphDealCycleRequest):
"""Run one full CEO deal DAG (prospecting → gate → compliance → HITL → outreach → self-improve)."""
try:
from app.agents import get_agent_system
bus = get_agent_system()
ceo = bus.get_agent("ceo_agent")
if not ceo:
raise HTTPException(status_code=500, detail="CEO Agent not available")
wrapped = await ceo.run(
{
"action": "langgraph_deal_cycle",
"deal_state": body.model_dump(),
}
)
if wrapped.get("status") != "success":
raise HTTPException(
status_code=500,
detail=wrapped.get("error") or wrapped.get("result") or str(wrapped),
)
result = wrapped.get("result", wrapped)
if isinstance(result, dict) and result.get("error"):
raise HTTPException(status_code=500, detail=str(result["error"]))
return result
except HTTPException:
raise
except Exception as e:
logger.exception("langgraph deal cycle")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/ceo/daily-cycle")
async def run_daily_cycle(background_tasks: BackgroundTasks):
"""Trigger the CEO Agent's full daily autonomous cycle."""

View File

@ -0,0 +1,282 @@
from __future__ import annotations
from typing import Any, Dict, List
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from app.flows.prospecting_durable_flow import prospecting_durable_flow
from app.flows.self_improvement_flow import self_improvement_flow
from app.services.contract_intelligence_service import contract_intelligence_service
from app.services.executive_roi_service import executive_roi_service
from app.services.predictive_revenue_service import predictive_revenue_service
from app.openclaw.plugins.salesforce_agentforce_plugin import SalesforceAgentforcePlugin
from app.openclaw.plugins.whatsapp_plugin import WhatsAppCloudPlugin
from app.openclaw.plugins.stripe_plugin import StripeBillingPlugin
from app.openclaw.plugins.voice_plugin import VoiceAgentsPlugin
from app.openclaw.plugins.contract_intelligence_plugin import ContractIntelligencePlugin
from app.config import get_settings
from app.services.go_live_matrix import build_matrix_report
router = APIRouter(prefix="/autonomous-foundation", tags=["Autonomous Foundation"])
settings = get_settings()
class DealPayload(BaseModel):
tenant_id: str = "default_tenant"
deal: Dict[str, Any] = Field(default_factory=dict)
class ROIRequest(BaseModel):
baseline: Dict[str, Any] = Field(default_factory=dict)
current: Dict[str, Any] = Field(default_factory=dict)
class PredictiveRequest(BaseModel):
pipeline: List[Dict[str, Any]] = Field(default_factory=list)
accounts: List[Dict[str, Any]] = Field(default_factory=list)
metrics: Dict[str, Any] = Field(default_factory=dict)
class MobileActionRequest(BaseModel):
tenant_id: str = "default_tenant"
rep_id: str
action: str
payload: Dict[str, Any] = Field(default_factory=dict)
class ConnectivityRequest(BaseModel):
tenant_id: str = "default_tenant"
company_name: str = "Dealix Test Account"
phone: str = "966500000000"
customer_id: str = "cus_test"
amount_sar: int = 10
def build_go_live_readiness_report() -> Dict[str, Any]:
"""
Full commercial go-live: blocking checks across security, data, LLM, email, CRM,
WhatsApp (incl. webhook verify + live mode), Stripe (incl. webhook secret), voice, e-sign.
"""
matrix = build_matrix_report(settings)
checks: Dict[str, str] = matrix["checks"]
passed = matrix["passed_count"]
total = matrix["total_count"]
readiness_percent = matrix["readiness_percent"]
launch_allowed = matrix["launch_allowed"]
missing_rows = matrix["missing"]
missing_legacy: List[Dict[str, str]] = [
{"check_id": m["check_id"], "env_var": m["env_var"], "hint": m["hint"]}
for m in missing_rows
]
blocked_reasons = [
f"غير مُعرّف أو غير صالح: {item['env_var']} ({item['check_id']})"
for item in missing_legacy
]
if launch_allowed:
summary_ar = (
"جميع فحوص الإطلاق التجاري (الإلزامية) ناجحة — يمكن البيع والتشغيل الفعلي عند اكتمال الاختبارات اليدوية."
)
summary_en = "All blocking commercial checks passed — ready for paid rollout after manual smoke tests."
else:
summary_ar = (
f"الإطلاق التجاري ممنوع: الجاهزية الإلزامية {readiness_percent}% ({passed}/{total}). "
f"أكمل {len(missing_legacy)} بندًا في backend/.env — راجع ملف الربط الشامل."
)
summary_en = (
f"Commercial launch blocked: {readiness_percent}% ({passed}/{total}). "
f"Complete {len(missing_legacy)} item(s); see INTEGRATION_MASTER_AR.md."
)
base_url = (settings.API_URL or "http://localhost:8000").rstrip("/")
gate_path = "/api/v1/autonomous-foundation/integrations/go-live-gate"
gate_url = f"{base_url}{gate_path}"
cli_examples = {
"powershell": (
f'Invoke-RestMethod -Uri "{gate_url}" -Method Get | ConvertTo-Json -Depth 12'
),
"curl": f'curl -sS "{gate_url}"',
}
warnings: List[str] = []
if getattr(settings, "WHATSAPP_MOCK_MODE", True):
warnings.append(
"WHATSAPP_MOCK_MODE is enabled — WhatsApp sends are simulated until you set WHATSAPP_MOCK_MODE=false and real tokens."
)
if getattr(settings, "ENVIRONMENT", "") == "development" and launch_allowed:
warnings.append("ENVIRONMENT=development — use production settings before real go-live.")
return {
"gate": "go_live",
"launch_mode": "full_commercial",
"launch_allowed": launch_allowed,
"readiness_percent": readiness_percent,
"readiness_percent_total": matrix["full_matrix"]["readiness_percent"],
"passed_count": passed,
"total_count": total,
"score": matrix["score"],
"overall": "PASS" if launch_allowed else "FAIL",
"summary": summary_ar,
"summary_en": summary_en,
"blocked_reasons": blocked_reasons,
"checks": checks,
"categories": matrix["categories"],
"blocking": matrix["blocking"],
"full_matrix": matrix["full_matrix"],
"missing": missing_legacy,
"missing_detail": missing_rows,
"missing_optional": matrix["missing_optional"],
"missing_count": len(missing_legacy),
"missing_optional_count": matrix["missing_optional_count"],
"env_template_file": "salesflow-saas/backend/.env.phase2.example",
"integration_docs": {
"integration_master_ar": "salesflow-saas/docs/INTEGRATION_MASTER_AR.md",
"launch_checklist": "salesflow-saas/docs/LAUNCH_CHECKLIST.md",
"frontend_env_example": "salesflow-saas/frontend/.env.example",
},
"cli_examples": cli_examples,
"warnings": warnings,
"notes": [
"الفحوص الإلزامية تشمل: أمان، قاعدة بيانات، ذكاء، بريد، Salesforce، واتساب (ومنع الوضع التجريبي)، Stripe + webhook، Twilio، توقيع إلكتروني.",
"راجع docs/INTEGRATION_MASTER_AR.md لجدول الربط الشامل وروابط الويبهوك.",
"انسخ backend/.env.phase2.example إلى backend/.env وعبّئ كل البنود الفاشلة.",
"ثم GET /integrations/go-live-gate حتى launch_allowed=true.",
"أخيراً POST /integrations/connectivity-test للتحقق من وقت التشغيل.",
],
}
@router.post("/flows/prospecting")
async def run_prospecting_flow(payload: DealPayload) -> Dict[str, Any]:
return await prospecting_durable_flow.run(payload.tenant_id, payload.deal)
@router.post("/flows/self-improvement")
async def run_self_improvement_flow(payload: DealPayload) -> Dict[str, Any]:
return self_improvement_flow.run(payload.tenant_id, payload.deal)
@router.post("/intelligence/contract")
async def run_contract_intelligence(payload: DealPayload) -> Dict[str, Any]:
return await contract_intelligence_service.generate_and_send(payload.deal)
@router.post("/intelligence/predictive")
async def run_predictive(payload: PredictiveRequest) -> Dict[str, Any]:
return {
"forecast": predictive_revenue_service.forecast(payload.pipeline),
"churn": predictive_revenue_service.predict_churn(payload.accounts),
"anomalies": predictive_revenue_service.detect_anomalies(payload.metrics),
}
@router.post("/dashboard/executive-roi")
async def executive_roi(payload: ROIRequest) -> Dict[str, Any]:
return executive_roi_service.build_snapshot(payload.baseline, payload.current)
@router.post("/mobile/field-action")
async def mobile_field_action(payload: MobileActionRequest) -> Dict[str, Any]:
return {
"status": "accepted",
"tenant_id": payload.tenant_id,
"rep_id": payload.rep_id,
"action": payload.action,
"payload": payload.payload,
}
@router.post("/integrations/webhook-hub/{provider}")
async def webhook_hub(provider: str, body: Dict[str, Any]) -> Dict[str, Any]:
return {"status": "received", "provider": provider, "body": body}
@router.post("/integrations/connectivity-test")
async def integrations_connectivity_test(payload: ConnectivityRequest) -> Dict[str, Any]:
"""
Live runtime probe of each integration. Never raises HTTP 500: each provider is isolated;
demo/placeholder .env keys will often return provider errors inside the JSON expected until real credentials.
"""
sf = SalesforceAgentforcePlugin()
wa = WhatsAppCloudPlugin()
stripe = StripeBillingPlugin()
voice = VoiceAgentsPlugin()
contract = ContractIntelligencePlugin()
async def _safe(name: str, coro):
try:
return {"status": "ok", "data": await coro}
except Exception as e:
return {"status": "error", "error": str(e), "provider": name}
account = await _safe("salesforce", sf.get_account_360(payload.company_name))
wa_result = await _safe(
"whatsapp",
wa.send_message(payload.phone, "Connectivity test from Dealix."),
)
stripe_result = await _safe(
"stripe",
stripe.create_charge(payload.customer_id, payload.amount_sar),
)
voice_result = await _safe(
"voice",
voice.trigger_call(payload.company_name, payload.phone, "connectivity_test"),
)
contract_result = await _safe(
"contract",
contract.request_signature("phase2-connectivity-contract", provider="docusign"),
)
parts = [account, wa_result, stripe_result, voice_result, contract_result]
ok_n = sum(1 for p in parts if p.get("status") == "ok")
return {
"tenant_id": payload.tenant_id,
"summary": {
"ok_count": ok_n,
"total": len(parts),
"note_ar": "البوابة تفحص وجود المتغيرات؛ هذا الاختبار يفحص الشبكة. أخطاء متوقعة مع مفاتيح تجريبية.",
"note_en": "Go-live gate validates env vars; this call hits real APIs — errors are expected with demo keys.",
},
"salesforce": account,
"whatsapp": wa_result,
"stripe": stripe_result,
"voice": voice_result,
"contract": contract_result,
}
@router.get("/integrations/live-readiness")
async def live_readiness_report() -> Dict[str, Any]:
report = build_go_live_readiness_report()
return {
"overall": report["overall"],
"launch_mode": report["launch_mode"],
"score": report["score"],
"readiness_percent": report["readiness_percent"],
"readiness_percent_total": report["readiness_percent_total"],
"summary": report["summary"],
"summary_en": report["summary_en"],
"blocked_reasons": report["blocked_reasons"],
"checks": report["checks"],
"categories": report["categories"],
"blocking": report["blocking"],
"full_matrix": report["full_matrix"],
"missing": report["missing"],
"missing_detail": report["missing_detail"],
"missing_optional": report["missing_optional"],
"integration_docs": report["integration_docs"],
"cli_examples": report["cli_examples"],
"notes": report["notes"],
}
@router.get("/integrations/go-live-gate")
async def go_live_gate():
"""
Blocks production launch until all required env integrations are configured (100%).
Returns 200 with full report when ready; 403 with the same report shape when blocked.
"""
report = build_go_live_readiness_report()
if report["launch_allowed"]:
return report
return JSONResponse(status_code=403, content=report)

View File

@ -9,6 +9,8 @@ from pydantic import BaseModel as Schema
from app.database import get_db
from app.api.deps import get_current_user, require_role
from app.models.user import User
from app.services.audit_service import record_audit
from app.services.operations_hub import emit_domain_event
from app.models.commission import Commission, CommissionStatus
router = APIRouter()
@ -161,6 +163,21 @@ async def approve_commission(
commission.approved_by = current_user.id
commission.approved_at = datetime.now(timezone.utc)
await db.flush()
await record_audit(
db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="commission.approve",
entity_type="commission",
entity_id=commission.id,
changes={"deal_id": str(commission.deal_id), "amount": float(commission.amount)},
)
await emit_domain_event(
db,
tenant_id=current_user.tenant_id,
event_type="commission.approved",
payload={"commission_id": str(commission.id), "deal_id": str(commission.deal_id)},
)
await db.refresh(commission)
return CommissionResponse.model_validate(commission)
@ -181,6 +198,21 @@ async def hold_commission(
commission.status = CommissionStatus.HELD
commission.held_reason = data.reason
await db.flush()
await record_audit(
db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="commission.hold",
entity_type="commission",
entity_id=commission.id,
changes={"reason": data.reason},
)
await emit_domain_event(
db,
tenant_id=current_user.tenant_id,
event_type="commission.held",
payload={"commission_id": str(commission.id)},
)
await db.refresh(commission)
return CommissionResponse.model_validate(commission)
@ -202,6 +234,21 @@ async def pay_commission(
commission.status = CommissionStatus.PAID
commission.paid_at = datetime.now(timezone.utc)
await db.flush()
await record_audit(
db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="commission.pay",
entity_type="commission",
entity_id=commission.id,
changes={"paid_at": commission.paid_at.isoformat() if commission.paid_at else None},
)
await emit_domain_event(
db,
tenant_id=current_user.tenant_id,
event_type="commission.paid",
payload={"commission_id": str(commission.id)},
)
await db.refresh(commission)
return CommissionResponse.model_validate(commission)

View File

@ -0,0 +1,22 @@
"""Customer onboarding journey & acceptance checklist — JSON for UI and sales engineering."""
from __future__ import annotations
from fastapi import APIRouter
from app.services.customer_onboarding_journey import (
build_acceptance_test_checklist,
build_journey,
)
router = APIRouter(prefix="/customer-onboarding", tags=["Customer Onboarding"])
@router.get("/journey")
async def get_customer_journey():
return build_journey()
@router.get("/acceptance-test")
async def get_acceptance_test_checklist():
return build_acceptance_test_checklist()

View File

@ -8,6 +8,8 @@ from app.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.models.deal import Deal
from app.services.audit_service import record_audit
from app.services.operations_hub import emit_domain_event
from app.schemas.deal import DealCreate, DealUpdate, DealResponse, StageUpdate, PipelineResponse
router = APIRouter()
@ -15,6 +17,18 @@ router = APIRouter()
PIPELINE_STAGES = ["new", "negotiation", "proposal", "closed_won", "closed_lost"]
def _deal_tenant_scope(query, user: User):
"""مندوب يرى صفقاته المسندة فقط."""
if getattr(user, "role", None) == "agent":
return query.where(Deal.assigned_to == user.id)
return query
def _ensure_deal_access(deal: Deal, user: User) -> None:
if getattr(user, "role", None) == "agent" and deal.assigned_to != user.id:
raise HTTPException(status_code=403, detail="Not assigned to this deal")
@router.get("", response_model=list[DealResponse])
async def list_deals(
stage: str = Query(None),
@ -23,6 +37,7 @@ async def list_deals(
db: AsyncSession = Depends(get_db),
):
query = select(Deal).where(Deal.tenant_id == current_user.tenant_id)
query = _deal_tenant_scope(query, current_user)
if stage:
query = query.where(Deal.stage == stage)
if assigned_to:
@ -38,7 +53,9 @@ async def get_pipeline(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Deal).where(Deal.tenant_id == current_user.tenant_id))
q = select(Deal).where(Deal.tenant_id == current_user.tenant_id)
q = _deal_tenant_scope(q, current_user)
result = await db.execute(q)
deals = result.scalars().all()
stages = {s: [] for s in PIPELINE_STAGES}
@ -61,6 +78,21 @@ async def create_deal(
deal = Deal(tenant_id=current_user.tenant_id, **data.model_dump(exclude_none=True))
db.add(deal)
await db.flush()
await record_audit(
db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="deal.create",
entity_type="deal",
entity_id=deal.id,
changes={"title": deal.title, "stage": deal.stage},
)
await emit_domain_event(
db,
tenant_id=current_user.tenant_id,
event_type="deal.created",
payload={"deal_id": str(deal.id), "stage": deal.stage},
)
await db.refresh(deal)
return DealResponse.model_validate(deal)
@ -76,11 +108,28 @@ async def update_deal(
deal = result.scalar_one_or_none()
if not deal:
raise HTTPException(status_code=404, detail="Deal not found")
_ensure_deal_access(deal, current_user)
before = {"stage": deal.stage, "value": str(deal.value) if deal.value is not None else None}
for field, value in data.model_dump(exclude_none=True).items():
setattr(deal, field, value)
await db.flush()
await record_audit(
db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="deal.update",
entity_type="deal",
entity_id=deal.id,
changes={"before": before, "after": data.model_dump(exclude_none=True)},
)
await emit_domain_event(
db,
tenant_id=current_user.tenant_id,
event_type="deal.updated",
payload={"deal_id": str(deal.id)},
)
await db.refresh(deal)
return DealResponse.model_validate(deal)
@ -99,12 +148,29 @@ async def update_deal_stage(
deal = result.scalar_one_or_none()
if not deal:
raise HTTPException(status_code=404, detail="Deal not found")
_ensure_deal_access(deal, current_user)
prev_stage = deal.stage
deal.stage = data.stage
if data.stage in ("closed_won", "closed_lost"):
deal.closed_at = datetime.now(timezone.utc)
deal.probability = 100 if data.stage == "closed_won" else 0
await db.flush()
await record_audit(
db,
tenant_id=current_user.tenant_id,
user_id=current_user.id,
action="deal.stage_change",
entity_type="deal",
entity_id=deal.id,
changes={"from": prev_stage, "to": data.stage},
)
await emit_domain_event(
db,
tenant_id=current_user.tenant_id,
event_type="deal.stage_changed",
payload={"deal_id": str(deal.id), "from": prev_stage, "to": data.stage},
)
await db.refresh(deal)
return DealResponse.model_validate(deal)

View File

@ -5,14 +5,18 @@ from datetime import datetime, timezone
from pydantic import BaseModel as Schema
from app.database import get_db
from app.config import get_settings
router = APIRouter()
_settings = get_settings()
class HealthResponse(Schema):
status: str
timestamp: str
version: str = "1.0.0"
version: str = "2.0.0"
app: str = "Dealix"
environment: str = "production"
class ReadyResponse(Schema):
@ -26,6 +30,9 @@ async def health_check():
return HealthResponse(
status="healthy",
timestamp=datetime.now(timezone.utc).isoformat(),
version="2.0.0",
app=_settings.APP_NAME,
environment=_settings.ENVIRONMENT,
)

View File

@ -11,6 +11,18 @@ from app.schemas.lead import LeadCreate, LeadUpdate, LeadResponse, LeadListRespo
router = APIRouter()
def _lead_list_scope(query, user: User):
"""مندوب: عملاء محتملون مسندون إليه فقط."""
if getattr(user, "role", None) == "agent":
return query.where(Lead.assigned_to == user.id)
return query
def _ensure_lead_access(lead: Lead, user: User) -> None:
if getattr(user, "role", None) == "agent" and lead.assigned_to != user.id:
raise HTTPException(status_code=403, detail="Not assigned to this lead")
@router.get("", response_model=LeadListResponse)
async def list_leads(
status: str = Query(None),
@ -23,12 +35,15 @@ async def list_leads(
db: AsyncSession = Depends(get_db),
):
query = select(Lead).where(Lead.tenant_id == current_user.tenant_id)
query = _lead_list_scope(query, current_user)
if status:
query = query.where(Lead.status == status)
if source:
query = query.where(Lead.source == source)
if assigned_to:
if getattr(current_user, "role", None) == "agent" and assigned_to != current_user.id:
raise HTTPException(status_code=403, detail="Cannot filter by other users")
query = query.where(Lead.assigned_to == assigned_to)
if search:
query = query.where(Lead.name.ilike(f"%{search}%") | Lead.phone.ilike(f"%{search}%") | Lead.email.ilike(f"%{search}%"))
@ -49,7 +64,13 @@ async def create_lead(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
lead = Lead(tenant_id=current_user.tenant_id, **data.model_dump(exclude_none=True))
raw = data.model_dump(exclude_none=True)
meta = raw.pop("metadata", None)
lead = Lead(tenant_id=current_user.tenant_id, **raw)
if meta is not None:
lead.extra_metadata = meta
if getattr(current_user, "role", None) == "agent":
lead.assigned_to = current_user.id
db.add(lead)
await db.flush()
await db.refresh(lead)
@ -66,6 +87,7 @@ async def get_lead(
lead = result.scalar_one_or_none()
if not lead:
raise HTTPException(status_code=404, detail="Lead not found")
_ensure_lead_access(lead, current_user)
return LeadResponse.model_validate(lead)
@ -80,9 +102,18 @@ async def update_lead(
lead = result.scalar_one_or_none()
if not lead:
raise HTTPException(status_code=404, detail="Lead not found")
_ensure_lead_access(lead, current_user)
for field, value in data.model_dump(exclude_none=True).items():
setattr(lead, field, value)
payload = data.model_dump(exclude_none=True)
if getattr(current_user, "role", None) == "agent" and "assigned_to" in payload:
if payload["assigned_to"] not in (None, current_user.id):
raise HTTPException(status_code=403, detail="Cannot reassign lead")
for field, value in payload.items():
if field == "metadata":
lead.extra_metadata = value if value is not None else {}
else:
setattr(lead, field, value)
await db.flush()
await db.refresh(lead)
@ -100,6 +131,14 @@ async def assign_lead(
lead = result.scalar_one_or_none()
if not lead:
raise HTTPException(status_code=404, detail="Lead not found")
_ensure_lead_access(lead, current_user)
role = getattr(current_user, "role", None)
if role == "agent":
if assigned_to != current_user.id:
raise HTTPException(status_code=403, detail="Agents can only assign to themselves")
elif role not in ("owner", "admin", "manager"):
raise HTTPException(status_code=403, detail="Insufficient permissions to assign leads")
lead.assigned_to = assigned_to
await db.flush()

View File

@ -0,0 +1,44 @@
"""
Marketing asset URLs for frontends and operators (single source of truth for paths).
"""
from __future__ import annotations
from fastapi import APIRouter
from app.config import get_settings
router = APIRouter(tags=["Marketing"])
settings = get_settings()
@router.get("/marketing/hub")
async def marketing_hub() -> dict:
"""
Returns absolute paths (same host in production behind nginx) and API base.
Use NEXT_PUBLIC_APP_URL or API_URL to build full URLs on the client if needed.
"""
base = settings.API_URL.rstrip("/")
return {
"app_name": settings.APP_NAME,
"api_base": base,
"paths": {
"marketing_index": "/dealix-marketing/",
"marketing_zip": "/dealix-marketing/dealix-marketing-bundle.zip",
"presentations_index": "/dealix-presentations/",
"company_master_html": "/dealix-presentations/00-dealix-company-master-ar.html",
"use_cases_master": "/dealix-marketing/dealix-use-cases-2026/00-master-use-cases-ar.html",
"diagrams_viewer": "/dealix-marketing/dealix-use-cases-2026/diagrams-viewer.html",
"access_urls_txt": "/dealix-marketing/ACCESS-URLS.txt",
"strategy_page": "/strategy",
"strategy_doc_md": "/strategy/DEALIX_NEXT_LEVEL_MASTER_PLAN_AR.md",
"ultimate_execution_doc_md": "/strategy/ULTIMATE_EXECUTION_MASTER_AR.md",
"integration_master_ar_md": "/strategy/INTEGRATION_MASTER_AR.md",
"go_live_gate_api": "/api/v1/autonomous-foundation/integrations/go-live-gate",
"live_readiness_api": "/api/v1/autonomous-foundation/integrations/live-readiness",
"strategy_summary_api": "/api/v1/strategy/summary",
},
"notes": {
"nginx": "Behind nginx, /dealix-marketing and /dealix-presentations must proxy to this API (see nginx.conf).",
"next_dev": "Use next.config.js rewrites to proxy these paths to API in local dev.",
},
}

View File

@ -0,0 +1,242 @@
"""Full Auto Ops: لقطة تشغيل، تدقيق، أحداث، موافقات، صحة تكامل."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.deps import get_current_user, get_optional_user, require_role
from app.models.user import User
from app.models.operations import ApprovalRequest
from app.services.audit_service import list_recent_audits
from app.services.operations_hub import (
count_events_since,
count_pending_approvals,
emit_domain_event,
list_integration_connectors,
upsert_connector_status,
)
router = APIRouter(prefix="/operations", tags=["Full Auto Operations"])
def _demo_snapshot() -> Dict[str, Any]:
return {
"demo_mode": True,
"pending_approvals": 0,
"domain_events_24h": 0,
"audit_events_24h": 0,
"connectors": [
{"connector_key": "crm_salesforce", "display_name_ar": "Salesforce CRM", "status": "unknown", "last_success_at": None, "last_attempt_at": None, "last_error": None},
{"connector_key": "whatsapp_cloud", "display_name_ar": "واتساب Cloud API", "status": "unknown", "last_success_at": None, "last_attempt_at": None, "last_error": None},
{"connector_key": "stripe_billing", "display_name_ar": "Stripe — الفوترة", "status": "unknown", "last_success_at": None, "last_attempt_at": None, "last_error": None},
{"connector_key": "email_sync", "display_name_ar": "مزامنة البريد", "status": "unknown", "last_success_at": None, "last_attempt_at": None, "last_error": None},
],
"note_ar": "وضع توضيحي — سجّل الدخول لرؤية بيانات المستأجر.",
}
@router.get("/snapshot")
async def operations_snapshot(
db: AsyncSession = Depends(get_db),
user: Optional[User] = Depends(get_optional_user),
):
"""لقطة تشغيل: موافقات معلّقة، أحداث، تدقيق، موصلات. بدون JWT: توضيحي."""
if not user:
return _demo_snapshot()
from app.services.audit_service import count_audits_since
pending = await count_pending_approvals(db, user.tenant_id)
ev = await count_events_since(db, user.tenant_id, 24)
aud = await count_audits_since(db, user.tenant_id, 24)
connectors = await list_integration_connectors(db, user.tenant_id)
return {
"demo_mode": False,
"pending_approvals": pending,
"domain_events_24h": ev,
"audit_events_24h": aud,
"connectors": connectors,
"note_ar": "حلقة التشغيل: أحداث مسجّلة + تدقيق + موصلات — تُوسَّع مع المزامنة الفعلية.",
}
@router.get("/audit-logs")
async def get_audit_logs(
db: AsyncSession = Depends(get_db),
user: User = Depends(require_role("owner", "admin", "manager")),
limit: int = 80,
):
items = await list_recent_audits(db, user.tenant_id, limit=limit)
return {"items": items, "count": len(items)}
@router.get("/domain-events")
async def get_domain_events(
db: AsyncSession = Depends(get_db),
user: User = Depends(require_role("owner", "admin", "manager")),
limit: int = 50,
):
from app.models.operations import DomainEvent
q = await db.execute(
select(DomainEvent)
.where(DomainEvent.tenant_id == user.tenant_id)
.order_by(DomainEvent.created_at.desc())
.limit(limit)
)
rows = q.scalars().all()
items: List[Dict[str, Any]] = []
for e in rows:
items.append(
{
"id": str(e.id),
"event_type": e.event_type,
"source": e.source,
"payload": e.payload,
"correlation_id": e.correlation_id,
"created_at": e.created_at.isoformat() if e.created_at else None,
}
)
return {"items": items, "count": len(items)}
class ApprovalCreate(BaseModel):
channel: str = Field(..., description="whatsapp | email | sms")
resource_type: str
resource_id: UUID
payload: Dict[str, Any] = Field(default_factory=dict)
class ApprovalResolve(BaseModel):
approve: bool
note: Optional[str] = None
@router.post("/approvals")
async def create_approval(
body: ApprovalCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""طلب موافقة قبل إرسال — يدخل طابور pending."""
row = ApprovalRequest(
tenant_id=user.tenant_id,
channel=body.channel,
resource_type=body.resource_type,
resource_id=body.resource_id,
payload=body.payload,
status="pending",
requested_by_id=user.id,
)
db.add(row)
await db.flush()
await emit_domain_event(
db,
tenant_id=user.tenant_id,
event_type="approval.requested",
payload={"approval_id": str(row.id), "channel": body.channel},
source="api",
)
return {"id": str(row.id), "status": row.status}
@router.get("/approvals")
async def list_approvals(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
status: Optional[str] = None,
):
q = select(ApprovalRequest).where(ApprovalRequest.tenant_id == user.tenant_id)
if status:
q = q.where(ApprovalRequest.status == status)
q = q.order_by(ApprovalRequest.created_at.desc()).limit(100)
result = await db.execute(q)
items = []
for a in result.scalars().all():
items.append(
{
"id": str(a.id),
"channel": a.channel,
"resource_type": a.resource_type,
"resource_id": str(a.resource_id),
"status": a.status,
"requested_by_id": str(a.requested_by_id),
"payload": a.payload,
"created_at": a.created_at.isoformat() if a.created_at else None,
}
)
return {"items": items, "count": len(items)}
@router.put("/approvals/{approval_id}")
async def resolve_approval(
approval_id: UUID,
body: ApprovalResolve,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_role("owner", "admin", "manager")),
):
q = await db.execute(
select(ApprovalRequest).where(
ApprovalRequest.id == approval_id,
ApprovalRequest.tenant_id == user.tenant_id,
)
)
row = q.scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="Approval not found")
if row.status != "pending":
raise HTTPException(status_code=400, detail="Not pending")
row.status = "approved" if body.approve else "rejected"
row.reviewed_by_id = user.id
row.reviewed_at = datetime.now(timezone.utc)
row.note = body.note
await db.flush()
await emit_domain_event(
db,
tenant_id=user.tenant_id,
event_type="approval.resolved",
payload={"approval_id": str(row.id), "result": row.status},
source="api",
)
return {"id": str(row.id), "status": row.status}
class ConnectorUpdate(BaseModel):
status: str
success: bool = False
last_error: Optional[str] = None
@router.put("/integration-connectors/{connector_key}")
async def update_connector(
connector_key: str,
body: ConnectorUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_role("owner", "admin")),
):
"""تحديث حالة موصل (مزامنة يدوية أو من عامل خلفي)."""
await upsert_connector_status(
db,
user.tenant_id,
connector_key,
status=body.status,
last_error=body.last_error,
success=body.success,
)
return {"connector_key": connector_key, "ok": True}
@router.get("/integration-connectors")
async def get_connectors(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
items = await list_integration_connectors(db, user.tenant_id)
return {"items": items, "count": len(items)}

View File

@ -13,6 +13,13 @@ from app.api.v1 import outreach_engine as outreach_router
from app.api.v1 import lead_prospector as prospector_router
from app.api.v1 import pipeline as pipeline_router
from app.api.v1 import agent_system as agent_system_router
from app.api.v1 import autonomous_foundation as autonomous_foundation_router
from app.api.v1 import marketing_hub as marketing_hub_router
from app.api.v1 import strategy_summary as strategy_summary_router
from app.api.v1 import value_proposition as value_proposition_router
from app.api.v1 import customer_onboarding as customer_onboarding_router
from app.api.v1 import sales_os as sales_os_router
from app.api.v1 import operations as operations_router
api_router = APIRouter()
@ -40,6 +47,12 @@ 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(marketing_hub_router.router)
api_router.include_router(strategy_summary_router.router)
api_router.include_router(value_proposition_router.router)
api_router.include_router(customer_onboarding_router.router)
api_router.include_router(sales_os_router.router)
api_router.include_router(operations_router.router)
api_router.include_router(analytics.router, tags=["Analytics & AI"])
api_router.include_router(webhooks.router, tags=["Webhooks"])
api_router.include_router(prospecting.router, prefix="/prospecting", tags=["Prospecting"])
@ -63,3 +76,4 @@ api_router.include_router(pipeline_router.router)
# ── 22-Agent AI System — Full Empire Control ─────────────────
api_router.include_router(agent_system_router.router)
api_router.include_router(autonomous_foundation_router.router)

View File

@ -0,0 +1,161 @@
"""Sales OS: commission ledger (شفافية عمولة)، مهام، حصص، تأهيل مندوب."""
from __future__ import annotations
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.deps import get_current_user, get_optional_user, require_role, get_current_tenant
from app.models.user import User
from app.models.tenant import Tenant
from app.services.sales_os_service import (
build_commission_ledger,
build_daily_digest,
build_deal_health,
build_manager_team_summary,
demo_commission_ledger,
merge_quota_view,
pipeline_value_open_deals,
pipeline_value_open_deals_scoped,
rep_onboarding_playbook,
tasks_inbox_today,
)
router = APIRouter(prefix="/sales-os", tags=["Sales OS"])
class QuotaUpdate(BaseModel):
default_monthly_quota_sar: Optional[float] = None
rep_quotas: Optional[Dict[str, float]] = Field(default=None, description="user_id -> monthly SAR target")
@router.get("/commission-ledger")
async def commission_ledger(
db: AsyncSession = Depends(get_db),
user: Optional[User] = Depends(get_optional_user),
):
"""
صفقة عمولة دفعة. بدون توكن: بيانات توضيحية. مع توكن: بيانات المستأجر (أو توضيحي إن فارغ).
"""
if user:
data = await build_commission_ledger(db, user.tenant_id)
if not data["items"]:
return demo_commission_ledger()
return data
return demo_commission_ledger()
@router.get("/tasks-inbox")
async def tasks_inbox(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""أنشطة مرتبطة بالمستخدم — بداية صندوق مهام."""
items = await tasks_inbox_today(db, user.tenant_id, user.id)
return {"items": items, "count": len(items)}
@router.get("/quota")
async def quota_overview(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
tenant: Tenant = Depends(get_current_tenant),
):
pipeline = await pipeline_value_open_deals_scoped(
db, user.tenant_id, user_id=user.id, role=user.role or "agent"
)
settings = tenant.settings if isinstance(tenant.settings, dict) else {}
return merge_quota_view(settings, user.id, pipeline)
@router.put("/quota")
async def quota_update(
body: QuotaUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_role("owner", "admin", "manager")),
tenant: Tenant = Depends(get_current_tenant),
):
base = tenant.settings if isinstance(tenant.settings, dict) else {}
sales_os = dict(base.get("sales_os") or {})
if body.default_monthly_quota_sar is not None:
sales_os["default_monthly_quota_sar"] = body.default_monthly_quota_sar
if body.rep_quotas is not None:
sales_os["rep_quotas"] = {str(k): float(v) for k, v in body.rep_quotas.items()}
tenant.settings = {**base, "sales_os": sales_os}
db.add(tenant)
await db.flush()
pipeline = await pipeline_value_open_deals_scoped(
db, user.tenant_id, user_id=user.id, role=user.role or "agent"
)
return merge_quota_view(tenant.settings or {}, user.id, pipeline)
@router.get("/rep-onboarding")
async def rep_onboarding_playbook_endpoint():
"""مسار 7/14/30 يوم — محتوى ثابت يُربط لاحقاً بتتبع DB."""
return rep_onboarding_playbook()
@router.get("/overview")
async def sales_os_overview(
db: AsyncSession = Depends(get_db),
user: Optional[User] = Depends(get_optional_user),
):
"""لقطة واحدة للواجهة: عمولة + حصة + مهام + تأهيل."""
out: Dict[str, Any] = {"rep_onboarding": rep_onboarding_playbook()}
if user:
t_result = await db.execute(select(Tenant).where(Tenant.id == user.tenant_id))
tenant = t_result.scalar_one_or_none()
settings = tenant.settings if tenant and isinstance(tenant.settings, dict) else {}
pipeline = await pipeline_value_open_deals_scoped(
db, user.tenant_id, user_id=user.id, role=user.role or "agent"
)
ledger = await build_commission_ledger(db, user.tenant_id)
if not ledger["items"]:
ledger = demo_commission_ledger()
out["commission_ledger"] = ledger
out["quota"] = merge_quota_view(settings, user.id, pipeline)
out["tasks"] = await tasks_inbox_today(db, user.tenant_id, user.id)
out["daily_digest"] = await build_daily_digest(
db, user.tenant_id, user.id, user.role or "agent", settings
)
else:
out["commission_ledger"] = demo_commission_ledger()
out["quota"] = None
out["tasks"] = []
out["daily_digest"] = None
return out
@router.get("/daily-digest")
async def daily_digest(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
tenant: Tenant = Depends(get_current_tenant),
):
"""ملخّص يومي: مهام، حصة، إغلاقات قريبة، اقتراحات."""
settings = tenant.settings if isinstance(tenant.settings, dict) else {}
return await build_daily_digest(db, user.tenant_id, user.id, user.role or "agent", settings)
@router.get("/manager-summary")
async def manager_summary(
db: AsyncSession = Depends(get_db),
user: User = Depends(require_role("owner", "admin", "manager")),
):
"""أنبوب الفريق حسب المندوب — للمدير."""
return await build_manager_team_summary(db, user.tenant_id)
@router.get("/deal-health")
async def deal_health(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""صحة الصفقات المفتوحة — إشارات أولية."""
return await build_deal_health(db, user.tenant_id, user.id, user.role or "agent")

View File

@ -0,0 +1,101 @@
"""JSON summary for /strategy page and integrations."""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(tags=["Strategy"])
_BLUEPRINT_VERSION = "4.0.0-legendary"
@router.get("/strategy/summary")
async def strategy_summary() -> dict:
return {
"product": "Dealix",
"blueprint_version": _BLUEPRINT_VERSION,
"positioning": "Revenue & Operations OS - B2B Saudi-first, governance, multi-tenant",
"vision": {
"tagline_ar": "ليس أداة فقط — شركة مبيعات رقمية مؤتمتة بالذكاء الاصطناعي تعمل 24/7",
"tagline_en": "Not just a tool — an AI-automated digital sales company operating 24/7",
},
"moat_pillars": [
"Local channels + compliance context (ZATCA, Arabic-first UX)",
"Governed actions (approvals before sensitive sends) vs generic chatbots",
"Multi-tenant CRM + integrations path (Salesforce, WhatsApp, Stripe, eSign)",
"Measurable self-improvement loops when enabled",
"OpenClaw-style durable flows + revision posture (see openclaw-config.yaml)",
],
"competitive_moat": {
"durable_runtime": "OpenClaw 2026.4.2 pattern — checkpoints, retries, bounded plugins",
"self_improvement": "6-phase loop: signals → diagnose → experiments → A/B → governance → promote/rollback",
"saudi_first": "WhatsApp-first, SAR, PDPL-aware handling; not generic US-centric sequences",
"knowledge": "In-app RAG only (PostgreSQL/pgvector, KnowledgeService) — no external RAG SaaS as SoT",
},
"auditable_targets": [
{"id": "revenue", "label_ar": "النمو الإيرادي", "target": "35× سنوياً مقابل خط أساس", "unit": "growth_vs_baseline"},
{"id": "efficiency", "label_ar": "كفاءة المبيعات", "target": "7080% عمل يدوي في المسار", "unit": "manual_work_reduction"},
{"id": "forecast", "label_ar": "دقة التنبؤ", "target": "أفق 30 يوماً — بيانات نظيفة ونماذج معايرة", "unit": "accuracy_horizon_30d"},
{"id": "cycle", "label_ar": "دورة الإغلاق", "target": "حوالي 40% زمن مقارنة بالخط الأساسي", "unit": "cycle_time_delta"},
{"id": "cac", "label_ar": "تكلفة الاكتساب", "target": "حوالي 31% عبر الأتمتة والتوجيه", "unit": "cac_delta"},
{"id": "compliance", "label_ar": "الامتثال", "target": "PDPL + جاهزية SOC2 للضوابط والسجلات", "unit": "policy"},
],
"design_principles": [
{"id": "value_first", "title_ar": "القيمة أولاً", "summary": "كل ميزة مربوطة بمؤشر عميل أو تشغيلي"},
{"id": "compliance_by_design", "title_ar": "الامتثال بالتصميم", "summary": "موافقات، سجلات، حدود بيانات وليست لاحقة"},
{"id": "self_evolving", "title_ar": "تطور ذاتي", "summary": "حلقة تحسين وتجارب بظلال/كاناري عند التفعيل"},
{"id": "simplicity", "title_ar": "بساطة ظاهرة", "summary": "تعقيد الداخل منظم؛ واجهة بسيطة"},
{"id": "measurable", "title_ar": "قابلية القياس", "summary": "ROI تنفيذي، تكلفة نماذج لكل مستأجر حيث ينطبق"},
{"id": "zero_trust", "title_ar": "أمان عبر عدم الثقة العمياء", "summary": "عزل مستأجرين؛ إجراءات حساسة خلف خطافات"},
],
"market_frame": "Global shift to Revenue Action Orchestration; high TCO on mega-platforms",
"phases": [
{"id": 0, "name": "Foundation", "horizon_days": 90},
{"id": 1, "name": "Differentiation", "horizon_months": "3-9"},
{"id": 2, "name": "Enterprise scale", "horizon_months": "9-18"},
{"id": 3, "name": "Geographic / category expansion", "horizon_months": "18-36"},
],
"execution_phases_detail": [
{
"id": 0,
"name_ar": "أساس الإنتاج",
"window": "090 يوماً",
"deliverables": ["CI واختبارات حرجة", "go-live gate", "pilot", "أصول تسويق موحّدة"],
},
{
"id": 1,
"name_ar": "MVP إيرادي",
"window": "شهر 23",
"deliverables": ["تأهيل أعمق", "عروض وعقود مسار", "لوحة ROI أساسية", "امتثال تشغيلي"],
},
{
"id": 2,
"name_ar": "توسع مؤسسي",
"window": "شهر 49",
"deliverables": ["أنماط multi-tenant أعمق", "مسار صوت", "طبقة تنبؤ إيرادات", "بوابة API"],
},
{
"id": 3,
"name_ar": "قيادة فئة",
"window": "شهر 1036",
"deliverables": ["مناطق", "شراكات", "قطاعات عمودية", "سوق إضافات"],
},
],
"kpis": [
{"axis": "product", "metric": "API p95, 5xx rate"},
{"axis": "adoption", "metric": "channels enabled, approval usage"},
{"axis": "revenue", "metric": "NRR, pilot→paid"},
{"axis": "trust", "metric": "case studies, NPS"},
],
"doc_paths": {
"full_markdown_web": "/strategy/DEALIX_NEXT_LEVEL_MASTER_PLAN_AR.md",
"ultimate_execution_ar": "/strategy/ULTIMATE_EXECUTION_MASTER_AR.md",
"integration_master_ar": "/strategy/INTEGRATION_MASTER_AR.md",
"investor_html": "/dealix-marketing/investor/00-investor-dealix-full-ar.html",
},
"repo_paths": {
"blueprint": "salesflow-saas/MASTER-BLUEPRINT.mdc",
"openclaw_config": "salesflow-saas/openclaw/openclaw-config.yaml",
"ultimate_doc": "salesflow-saas/docs/ULTIMATE_EXECUTION_MASTER_AR.md",
"integration_master": "salesflow-saas/docs/INTEGRATION_MASTER_AR.md",
},
}

View File

@ -0,0 +1,52 @@
"""Public JSON describing business value — for demos, proposals, and dashboard."""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(prefix="/value-proposition", tags=["Value Proposition"])
@router.get("/")
async def get_value_proposition():
return {
"product": "Dealix",
"tagline_ar": "نظام تشغيل إيرادات بالذكاء الاصطناعي — مبني للسوق السعودي",
"pillars": [
{
"id": "velocity",
"title_ar": "سرعة الأنبوب",
"summary_ar": "تقليل زمن الدورة من التأهيل إلى الإغلاق عبر أتمتة المتابعة والجدولة.",
"metrics_hint": ["pipeline_velocity_days", "response_time"],
},
{
"id": "conversion",
"title_ar": "رفع معدل الفوز",
"summary_ar": "تأهيل أعمق، اعتراضات أقل، ومسارات عروض متسقة عبر وكلاء متخصصين.",
"metrics_hint": ["win_rate", "qualification_score"],
},
{
"id": "cost",
"title_ar": "تخفيض العمل اليدوي",
"summary_ar": "إزالة التكرار في الرسائل، التقارير، والتنسيق بين الفرق.",
"metrics_hint": ["manual_work_reduction_percent", "tickets_deflected"],
},
{
"id": "trust",
"title_ar": "امتثال وتتبع",
"summary_ar": "مسارات موافقات، سجل تدقيق، وقنوات رسمية (واتساب، بريد، صوت).",
"metrics_hint": ["consent_rate", "audit_events"],
},
],
"sectors_sample": [
"العقارات",
"الصحة",
"التجزئة",
"التعليم",
"B2B خدمات",
],
"roi_framework_ar": (
"يقيس النظام أثراً مالياً عبر ارتفاع الإيراد، تحسين معدل الفوز، "
"وتسريع الأنبوب مع تقليل العمل اليدوي — جاهز للعرض على الإدارة العليا."
),
}

View File

@ -10,7 +10,6 @@ from app.config import get_settings
from app.database import async_session
from app.services.lead_service import LeadService
from app.ai.orchestrator import Orchestrator
from app.integrations.whatsapp import send_whatsapp_message
import logging
logger = logging.getLogger("dealix.webhooks")
@ -113,10 +112,21 @@ async def _process_whatsapp_message(
channel="whatsapp"
)
# 4. Immediate Response (Closing the loop)
# 4. Immediate Response (Closing the loop) — مع حوكمة اختيارية
if ai_result and ai_result.get("reply"):
await send_whatsapp_message(phone, ai_result["reply"])
from uuid import UUID as _UUID
from app.services.outbound_governance import send_whatsapp_with_governance
await send_whatsapp_with_governance(
db,
tenant_id=_UUID(tenant_id),
phone=phone,
message=ai_result["reply"],
lead_id=_UUID(lead["id"]),
)
await db.commit()
except Exception as e:
logger.exception(f"Critical error in WhatsApp AI pipeline: {str(e)}")

View File

@ -1,4 +1,4 @@
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
from typing import Optional
@ -29,6 +29,16 @@ class Settings(BaseSettings):
# ── URLs ─────────────────────────────────────────────
API_URL: str = "http://localhost:8000"
FRONTEND_URL: str = "http://localhost:3000"
# Comma-separated extra CORS origins (e.g. https://staging.example.com)
CORS_EXTRA_ORIGINS: str = ""
# When False and ENVIRONMENT=production, OpenAPI docs are disabled
EXPOSE_OPENAPI: bool = True
# If non-empty, require Authorization: Bearer <token> for /api/v1 (except health, webhooks, marketing, strategy, value-proposition)
DEALIX_INTERNAL_API_TOKEN: str = ""
# Serve sales_assets + sector presentations at /dealix-marketing, /dealix-presentations
MARKETING_STATIC_ENABLED: bool = True
# Empty = auto (repo/salesflow-saas). In Docker set to /salesflow (see docker-compose).
MARKETING_STATIC_ROOT: str = ""
# ── LLM Providers ────────────────────────────────────
# Primary: Groq (free/cheap, very fast)
@ -41,6 +51,12 @@ class Settings(BaseSettings):
OPENAI_MODEL: str = "gpt-4o"
OPENAI_MINI_MODEL: str = "gpt-4o-mini"
# Additional LLM backends (read by model_router / agents via Settings or os.environ)
ANTHROPIC_API_KEY: str = ""
DEEPSEEK_API_KEY: str = ""
ZAI_API_KEY: str = "" # Z.ai / GLM
GOOGLE_API_KEY: str = "" # Gemini (same name as lead_prospector)
# Embeddings
EMBEDDING_PROVIDER: str = "openai" # openai, sentence-transformers
EMBEDDING_MODEL: str = "text-embedding-3-small"
@ -86,6 +102,28 @@ class Settings(BaseSettings):
SALESFORCE_CLIENT_ID: str = ""
SALESFORCE_CLIENT_SECRET: str = ""
SALESFORCE_DOMAIN: str = ""
SALESFORCE_API_VERSION: str = "v60.0"
SALESFORCE_REFRESH_TOKEN: str = ""
SALESFORCE_ACCESS_TOKEN: str = ""
# ── Stripe / Billing ────────────────────────────────────
STRIPE_SECRET_KEY: str = ""
STRIPE_WEBHOOK_SECRET: str = ""
# ── E-Sign Providers ────────────────────────────────────
DOCUSIGN_API_URL: str = "https://demo.docusign.net/restapi"
DOCUSIGN_ACCESS_TOKEN: str = ""
ADOBE_SIGN_API_URL: str = "https://api.na1.adobesign.com/api/rest/v6"
ADOBE_SIGN_ACCESS_TOKEN: str = ""
# ── Voice Agent Providers ───────────────────────────────
TWILIO_ACCOUNT_SID: str = ""
TWILIO_AUTH_TOKEN: str = ""
TWILIO_FROM_NUMBER: str = ""
VOICE_PROVIDER: str = "twilio"
# ── Autonomous Loops ────────────────────────────────────
SELF_IMPROVEMENT_INTERVAL_SECONDS: int = 900
# ── Scraping / Lead Gen ──────────────────────────────
GOOGLE_MAPS_API_KEY: str = ""
@ -104,10 +142,7 @@ class Settings(BaseSettings):
UPLOAD_DIR: str = "/app/uploads"
MAX_UPLOAD_SIZE_MB: int = 10
class Config:
env_file = ".env"
case_sensitive = True
extra = "allow"
model_config = SettingsConfigDict(env_file=".env", case_sensitive=True, extra="allow")
@lru_cache()

View File

@ -0,0 +1,2 @@
"""Durable orchestration flows."""

View File

@ -0,0 +1,77 @@
from __future__ import annotations
from typing import Any, Dict
from app.openclaw.durable_flow import DurableTaskFlow
from app.openclaw.hooks import before_agent_reply
from app.openclaw.plugins.salesforce_agentforce_plugin import SalesforceAgentforcePlugin
from app.openclaw.plugins.whatsapp_plugin import WhatsAppCloudPlugin
from app.openclaw.plugins.voice_plugin import VoiceAgentsPlugin
from app.services.email_service import email_service
from app.services.linkedin_service import linkedin_service
from app.services.predictive_revenue_service import predictive_revenue_service
from app.services.signal_selling_service import signal_selling_service
class ProspectingDurableFlow:
"""Phase-1 durable flow for multi-channel prospecting."""
def __init__(self) -> None:
self.salesforce = SalesforceAgentforcePlugin()
self.whatsapp = WhatsAppCloudPlugin()
self.voice = VoiceAgentsPlugin()
async def run(self, tenant_id: str, deal: Dict[str, Any]) -> Dict[str, Any]:
flow = DurableTaskFlow(flow_name="prospecting_crew_v1", tenant_id=tenant_id)
flow.checkpoint("start", {"deal": deal, "status": "running"})
account_360 = await self.salesforce.get_account_360(deal.get("company_name", "Unknown"))
flow.checkpoint("salesforce_grounding", {"account_360": account_360})
signals = signal_selling_service.aggregate_signals(
web_signals=deal.get("web_signals", []),
email_signals=deal.get("email_signals", []),
call_signals=deal.get("call_signals", []),
linkedin_signals=deal.get("linkedin_signals", []),
)
lead_score = predictive_revenue_service.score_signal_based_lead(deal, signals.get("top_signals", []))
flow.checkpoint("signal_scoring", {"signals": signals, "signal_score": lead_score})
approval_payload = {"approval_token": deal.get("approval_token", "")}
for action in ["send_whatsapp", "send_email", "send_linkedin", "trigger_voice_call", "sync_salesforce"]:
gate = before_agent_reply(action=action, payload=approval_payload, tenant_id=tenant_id)
if not gate["allowed"]:
flow.checkpoint("blocked", {"status": "blocked", "action": action, "reason": gate["reason"]})
return flow.as_dict()
wa = await self.whatsapp.send_message(
phone=deal.get("phone", ""),
text=deal.get("outreach_message", "مرحبا، نقدر نساعدكم في تسريع الإيرادات عبر Dealix."),
)
flow.checkpoint("whatsapp_sent", {"whatsapp": wa})
email = email_service.send_outreach_email(
company_name=deal.get("company_name", "Unknown"),
contact_person=deal.get("decision_maker", "Decision Maker"),
)
flow.checkpoint("email_sent", {"email": email})
linkedin = linkedin_service.send_connection_request(
company_name=deal.get("company_name", "Unknown"),
person_name=deal.get("decision_maker", "Sales Director"),
)
flow.checkpoint("linkedin_sent", {"linkedin": linkedin})
voice = await self.voice.trigger_call(
company_name=deal.get("company_name", "Unknown"),
phone=deal.get("phone", ""),
objective="meeting_booking_and_objection_handling",
)
flow.checkpoint("voice_triggered", {"voice": voice})
await self.salesforce.sync_opportunity({**deal, "intent_score": lead_score, "deal_stage": "QUALIFIED"})
flow.checkpoint("salesforce_synced", {"status": "completed"})
return flow.as_dict()
prospecting_durable_flow = ProspectingDurableFlow()

View File

@ -0,0 +1,26 @@
from __future__ import annotations
from typing import Any, Dict
from app.openclaw.durable_flow import DurableTaskFlow
class SelfImprovementFlow:
"""6-phase self-improvement loop v2.0 as durable flow."""
def run(self, tenant_id: str, input_state: Dict[str, Any]) -> Dict[str, Any]:
flow = DurableTaskFlow(flow_name="self_improvement_v2", tenant_id=tenant_id)
flow.checkpoint("collect_signals", {"signals": input_state.get("signals", [])})
flow.checkpoint("diagnose_bottlenecks", {"bottlenecks": input_state.get("bottlenecks", [])})
flow.checkpoint("generate_experiments", {"experiments": input_state.get("experiments", [])})
flow.checkpoint("run_ab_tests", {"ab_results": input_state.get("ab_results", {})})
flow.checkpoint(
"validate_security_governance",
{"governance_passed": input_state.get("governance_passed", True)},
)
flow.checkpoint("promote_or_rollback", {"promoted": input_state.get("promoted", True)})
flow.checkpoint("done", {"status": "completed"})
return flow.as_dict()
self_improvement_flow = SelfImprovementFlow()

View File

@ -3,29 +3,82 @@ from app.sqlite_patch import apply_patch
apply_patch()
# ──────────────────────────────────────────────────────────────
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
import asyncio
from app.config import get_settings
from app.api.v1.router import api_router
from app.flows.self_improvement_flow import self_improvement_flow
from app.middleware.internal_api import InternalApiTokenMiddleware
settings = get_settings()
def _cors_origins() -> list[str]:
base = [
settings.FRONTEND_URL,
"http://localhost:3000",
"http://localhost:5173",
"https://dealix.sa",
"https://app.dealix.sa",
]
extra = [x.strip() for x in (settings.CORS_EXTRA_ORIGINS or "").split(",") if x.strip()]
seen: set[str] = set()
out: list[str] = []
for o in base + extra:
if o not in seen:
seen.add(o)
out.append(o)
return out
def _openapi_urls() -> tuple[str | None, str | None, str | None]:
if not settings.EXPOSE_OPENAPI:
return None, None, None
return "/api/docs", "/api/redoc", "/api/openapi.json"
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application startup and shutdown events."""
stop_event = asyncio.Event()
async def _self_improvement_worker():
while not stop_event.is_set():
self_improvement_flow.run(
tenant_id="system_tenant",
input_state={
"signals": [],
"bottlenecks": [],
"experiments": [{"name": "always-on-ab-loop"}],
"ab_results": {},
"governance_passed": True,
"promoted": True,
},
)
await asyncio.sleep(max(60, settings.SELF_IMPROVEMENT_INTERVAL_SECONDS))
worker_task = asyncio.create_task(_self_improvement_worker())
# Startup
print(f"🚀 {settings.APP_NAME} ({settings.APP_NAME_AR}) starting...")
print(f"[startup] {settings.APP_NAME} 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...")
stop_event.set()
worker_task.cancel()
print(f"[shutdown] {settings.APP_NAME} shutting down...")
_docs, _redoc, _openapi = _openapi_urls()
app = FastAPI(
title=f"{settings.APP_NAME} API",
description=(
@ -34,22 +87,17 @@ app = FastAPI(
"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",
docs_url=_docs,
redoc_url=_redoc,
openapi_url=_openapi,
lifespan=lifespan,
)
# CORS
app.add_middleware(InternalApiTokenMiddleware)
# CORS runs outermost (added last) so browser preflight is handled first
app.add_middleware(
CORSMiddleware,
allow_origins=[
settings.FRONTEND_URL,
"http://localhost:3000",
"http://localhost:5173",
"https://dealix.sa",
"https://app.dealix.sa",
],
allow_origins=_cors_origins(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@ -58,17 +106,30 @@ app.add_middleware(
# API Routes
app.include_router(api_router, prefix="/api/v1")
# ── Static marketing assets (browse + direct download) ─────────
def _resolve_salesflow_root() -> Path:
if settings.MARKETING_STATIC_ROOT.strip():
return Path(settings.MARKETING_STATIC_ROOT).resolve()
# backend/app/main.py -> parents: app, backend, salesflow-saas
return Path(__file__).resolve().parent.parent.parent
# Health check (outside router for direct access)
@app.get("/api/v1/health")
async def health_check():
return {
"status": "healthy",
"app": settings.APP_NAME,
"version": "2.0.0",
"environment": settings.ENVIRONMENT,
"ai_engine": {
"primary": settings.LLM_PRIMARY_PROVIDER,
"fallback": settings.LLM_FALLBACK_PROVIDER,
},
}
_salesflow_root = _resolve_salesflow_root()
_marketing_dir = _salesflow_root / "sales_assets"
_presentations_dir = _salesflow_root / "presentations" / "dealix-2026-sectors"
if settings.MARKETING_STATIC_ENABLED:
if _marketing_dir.is_dir():
app.mount(
"/dealix-marketing",
StaticFiles(directory=str(_marketing_dir), html=True),
name="dealix_marketing",
)
print(" Marketing static: /dealix-marketing/ (index, ZIP, use cases)")
if _presentations_dir.is_dir():
app.mount(
"/dealix-presentations",
StaticFiles(directory=str(_presentations_dir), html=True),
name="dealix_presentations",
)
print(" Marketing static: /dealix-presentations/ (sector HTML)")

View File

@ -0,0 +1 @@
"""ASGI / Starlette middleware."""

View File

@ -0,0 +1,64 @@
"""Optional bearer token for /api/v1 when DEALIX_INTERNAL_API_TOKEN is set (production hardening)."""
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from app.config import get_settings
def _exempt_path(path: str) -> bool:
if path in ("/api/v1/health", "/api/v1/ready"):
return True
if path.startswith("/api/v1/webhooks"):
return True
if path.startswith("/api/v1/marketing"):
return True
if path.startswith("/api/v1/strategy"):
return True
if path.startswith("/api/v1/value-proposition"):
return True
if path.startswith("/api/v1/customer-onboarding"):
return True
# Public demo GETs only; /sales-os/quota, /tasks-inbox, PUT /quota require token when set
if path in (
"/api/v1/sales-os/commission-ledger",
"/api/v1/sales-os/rep-onboarding",
"/api/v1/sales-os/overview",
"/api/v1/operations/snapshot",
):
return True
# مسارات المسوقين العامة (تسجيل، لوحة، برنامج) دون كشف بيانات فردية حساسة
if path == "/api/v1/affiliates/program" or path == "/api/v1/affiliates/register":
return True
if path.startswith("/api/v1/affiliates/leaderboard"):
return True
return False
class InternalApiTokenMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.method == "OPTIONS":
return await call_next(request)
settings = get_settings()
token = (settings.DEALIX_INTERNAL_API_TOKEN or "").strip()
if not token:
return await call_next(request)
path = request.url.path
if not path.startswith("/api/v1"):
return await call_next(request)
if _exempt_path(path):
return await call_next(request)
auth = request.headers.get("authorization") or ""
expected = f"Bearer {token}"
if auth != expected:
return JSONResponse(
status_code=401,
content={"detail": "Invalid or missing Authorization bearer token"},
)
return await call_next(request)

View File

@ -12,6 +12,7 @@ from app.models.subscription import Subscription
from app.models.template import IndustryTemplate
from app.models.property import Property
from app.models.audit_log import AuditLog
from app.models.operations import ApprovalRequest, DomainEvent, IntegrationSyncState
from app.models.affiliate import AffiliateMarketer, AffiliatePerformance, AffiliateDeal
from app.models.ai_conversation import AIConversation, AutoBooking
from app.models.company import Company, Contact
@ -27,6 +28,7 @@ __all__ = [
"BaseModel", "TenantModel", "Tenant", "User", "Lead", "Customer",
"Deal", "Activity", "Message", "Proposal", "Notification",
"Subscription", "IndustryTemplate", "Property", "AuditLog",
"DomainEvent", "ApprovalRequest", "IntegrationSyncState",
"AffiliateMarketer", "AffiliatePerformance", "AffiliateDeal",
"AIConversation", "AutoBooking",
"Company", "Contact", "Call", "Commission", "Payout",

View File

@ -46,6 +46,7 @@ class Commission(TenantModel):
deal = relationship("Deal")
payout = relationship("Payout", back_populates="commissions")
approved_user = relationship("User", foreign_keys=[approved_by])
dispute = relationship("Dispute", foreign_keys=[dispute_id])
class Payout(BaseModel):

View File

@ -9,7 +9,7 @@ from app.config import get_settings
_settings = get_settings()
IS_SQLITE = "sqlite" in _settings.DATABASE_URL
from sqlalchemy import Column, String, Text
from sqlalchemy import Column, String, Text, TypeDecorator
if IS_SQLITE:
# ── SQLite-compatible replacements ─────────────────────────
@ -19,10 +19,27 @@ if IS_SQLITE:
def __new__(cls, as_uuid=True):
return String(36)
class JSONB:
"""Fake JSONB column that stores as Text for SQLite."""
def __new__(cls):
return Text()
class JSONB(TypeDecorator):
"""Persist dict/list as JSON text under SQLite (binds dict correctly)."""
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False)
def process_result_value(self, value, dialect):
if value is None:
return None
if isinstance(value, str):
try:
return json.loads(value)
except Exception:
return {}
return value
def default_uuid():
return str(uuid.uuid4())

View File

@ -38,7 +38,7 @@ class Dispute(TenantModel):
resolved_at = Column(DateTime(timezone=True), nullable=True)
escalated_to = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
commission = relationship("Commission")
commission = relationship("Commission", foreign_keys=[commission_id])
deal = relationship("Deal")
affiliate = relationship("AffiliateMarketer")
resolver = relationship("User", foreign_keys=[resolved_by])

View File

@ -0,0 +1,47 @@
"""Full Auto Ops: domain events, approval queue, integration connector health."""
from __future__ import annotations
from sqlalchemy import Column, String, Text, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.models.base import TenantModel
class DomainEvent(TenantModel):
__tablename__ = "domain_events"
event_type = Column(String(120), nullable=False, index=True)
payload = Column(JSONB, default=dict)
source = Column(String(50), nullable=False, default="api") # api, webhook, worker
correlation_id = Column(String(80), nullable=True, index=True)
class ApprovalRequest(TenantModel):
__tablename__ = "approval_requests"
channel = Column(String(40), nullable=False) # whatsapp, email, sms
resource_type = Column(String(80), nullable=False)
resource_id = Column(UUID(as_uuid=True), nullable=False, index=True)
payload = Column(JSONB, default=dict)
status = Column(String(20), nullable=False, default="pending") # pending, approved, rejected
requested_by_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
reviewed_by_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
reviewed_at = Column(DateTime(timezone=True), nullable=True)
note = Column(Text, nullable=True)
requested_by = relationship("User", foreign_keys=[requested_by_id])
reviewed_by = relationship("User", foreign_keys=[reviewed_by_id])
class IntegrationSyncState(TenantModel):
__tablename__ = "integration_sync_states"
__table_args__ = (UniqueConstraint("tenant_id", "connector_key", name="uq_tenant_connector"),)
connector_key = Column(String(80), nullable=False, index=True)
display_name_ar = Column(String(255), nullable=True)
last_success_at = Column(DateTime(timezone=True), nullable=True)
last_attempt_at = Column(DateTime(timezone=True), nullable=True)
status = Column(String(20), nullable=False, default="unknown") # ok, degraded, error, unknown
last_error = Column(Text, nullable=True)

View File

@ -0,0 +1,2 @@
"""OpenClaw-compatible orchestration utilities."""

View File

@ -0,0 +1,51 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List
import uuid
@dataclass
class FlowRevision:
revision_id: str
at: str
note: str
checkpoint: Dict[str, Any]
@dataclass
class DurableTaskFlow:
flow_name: str
tenant_id: str
run_id: str = field(default_factory=lambda: str(uuid.uuid4()))
checkpoints: List[FlowRevision] = field(default_factory=list)
state: Dict[str, Any] = field(default_factory=dict)
def checkpoint(self, note: str, patch: Dict[str, Any]) -> FlowRevision:
self.state.update(patch)
revision = FlowRevision(
revision_id=str(uuid.uuid4()),
at=datetime.now(timezone.utc).isoformat(),
note=note,
checkpoint=dict(self.state),
)
self.checkpoints.append(revision)
return revision
def as_dict(self) -> Dict[str, Any]:
return {
"flow_name": self.flow_name,
"tenant_id": self.tenant_id,
"run_id": self.run_id,
"state": self.state,
"revisions": [
{
"revision_id": r.revision_id,
"at": r.at,
"note": r.note,
"checkpoint": r.checkpoint,
}
for r in self.checkpoints
],
}

View File

@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Any, Dict
SENSITIVE_ACTIONS = {
"send_whatsapp",
"send_email",
"send_linkedin",
"trigger_voice_call",
"sync_salesforce",
"create_contract",
"send_contract_for_signature",
"create_charge",
}
def before_agent_reply(action: str, payload: Dict[str, Any], tenant_id: str) -> Dict[str, Any]:
"""
OpenClaw-style governance hook.
Blocks sensitive actions when tenant isolation or approvals are missing.
"""
if not tenant_id:
return {"allowed": False, "reason": "missing_tenant_id"}
if action in SENSITIVE_ACTIONS:
if not payload.get("approval_token"):
return {"allowed": False, "reason": f"approval_required:{action}"}
if payload.get("cross_tenant_context"):
return {"allowed": False, "reason": "cross_tenant_context_blocked"}
return {"allowed": True, "reason": "ok"}

View File

@ -0,0 +1,2 @@
"""OpenClaw plugin boundary implementations."""

View File

@ -0,0 +1,24 @@
from __future__ import annotations
from typing import Any, Dict
from app.services.esign_service import esign_service
class ContractIntelligencePlugin:
name = "contract-intelligence"
async def generate_contract(self, deal: Dict[str, Any]) -> Dict[str, Any]:
company = deal.get("company_name", "Unknown")
return {
"status": "drafted",
"document_id": f"contract-{company.lower().replace(' ', '-')}",
"summary": f"AI-generated contract draft for {company}",
}
async def request_signature(self, document_id: str, provider: str = "docusign") -> Dict[str, Any]:
return await esign_service.send_for_signature(
document_name=document_id,
signer_email="procurement@example.com",
provider=provider,
)

View File

@ -0,0 +1,15 @@
from __future__ import annotations
from typing import Any, Dict
from app.services.salesforce_agentforce import agentforce_service
class SalesforceAgentforcePlugin:
name = "salesforce-agentforce"
async def get_account_360(self, account_name: str) -> Dict[str, Any]:
return await agentforce_service.get_account_360(account_name)
async def sync_opportunity(self, deal_state: Dict[str, Any]) -> bool:
return await agentforce_service.sync_deal(deal_state)

View File

@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Dict, Any
from app.services.stripe_service import stripe_service
class StripeBillingPlugin:
name = "stripe-billing"
async def create_charge(self, customer_id: str, amount_sar: int) -> Dict[str, Any]:
response = await stripe_service.create_payment_intent(amount_sar, customer_id)
return {"provider": "stripe", "customer_id": customer_id, "amount_sar": amount_sar, "response": response}

View File

@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Dict, Any
from app.services.voice_service import voice_service
class VoiceAgentsPlugin:
name = "voice-agents"
async def trigger_call(self, company_name: str, phone: str, objective: str) -> Dict[str, Any]:
result = await voice_service.trigger_sales_call(phone, objective)
return {"channel": "voice", "company_name": company_name, "phone": phone, "objective": objective, "provider_result": result}

View File

@ -0,0 +1,13 @@
from __future__ import annotations
from typing import Dict, Any
from app.integrations.whatsapp import send_whatsapp_message
class WhatsAppCloudPlugin:
name = "whatsapp-cloud"
async def send_message(self, phone: str, text: str) -> Dict[str, Any]:
result = await send_whatsapp_message(phone, text)
return {"channel": "whatsapp", "phone": phone, "provider_result": result}

View File

@ -1,4 +1,4 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional
from uuid import UUID
from datetime import datetime
@ -26,6 +26,8 @@ class LeadUpdate(BaseModel):
class LeadResponse(BaseModel):
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
id: UUID
tenant_id: UUID
name: str
@ -35,13 +37,15 @@ class LeadResponse(BaseModel):
status: str
score: int
notes: Optional[str]
metadata: Optional[dict]
metadata: Optional[dict] = Field(
default=None,
validation_alias="extra_metadata",
serialization_alias="metadata",
)
assigned_to: Optional[UUID]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class LeadListResponse(BaseModel):
items: list[LeadResponse]

View File

@ -0,0 +1,80 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from uuid import UUID
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.audit_log import AuditLog
async def record_audit(
db: AsyncSession,
*,
tenant_id: UUID,
user_id: Optional[UUID],
action: str,
entity_type: str,
entity_id: Optional[UUID],
changes: Optional[Dict[str, Any]] = None,
ip: Optional[str] = None,
) -> AuditLog:
row = AuditLog(
tenant_id=tenant_id,
user_id=user_id,
action=action,
entity_type=entity_type,
entity_id=entity_id,
changes=changes or {},
ip_address=ip,
)
db.add(row)
await db.flush()
return row
async def count_audits_since(
db: AsyncSession,
tenant_id: UUID,
hours: int = 24,
) -> int:
from datetime import datetime, timedelta, timezone
since = datetime.now(timezone.utc) - timedelta(hours=hours)
q = await db.execute(
select(func.count()).select_from(AuditLog).where(
AuditLog.tenant_id == tenant_id,
AuditLog.created_at >= since,
)
)
return int(q.scalar() or 0)
async def list_recent_audits(
db: AsyncSession,
tenant_id: UUID,
*,
limit: int = 50,
):
q = await db.execute(
select(AuditLog)
.where(AuditLog.tenant_id == tenant_id)
.order_by(AuditLog.created_at.desc())
.limit(limit)
)
rows = q.scalars().all()
out = []
for a in rows:
out.append(
{
"id": str(a.id),
"action": a.action,
"entity_type": a.entity_type,
"entity_id": str(a.entity_id) if a.entity_id else None,
"user_id": str(a.user_id) if a.user_id else None,
"changes": a.changes,
"created_at": a.created_at.isoformat() if a.created_at else None,
}
)
return out

View File

@ -9,14 +9,13 @@ 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
from app.utils import security as security_utils
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
class AuthService:
@ -29,11 +28,11 @@ class AuthService:
@staticmethod
def hash_password(password: str) -> str:
return pwd_context.hash(password)
return security_utils.hash_password(password)
@staticmethod
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
return security_utils.verify_password(plain, hashed)
# ── JWT Tokens ────────────────────────────────

View File

@ -0,0 +1,18 @@
from __future__ import annotations
from typing import Any, Dict
from app.openclaw.plugins.contract_intelligence_plugin import ContractIntelligencePlugin
class ContractIntelligenceService:
def __init__(self) -> None:
self.plugin = ContractIntelligencePlugin()
async def generate_and_send(self, deal: Dict[str, Any], provider: str = "docusign") -> Dict[str, Any]:
draft = await self.plugin.generate_contract(deal)
signature = await self.plugin.request_signature(draft["document_id"], provider=provider)
return {"draft": draft, "signature": signature}
contract_intelligence_service = ContractIntelligenceService()

View File

@ -0,0 +1,274 @@
"""
Structured B2B customer journey: roles, steps, WhatsApp milestones, agent ownership.
Used by GET /api/v1/customer-onboarding/journey and acceptance-test docs.
"""
from __future__ import annotations
from typing import Any, Dict, List
def build_journey() -> Dict[str, Any]:
return {
"product": "Dealix",
"version": "1.0",
"summary_ar": (
"رحلة عميل مدفوع من التعاقد إلى تشغيل كامل لنظام التشغيل الإيرادي (OS): "
"كل مرحلة تسمّي مالكاً عند العميل، ووكيلاً ذكائياً أو بشرياً من Dealix، ونقاط تحقق على واتساب."
),
"roles": [
{
"id": "economic_buyer",
"title_ar": "صاحب القرار المالي / المدير التنفيذي",
"responsibility_ar": "الموافقة على النطاق، الميزانية، وترتيب مسؤول تقني داخلي.",
},
{
"id": "technical_owner",
"title_ar": "مسؤول الربط التقني (IT / مطور)",
"responsibility_ar": "توفير مفاتيح API، DNS، SSL، وصول Salesforce/Stripe، اختبار Webhooks.",
},
{
"id": "channel_owner",
"title_ar": "مسؤول القنوات (واتساب / مبيعات)",
"responsibility_ar": "حساب Meta Business، أرقام معتمدة، اختبار قوالب الرسائل.",
},
{
"id": "dealix_success",
"title_ar": "مدير نجاح العميل (Dealix) — بشري",
"responsibility_ar": "تنسيق الجدول، جلسات التحقق، تصعيد الانحرافات عن الخطة.",
},
{
"id": "integration_concierge_agent",
"title_ar": "وكيل دمج ذكي (Integration Concierge)",
"responsibility_ar": "شرح الخطوات، تذكير، تلخيص حالة الربط، إجابة FAQ تشغيلية — عبر الواجهة أو واتساب داخلي.",
},
],
"phases": [
_phase(
"p0_contract",
"التعاقد والتسليم",
[
_step(
"s0_1",
"توقيع العقد وتحديد نطاق المستأجرين والقطاع",
"economic_buyer",
["dealix_success"],
["الموافقة على SOW", "عدد الفروع/المستخدمين"],
"واتساب: رسالة ترحيب رسمية + رابط بوابة العميل.",
),
_step(
"s0_2",
"تعيين مسؤول تقني + مسؤول قنوات + قناة واتساب للمشروع",
"economic_buyer",
["dealix_success", "integration_concierge_agent"],
["أسماء، أرقام جوال، بريد"],
"واتساب مجموعة مشروع (اختياري) مع Dealix.",
),
],
),
_phase(
"p1_platform",
"التأسيس على المنصة",
[
_step(
"s1_1",
"بيئة الإنتاج: خادم، Docker/Compose أو K8s، قاعدة بيانات، Redis",
"technical_owner",
["integration_concierge_agent"],
["عنوان API عام، HTTPS، SECRET_KEY"],
None,
),
_step(
"s1_2",
"متغيرات البيئة: LLM، بريد، قاعدة بيانات",
"technical_owner",
["integration_concierge_agent"],
["مفاتيح آمنة خارج Git"],
None,
),
_step(
"s1_3",
"فحص الجاهزية التلقائي: go-live-gate + live-readiness",
"technical_owner",
["dealix_success"],
["تقرير JSON أو لقطة شاشة ناجحة"],
"واتساب: ملخص PASS/FAIL للبنود الحرجة.",
),
],
),
_phase(
"p2_integrations",
"التكاملات — CRM، مدفوعات، توقيع",
[
_step(
"s2_1",
"Salesforce Connected App + refresh token",
"technical_owner",
["integration_concierge_agent"],
["بيانات الدومين، OAuth"],
None,
),
_step(
"s2_2",
"Stripe: مفاتيح live/test + webhook",
"technical_owner",
["integration_concierge_agent"],
["سر webhook، مسار عام"],
None,
),
_step(
"s2_3",
"توقيع إلكتروني (DocuSign أو Adobe)",
"technical_owner",
["dealix_success"],
["رمز وصول أو تكامل"],
None,
),
],
),
_phase(
"p3_whatsapp",
"واتساب للأعمال",
[
_step(
"s3_1",
"Meta Business: تطبيق واتساب + رقم معتمد",
"channel_owner",
["integration_concierge_agent", "dealix_success"],
["WHATSAPP_* tokens", "WHATSAPP_MOCK_MODE=false"],
"واتساب: اختبار رسالة صادرة من النظام.",
),
_step(
"s3_2",
"Webhook عام + تحقق Meta",
"technical_owner",
["integration_concierge_agent"],
["VERIFY_TOKEN، URL عام HTTPS"],
"واتساب: اشتراك webhook ناجح.",
),
],
),
_phase(
"p4_voice_email",
"صوت وبريد",
[
_step(
"s4_1",
"Twilio صوت (اختياري حسب الباقة)",
"technical_owner",
["integration_concierge_agent"],
["TWILIO_*"],
None,
),
_step(
"s4_2",
"بريد صادر SendGrid أو SMTP",
"technical_owner",
["integration_concierge_agent"],
["SPF/DKIM عند النطاق"],
None,
),
],
),
_phase(
"p5_go_live",
"الإطلاق والتشغيل",
[
_step(
"s5_1",
"مواءمة NEXT_PUBLIC_API_URL مع عنوان API العام",
"technical_owner",
["integration_concierge_agent"],
["واجهة تعمل بدون CORS errors"],
"واتساب: إعلان جاهزية القناة للفريق الداخلي.",
),
_step(
"s5_2",
"تدريب سريع لمسؤول القنوات على لوحة التحكم",
"channel_owner",
["dealix_success", "onboarding_coach"],
["جلسة مسجلة أو مباشرة"],
None,
),
_step(
"s5_3",
"مراجعة أسبوعية: مؤشرات، أخطاء، تحسين",
"economic_buyer",
["dealix_success"],
["اجتماع نجاح عملاء"],
"واتساب: تذكير أسبوعي آلي من الوكيل (إن وُجدت أتمتة).",
),
],
),
],
"agent_registry_refs": {
"integration_concierge_agent": "ai-agents/prompts/customer-integration-concierge.md",
"onboarding_coach": "ai-agents/prompts/affiliate-onboarding-coach.md",
"arabic_whatsapp": "ai-agents/prompts/arabic-whatsapp-agent.md",
},
"full_os_gaps_ar": [
"وكيل موحّد يربط حالة go-live gate بحالة محادثة واتساب (حلقة مغلقة) — جزئي عبر التقارير، يحتاج أتمتة إشعار.",
"مسار SLA بشري واضح عند فشل خطوة لأكثر من X ساعات — إشعار مدير النجاح.",
"اختبار تحميل وقبول UAT موثّق كقالب لكل عميل — قيد التطوير.",
],
}
def _phase(phase_id: str, title_ar: str, steps: List[Dict[str, Any]]) -> Dict[str, Any]:
return {"id": phase_id, "title_ar": title_ar, "steps": steps}
def _step(
step_id: str,
title_ar: str,
primary_owner_role: str,
supporting_agents: List[str],
customer_inputs_ar: List[str],
whatsapp_milestone_ar: str | None,
) -> Dict[str, Any]:
return {
"id": step_id,
"title_ar": title_ar,
"primary_owner_role": primary_owner_role,
"supporting_agents": supporting_agents,
"customer_must_provide_ar": customer_inputs_ar,
"whatsapp_milestone_ar": whatsapp_milestone_ar,
}
def build_acceptance_test_checklist() -> Dict[str, Any]:
"""Human + automated checklist «كأنك عميل»."""
return {
"title_ar": "اختبار قبول تشغيل Dealix (عميل)",
"sections": [
{
"id": "prep",
"title_ar": "ما يجب أن يجهّزه العميل قبل الاتصال",
"items": [
"نطاق أو عنوان API عام على HTTPS",
"مسؤول تقني متاح لـ DNS وSSL والأسرار",
"حساب Meta Business جاهز إن وُجدت قناة واتساب",
"قرار ميزانية للاشتراكات (Stripe) إن لزم",
],
},
{
"id": "automated",
"title_ar": "فحوص آلية (من الخادم)",
"items": [
"GET /api/v1/health",
"GET /api/v1/ready (قاعدة البيانات)",
"GET /api/v1/autonomous-foundation/integrations/go-live-gate",
"POST /api/v1/autonomous-foundation/integrations/connectivity-test",
],
},
{
"id": "manual",
"title_ar": "فحوص يدوية",
"items": [
"إرسال رسالة واتساب تجريبية والاستلام",
"تسجيل دخول Salesforce اختبار قراءة سجل",
"حدث Stripe تجريبي (إن أمكن)",
],
},
],
}

View File

@ -0,0 +1,19 @@
import logging
logger = logging.getLogger(__name__)
class EmailService:
@staticmethod
def send_outreach_email(company_name: str, contact_person: str = "Decision Maker"):
"""
Simulates sending a highly personalized B2B outreach email.
In production, this integrates with Resend, SendGrid, or Mailgun.
"""
subject = f"شراكة استراتيجية مقترحة لشركة {company_name}"
body = f"مرحباً {contact_person},\n\nنحن في Dealix نتابع نمو {company_name} الرائع..."
logger.info(f"📧 [EmailService] Sending outreach to {company_name} | Subject: {subject}")
# Integration logic here
return {"status": "sent", "provider": "Resend", "message_id": "re_123456789"}
email_service = EmailService()

View File

@ -0,0 +1,42 @@
from __future__ import annotations
from typing import Any, Dict
import httpx
from app.config import get_settings
settings = get_settings()
class ESignService:
async def send_for_signature(self, document_name: str, signer_email: str, provider: str = "docusign") -> Dict[str, Any]:
if provider == "adobe":
return await self._adobe_send(document_name, signer_email)
return await self._docusign_send(document_name, signer_email)
async def _docusign_send(self, document_name: str, signer_email: str) -> Dict[str, Any]:
if not settings.DOCUSIGN_ACCESS_TOKEN:
return {"status": "mock", "provider": "docusign", "document_name": document_name, "signer_email": signer_email}
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
f"{settings.DOCUSIGN_API_URL}/v2.1/accounts/me/envelopes",
headers={"Authorization": f"Bearer {settings.DOCUSIGN_ACCESS_TOKEN}", "Content-Type": "application/json"},
json={"emailSubject": f"Signature Request: {document_name}", "status": "sent"},
)
resp.raise_for_status()
return {"status": "sent", "provider": "docusign", "response": resp.json()}
async def _adobe_send(self, document_name: str, signer_email: str) -> Dict[str, Any]:
if not settings.ADOBE_SIGN_ACCESS_TOKEN:
return {"status": "mock", "provider": "adobe", "document_name": document_name, "signer_email": signer_email}
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
f"{settings.ADOBE_SIGN_API_URL}/agreements",
headers={"Authorization": f"Bearer {settings.ADOBE_SIGN_ACCESS_TOKEN}", "Content-Type": "application/json"},
json={"name": document_name, "state": "IN_PROCESS"},
)
resp.raise_for_status()
return {"status": "sent", "provider": "adobe", "response": resp.json()}
esign_service = ESignService()

View File

@ -0,0 +1,20 @@
from __future__ import annotations
from typing import Any, Dict
class ExecutiveROIService:
def build_snapshot(self, baseline: Dict[str, Any], current: Dict[str, Any]) -> Dict[str, Any]:
baseline_revenue = float(baseline.get("revenue", 0))
current_revenue = float(current.get("revenue", 0))
lift = 0.0 if baseline_revenue == 0 else ((current_revenue - baseline_revenue) / baseline_revenue) * 100.0
return {
"revenue_lift_percent": round(lift, 2),
"win_rate": current.get("win_rate", 0),
"pipeline_velocity_days": current.get("pipeline_velocity_days", 0),
"manual_work_reduction_percent": current.get("manual_work_reduction_percent", 0),
"summary": "Executive snapshot generated for CEO dashboard.",
}
executive_roi_service = ExecutiveROIService()

View File

@ -0,0 +1,377 @@
"""
Full commercial go-live matrix: env checks, categories, blocking vs optional.
Used by /integrations/go-live-gate and /integrations/live-readiness.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Any, Callable, Dict, List, Tuple
from app.config import Settings
@dataclass(frozen=True)
class CheckDef:
id: str
category: str
category_ar: str
label_ar: str
env_vars: str
blocking: bool
hint_ar: str
DEFAULT_SECRET = "change-this-to-a-random-secret-key"
def _pass(v: bool) -> str:
return "PASS" if v else "FAIL"
def build_check_definitions() -> List[CheckDef]:
"""Human-facing catalog; order = display order."""
return [
CheckDef(
"secret_key",
"security",
"الأمان والأساس",
"SECRET_KEY قوي (ليس القيمة الافتراضية)",
"SECRET_KEY",
True,
"استخدم مفتاحاً عشوائياً طويلاً في الإنتاج؛ لا ترفع الملف إلى Git.",
),
CheckDef(
"environment_production",
"security",
"الأمان والأساس",
"ENVIRONMENT=production للإطلاق التجاري",
"ENVIRONMENT",
False,
"يُفضّل production على الخادم العام؛ development يُسجّل كتحذير فقط.",
),
CheckDef(
"database_url",
"data",
"البيانات",
"DATABASE_URL مُعرّف",
"DATABASE_URL",
True,
"PostgreSQL بسلسلة asyncpg؛ راجع النسخ الاحتياطي والعزل.",
),
CheckDef(
"llm_configured",
"intelligence",
"الذكاء والنماذج",
"مفتاح مزود نموذج واحد على الأقل (Groq، OpenAI، Anthropic، DeepSeek، Z.ai، Gemini)",
"GROQ_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY / …",
True,
"مطلوب لتشغيل الوكلاء؛ model_router يختار المزود حسب التوفر.",
),
CheckDef(
"email_outbound",
"channels",
"القنوات والتواصل",
"بريد صادر (SendGrid أو SMTP كامل)",
"SENDGRID_API_KEY أو SMTP_USER+SMTP_PASSWORD",
True,
"للإشعارات والعروض والترحيب؛ SendGrid أو SMTP موثوق.",
),
CheckDef(
"salesforce_client_id",
"crm",
"CRM و Salesforce",
"SALESFORCE_CLIENT_ID",
"SALESFORCE_CLIENT_ID",
True,
"مفتاح تطبيق Connected App في Salesforce.",
),
CheckDef(
"salesforce_client_secret",
"crm",
"CRM و Salesforce",
"SALESFORCE_CLIENT_SECRET",
"SALESFORCE_CLIENT_SECRET",
True,
"سر التطبيق من نفس Connected App.",
),
CheckDef(
"salesforce_refresh_token",
"crm",
"CRM و Salesforce",
"SALESFORCE_REFRESH_TOKEN",
"SALESFORCE_REFRESH_TOKEN",
True,
"من OAuth web-server flow بصلاحيات API المطلوبة.",
),
CheckDef(
"salesforce_domain",
"crm",
"CRM و Salesforce",
"SALESFORCE_DOMAIN (مثال login.salesforce.com)",
"SALESFORCE_DOMAIN",
True,
"نطاق تسجيل الدخول أو My Domain.",
),
CheckDef(
"whatsapp_api_token",
"channels",
"القنوات والتواصل",
"WHATSAPP_API_TOKEN",
"WHATSAPP_API_TOKEN",
True,
"رمز Meta Graph API لواتساب الأعمال.",
),
CheckDef(
"whatsapp_phone_number_id",
"channels",
"القنوات والتواصل",
"WHATSAPP_PHONE_NUMBER_ID",
"WHATSAPP_PHONE_NUMBER_ID",
True,
"من لوحة Meta Business → واتساب.",
),
CheckDef(
"whatsapp_verify_token",
"channels",
"القنوات والتواصل",
"WHATSAPP_VERIFY_TOKEN (Webhook)",
"WHATSAPP_VERIFY_TOKEN",
True,
"للتحقق من Webhook واتساب على رابط الـ API العام.",
),
CheckDef(
"whatsapp_not_mock",
"channels",
"القنوات والتواصل",
"WHATSAPP_MOCK_MODE=false للإرسال الحقيقي",
"WHATSAPP_MOCK_MODE",
True,
"عطّل الوضع التجريبي عند جاهزية الرموز الحقيقية.",
),
CheckDef(
"stripe_secret_key",
"billing",
"المدفوعات والفوترة",
"STRIPE_SECRET_KEY",
"STRIPE_SECRET_KEY",
True,
"مفتاح سري live أو test حسب البيئة.",
),
CheckDef(
"stripe_webhook_secret",
"billing",
"المدفوعات والفوترة",
"STRIPE_WEBHOOK_SECRET",
"STRIPE_WEBHOOK_SECRET",
True,
"للتحقق من توقيع webhooks الفواتير والاشتراكات.",
),
CheckDef(
"twilio_account_sid",
"voice",
"الصوت والمكالمات",
"TWILIO_ACCOUNT_SID",
"TWILIO_ACCOUNT_SID",
True,
"حساب Twilio للمكالمات الصادرة/الواردة.",
),
CheckDef(
"twilio_auth_token",
"voice",
"الصوت والمكالمات",
"TWILIO_AUTH_TOKEN",
"TWILIO_AUTH_TOKEN",
True,
"رمز Twilio Auth.",
),
CheckDef(
"twilio_from_number",
"voice",
"الصوت والمكالمات",
"TWILIO_FROM_NUMBER (E.164)",
"TWILIO_FROM_NUMBER",
True,
"رقم معتمد للاتصال الصادر.",
),
CheckDef(
"esign_provider",
"contracts",
"العقود والتوقيع",
"DocuSign أو Adobe Sign (رمز وصول)",
"DOCUSIGN_ACCESS_TOKEN / ADOBE_SIGN_ACCESS_TOKEN",
True,
"مطلوب لتدفقات التوقيع الإلكتروني.",
),
CheckDef(
"api_public_url",
"ops",
"التشغيل والروابط",
"API_URL يشير إلى عنوان الخادم العام",
"API_URL",
False,
"للروابط في الويبهوكات والوثائق؛ يجب أن يكون HTTPS في الإنتاج.",
),
CheckDef(
"frontend_url",
"ops",
"التشغيل والروابط",
"FRONTEND_URL لـ CORS والروابط",
"FRONTEND_URL",
False,
"واجهة Dealix؛ يجب تطابق النطاق في الإنتاج.",
),
CheckDef(
"hubspot_optional",
"integrations",
"تكاملات إضافية",
"HubSpot (اختياري)",
"HUBSPOT_API_KEY",
False,
"إن وُجدت تستخدم للمزامنة الاختيارية.",
),
CheckDef(
"unifonic_optional",
"integrations",
"تكاملات إضافية",
"Unifonic SMS (اختياري)",
"UNIFONIC_APP_SID",
False,
"رسائل SMS للسوق السعودي.",
),
CheckDef(
"linkedin_enrichment_optional",
"integrations",
"تكاملات إضافية",
"RapidAPI / إثراء (اختياري)",
"RAPIDAPI_KEY",
False,
"لإثراء العملاء المحتملين عند التفعيل.",
),
]
def evaluate_checks(settings: Settings) -> Dict[str, str]:
s = settings
sendgrid_ok = bool(s.SENDGRID_API_KEY and s.SENDGRID_API_KEY.strip())
smtp_ok = bool(s.SMTP_USER and s.SMTP_PASSWORD)
esign_ok = bool(
(s.DOCUSIGN_ACCESS_TOKEN and s.DOCUSIGN_ACCESS_TOKEN.strip())
or (s.ADOBE_SIGN_ACCESS_TOKEN and s.ADOBE_SIGN_ACCESS_TOKEN.strip())
)
return {
"secret_key": _pass(bool(s.SECRET_KEY and s.SECRET_KEY != DEFAULT_SECRET)),
"environment_production": _pass(getattr(s, "ENVIRONMENT", "") == "production"),
"database_url": _pass(bool(s.DATABASE_URL and s.DATABASE_URL.strip())),
"llm_configured": _pass(
bool(
(s.GROQ_API_KEY and s.GROQ_API_KEY.strip())
or (s.OPENAI_API_KEY and s.OPENAI_API_KEY.strip())
or (s.ANTHROPIC_API_KEY and s.ANTHROPIC_API_KEY.strip())
or (s.DEEPSEEK_API_KEY and s.DEEPSEEK_API_KEY.strip())
or (s.ZAI_API_KEY and s.ZAI_API_KEY.strip())
or (s.GOOGLE_API_KEY and s.GOOGLE_API_KEY.strip())
)
),
"email_outbound": _pass(sendgrid_ok or smtp_ok),
"salesforce_client_id": _pass(bool(s.SALESFORCE_CLIENT_ID)),
"salesforce_client_secret": _pass(bool(s.SALESFORCE_CLIENT_SECRET)),
"salesforce_refresh_token": _pass(bool(s.SALESFORCE_REFRESH_TOKEN)),
"salesforce_domain": _pass(bool(s.SALESFORCE_DOMAIN)),
"whatsapp_api_token": _pass(bool(s.WHATSAPP_API_TOKEN)),
"whatsapp_phone_number_id": _pass(bool(s.WHATSAPP_PHONE_NUMBER_ID)),
"whatsapp_verify_token": _pass(bool(s.WHATSAPP_VERIFY_TOKEN)),
"whatsapp_not_mock": _pass(not getattr(s, "WHATSAPP_MOCK_MODE", True)),
"stripe_secret_key": _pass(bool(s.STRIPE_SECRET_KEY)),
"stripe_webhook_secret": _pass(bool(s.STRIPE_WEBHOOK_SECRET)),
"twilio_account_sid": _pass(bool(s.TWILIO_ACCOUNT_SID)),
"twilio_auth_token": _pass(bool(s.TWILIO_AUTH_TOKEN)),
"twilio_from_number": _pass(bool(s.TWILIO_FROM_NUMBER)),
"esign_provider": _pass(esign_ok),
"api_public_url": _pass(bool(s.API_URL and str(s.API_URL).strip())),
"frontend_url": _pass(bool(s.FRONTEND_URL)),
"hubspot_optional": _pass(bool(s.HUBSPOT_API_KEY)),
"unifonic_optional": _pass(bool(s.UNIFONIC_APP_SID)),
"linkedin_enrichment_optional": _pass(bool(s.RAPIDAPI_KEY)),
}
def build_matrix_report(settings: Settings) -> Dict[str, Any]:
definitions = build_check_definitions()
checks = evaluate_checks(settings)
# Ensure every defined id has a status (fallback FAIL)
for d in definitions:
if d.id not in checks:
checks[d.id] = "FAIL"
blocking_defs = [d for d in definitions if d.blocking]
optional_defs = [d for d in definitions if not d.blocking]
passed_blocking = sum(1 for d in blocking_defs if checks.get(d.id) == "PASS")
total_blocking = len(blocking_defs)
passed_all = sum(1 for d in definitions if checks.get(d.id) == "PASS")
total_all = len(definitions)
readiness_blocking = round(100.0 * passed_blocking / total_blocking, 2) if total_blocking else 0.0
readiness_total = round(100.0 * passed_all / total_all, 2) if total_all else 0.0
missing: List[Dict[str, Any]] = []
for d in definitions:
if checks.get(d.id) != "PASS":
missing.append(
{
"check_id": d.id,
"env_var": d.env_vars,
"hint": d.hint_ar,
"label_ar": d.label_ar,
"category": d.category,
"category_ar": d.category_ar,
"blocking": d.blocking,
}
)
missing_blocking = [m for m in missing if m["blocking"]]
launch_allowed = passed_blocking == total_blocking and total_blocking > 0
categories: Dict[str, Dict[str, Any]] = {}
for d in definitions:
cat = d.category_ar
if cat not in categories:
categories[cat] = {"passed": 0, "total": 0, "items": []}
categories[cat]["total"] += 1
if checks.get(d.id) == "PASS":
categories[cat]["passed"] += 1
categories[cat]["items"].append(
{
"id": d.id,
"label_ar": d.label_ar,
"status": checks.get(d.id, "FAIL"),
"blocking": d.blocking,
}
)
return {
"checks": checks,
"definitions": [asdict(d) for d in definitions],
"categories": categories,
"blocking": {
"passed": passed_blocking,
"total": total_blocking,
"readiness_percent": readiness_blocking,
},
"full_matrix": {
"passed": passed_all,
"total": total_all,
"readiness_percent": readiness_total,
},
"missing": missing_blocking,
"missing_optional": [m for m in missing if not m["blocking"]],
"missing_count": len(missing_blocking),
"missing_optional_count": len([m for m in missing if not m["blocking"]]),
"launch_allowed": launch_allowed,
"readiness_percent": readiness_blocking,
"readiness_percent_total": readiness_total,
"passed_count": passed_blocking,
"total_count": total_blocking,
"score": f"{passed_blocking}/{total_blocking}",
}

View File

@ -0,0 +1,18 @@
import logging
logger = logging.getLogger(__name__)
class LinkedInService:
@staticmethod
def send_connection_request(company_name: str, person_name: str = "Sales Director"):
"""
Simulates sending a LinkedIn connection request and follow-up message.
In production, this integrates with LinkedIn API or Browser Automation.
"""
message = f"Hello {person_name}, we are Dealix, the first Saudi Revenue OS. We see {company_name} is growing, let's talk!"
logger.info(f"🔗 [LinkedInService] Sending connection request to {person_name} at {company_name}")
# Integration logic here
return {"status": "request_sent", "provider": "LinkedIn-Automation", "target": person_name}
linkedin_service = LinkedInService()

View File

@ -0,0 +1,143 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.operations import ApprovalRequest, DomainEvent, IntegrationSyncState
async def emit_domain_event(
db: AsyncSession,
*,
tenant_id: UUID,
event_type: str,
payload: Dict[str, Any],
source: str = "api",
correlation_id: Optional[str] = None,
) -> DomainEvent:
row = DomainEvent(
tenant_id=tenant_id,
event_type=event_type,
payload=payload,
source=source,
correlation_id=correlation_id,
)
db.add(row)
await db.flush()
return row
async def count_events_since(
db: AsyncSession,
tenant_id: UUID,
hours: int = 24,
) -> int:
since = datetime.now(timezone.utc) - timedelta(hours=hours)
q = await db.execute(
select(func.count()).select_from(DomainEvent).where(
DomainEvent.tenant_id == tenant_id,
DomainEvent.created_at >= since,
)
)
return int(q.scalar() or 0)
async def count_pending_approvals(db: AsyncSession, tenant_id: UUID) -> int:
q = await db.execute(
select(func.count()).select_from(ApprovalRequest).where(
ApprovalRequest.tenant_id == tenant_id,
ApprovalRequest.status == "pending",
)
)
return int(q.scalar() or 0)
_DEFAULT_CONNECTORS: List[Dict[str, str]] = [
{"connector_key": "crm_salesforce", "display_name_ar": "Salesforce CRM", "status": "unknown"},
{"connector_key": "whatsapp_cloud", "display_name_ar": "واتساب Cloud API", "status": "unknown"},
{"connector_key": "stripe_billing", "display_name_ar": "Stripe — الفوترة", "status": "unknown"},
{"connector_key": "email_sync", "display_name_ar": "مزامنة البريد", "status": "unknown"},
]
async def ensure_default_connectors(db: AsyncSession, tenant_id: UUID) -> None:
existing = (
await db.execute(select(IntegrationSyncState.connector_key).where(IntegrationSyncState.tenant_id == tenant_id))
).scalars().all()
have = set(existing)
for row in _DEFAULT_CONNECTORS:
if row["connector_key"] not in have:
db.add(
IntegrationSyncState(
tenant_id=tenant_id,
connector_key=row["connector_key"],
display_name_ar=row["display_name_ar"],
status=row["status"],
)
)
await db.flush()
async def list_integration_connectors(db: AsyncSession, tenant_id: UUID) -> List[Dict[str, Any]]:
await ensure_default_connectors(db, tenant_id)
q = await db.execute(
select(IntegrationSyncState).where(IntegrationSyncState.tenant_id == tenant_id).order_by(IntegrationSyncState.connector_key)
)
out = []
for row in q.scalars().all():
out.append(
{
"connector_key": row.connector_key,
"display_name_ar": row.display_name_ar,
"status": row.status,
"last_success_at": row.last_success_at.isoformat() if row.last_success_at else None,
"last_attempt_at": row.last_attempt_at.isoformat() if row.last_attempt_at else None,
"last_error": (row.last_error or "")[:500] if row.last_error else None,
}
)
return out
async def upsert_connector_status(
db: AsyncSession,
tenant_id: UUID,
connector_key: str,
*,
status: str,
last_error: Optional[str] = None,
success: bool = False,
) -> None:
await ensure_default_connectors(db, tenant_id)
q = await db.execute(
select(IntegrationSyncState).where(
IntegrationSyncState.tenant_id == tenant_id,
IntegrationSyncState.connector_key == connector_key,
)
)
row = q.scalar_one_or_none()
now = datetime.now(timezone.utc)
if not row:
row = IntegrationSyncState(
tenant_id=tenant_id,
connector_key=connector_key,
status=status,
last_attempt_at=now,
)
if success:
row.last_success_at = now
elif last_error is not None:
row.last_error = last_error
db.add(row)
else:
row.status = status
row.last_attempt_at = now
if last_error is not None:
row.last_error = last_error
if success:
row.last_success_at = now
row.last_error = None
await db.flush()

View File

@ -0,0 +1,67 @@
import logging
import asyncio
import re
from typing import List, Dict
logger = logging.getLogger(__name__)
class SocialOSINTService:
"""
Advanced Open Source Intelligence Service.
Analyzes social signals to find high-intent B2B leads in Saudi Arabia.
"""
KEYWORD_INTENT_MAP = {
"expansion": ["فرع جديد", "توسع", "new branch", "opening soon"],
"hiring": ["مطلوب", "وظائف", "هيا بنا نعمل", "hiring", "jobs"],
"seeking": ["نبحث عن", "looking for", "مطلوب مورد", "RFQ"],
"complaint": ["مشكلة في", "bad service", "alternative to", "بديل لـ"]
}
@staticmethod
async def search_x_signals(query: str) -> List[Dict]:
"""
Simulates deep searching on X (Twitter) for B2B intent signals.
In production: Integrates with Apify X Scraper or Official API.
"""
logger.info(f"🕵️ [OSINT] Scraping X signals for: {query}")
await asyncio.sleep(1) # Simulating network latency
# Mocking a high-intent signal from a real company
return [
{
"platform": "X (Twitter)",
"actor": "TechSolutions_KSA",
"content": "نبحث عن محرك مبيعات رقمي لأتمتة عملياتنا في الرياض. أي اقتراحات؟",
"intent": "seeking",
"timestamp": "2026-04-02",
"score": 92
}
]
@staticmethod
async def search_instagram_signals(query: str) -> List[Dict]:
"""
Simulates Instagram bio/post analysis for Saudi business signals.
"""
logger.info(f"📸 [OSINT] Analyzing Instagram business profiles for: {query}")
return [
{
"platform": "Instagram",
"actor": "LuxuryRealEstate_SA",
"content": "قريباً افتتاح الفرع الثالث في جدة! 🚀",
"intent": "expansion",
"timestamp": "2026-04-01",
"score": 88
}
]
async def get_total_signals(self, company_name: str) -> List[Dict]:
"""Gathers signals across all supported social platforms."""
results = await asyncio.gather(
self.search_x_signals(company_name),
self.search_instagram_signals(company_name)
)
return [item for sublist in results for item in sublist]
osint_service = SocialOSINTService()

View File

@ -0,0 +1,78 @@
"""
حوكمة الإرسال: عند تفعيلها في tenant.settings["governance"] لا يُرسل واتساب آلياً
بل يُنشأ طلب موافقة ويُسجَّل حدث نطاق.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.integrations.whatsapp import send_whatsapp_message
from app.models.operations import ApprovalRequest
from app.models.tenant import Tenant
from app.models.user import User
from app.services.operations_hub import emit_domain_event
def _governance_whatsapp_approval_required(settings: Optional[dict]) -> bool:
if not isinstance(settings, dict):
return False
gov = settings.get("governance") or {}
return bool(gov.get("whatsapp_outbound_requires_approval"))
async def send_whatsapp_with_governance(
db: AsyncSession,
*,
tenant_id: UUID,
phone: str,
message: str,
lead_id: UUID,
) -> Dict[str, Any]:
t_result = await db.execute(select(Tenant).where(Tenant.id == tenant_id))
tenant = t_result.scalar_one_or_none()
settings = tenant.settings if tenant and isinstance(tenant.settings, dict) else {}
if not _governance_whatsapp_approval_required(settings):
out = await send_whatsapp_message(phone, message)
return {"sent": True, "result": out}
u_result = await db.execute(
select(User)
.where(User.tenant_id == tenant_id, User.role.in_(["owner", "admin"]))
.order_by(User.created_at.asc())
.limit(1)
)
actor = u_result.scalar_one_or_none()
if not actor:
u_result = await db.execute(
select(User).where(User.tenant_id == tenant_id).order_by(User.created_at.asc()).limit(1)
)
actor = u_result.scalar_one_or_none()
if not actor:
out = await send_whatsapp_message(phone, message)
return {"sent": True, "result": out, "note": "no user for approval queue; sent anyway"}
row = ApprovalRequest(
tenant_id=tenant_id,
channel="whatsapp",
resource_type="lead",
resource_id=lead_id,
payload={"phone": phone, "message_preview": (message or "")[:2000], "kind": "ai_inbound_reply"},
status="pending",
requested_by_id=actor.id,
)
db.add(row)
await db.flush()
await emit_domain_event(
db,
tenant_id=tenant_id,
event_type="whatsapp.outbound.deferred_for_approval",
payload={"approval_id": str(row.id), "lead_id": str(lead_id)},
source="webhook",
)
return {"sent": False, "pending_approval": True, "approval_id": str(row.id)}

View File

@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Any, Dict, List
class PredictiveRevenueService:
"""Forecasting + churn + anomaly skeleton for phase-1 foundation."""
def score_signal_based_lead(self, lead: Dict[str, Any], signals: List[Dict[str, Any]]) -> float:
base = float(lead.get("discovery_score", 50))
signal_boost = sum(float(s.get("score", 0)) for s in signals[:5]) / 10.0
return min(100.0, round(base + signal_boost, 2))
def forecast(self, pipeline: List[Dict[str, Any]]) -> Dict[str, Any]:
weighted = 0.0
for deal in pipeline:
value = float(deal.get("value", 0))
prob = float(deal.get("win_probability", 0.3))
weighted += value * prob
return {"weighted_forecast_sar": round(weighted, 2), "confidence": 0.74}
def predict_churn(self, accounts: List[Dict[str, Any]]) -> Dict[str, Any]:
risky = [a for a in accounts if float(a.get("health_score", 100)) < 50]
return {"risk_count": len(risky), "at_risk_accounts": risky[:20]}
def detect_anomalies(self, metrics: Dict[str, Any]) -> Dict[str, Any]:
velocity = float(metrics.get("pipeline_velocity", 0))
drop = velocity < float(metrics.get("velocity_floor", 1))
return {"pipeline_velocity_drop": drop, "details": metrics}
predictive_revenue_service = PredictiveRevenueService()

View File

@ -0,0 +1,444 @@
"""
Sales OS: commission ledger, quota helpers, rep onboarding playbook (in-memory / tenant.settings).
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from typing import Any, Dict, List, Optional
from uuid import UUID
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.commission import Commission, CommissionStatus, Payout, PayoutStatus
from app.models.deal import Deal
from app.models.affiliate import AffiliateMarketer
from app.models.activity import Activity
from app.models.user import User
def _status_ar(cs: CommissionStatus) -> str:
m = {
CommissionStatus.DRAFT: "مسودة",
CommissionStatus.PENDING: "قيد المراجعة",
CommissionStatus.APPROVED: "معتمد — جاهز للدفع",
CommissionStatus.HELD: "معلّق",
CommissionStatus.PAID: "مدفوع للمسوّق",
CommissionStatus.REJECTED: "مرفوض",
CommissionStatus.DISPUTED: "نزاع",
CommissionStatus.CLAWBACK: "استرداد",
}
return m.get(cs, cs.value if hasattr(cs, "value") else str(cs))
def _payout_status_ar(ps: Optional[PayoutStatus]) -> Optional[str]:
if ps is None:
return None
m = {
PayoutStatus.PENDING: "دفعة قيد الانتظار",
PayoutStatus.PROCESSING: "قيد التحويل",
PayoutStatus.PAID: "تم التحويل",
PayoutStatus.FAILED: "فشل التحويل",
}
return m.get(ps, ps.value)
async def build_commission_ledger(
db: AsyncSession,
tenant_id: UUID,
*,
limit: int = 100,
) -> Dict[str, Any]:
"""Join commissions → deal → affiliate → payout for transparency UI."""
q = (
select(Commission, Deal, AffiliateMarketer, Payout)
.join(Deal, Commission.deal_id == Deal.id)
.join(AffiliateMarketer, Commission.affiliate_id == AffiliateMarketer.id)
.outerjoin(Payout, Commission.payout_id == Payout.id)
.where(Commission.tenant_id == tenant_id)
.order_by(Commission.created_at.desc())
.limit(limit)
)
result = await db.execute(q)
rows = result.all()
items: List[Dict[str, Any]] = []
for c, deal, aff, po in rows:
items.append(
{
"commission_id": str(c.id),
"deal_id": str(deal.id),
"deal_title": deal.title,
"deal_stage": deal.stage,
"deal_value_sar": float(deal.value) if deal.value is not None else None,
"affiliate_name": aff.full_name_ar or aff.full_name,
"affiliate_id": str(aff.id),
"amount_sar": float(c.amount),
"rate": float(c.rate),
"plan_type": c.plan_type,
"status": c.status.value,
"status_ar": _status_ar(c.status),
"payout_id": str(c.payout_id) if c.payout_id else None,
"payout_status": po.status.value if po else None,
"payout_status_ar": _payout_status_ar(po.status if po else None),
"payout_total_sar": float(po.total_amount) if po else None,
"approved_at": c.approved_at.isoformat() if c.approved_at else None,
"paid_at": c.paid_at.isoformat() if c.paid_at else None,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
)
totals = {
"pending_review": sum(1 for i in items if i["status"] in ("draft", "pending")),
"approved_unpaid": sum(1 for i in items if i["status"] == "approved"),
"paid": sum(1 for i in items if i["status"] == "paid"),
"total_amount_sar": sum(i["amount_sar"] for i in items if i["status"] != "rejected"),
}
return {
"demo_mode": False,
"items": items,
"summary": totals,
}
def demo_commission_ledger() -> Dict[str, Any]:
return {
"demo_mode": True,
"items": [
{
"commission_id": "demo-1",
"deal_id": "demo-deal-1",
"deal_title": "اشتراك احترافي — عميل تجريبي",
"deal_stage": "closed_won",
"deal_value_sar": 699.0,
"affiliate_name": "مسوّق تجريبي",
"affiliate_id": "demo-aff",
"amount_sar": 140.0,
"rate": 0.2,
"plan_type": "professional",
"status": "approved",
"status_ar": "معتمد — جاهز للدفع",
"payout_id": None,
"payout_status": None,
"payout_status_ar": None,
"payout_total_sar": None,
"approved_at": datetime.now(timezone.utc).isoformat(),
"paid_at": None,
"created_at": datetime.now(timezone.utc).isoformat(),
},
{
"commission_id": "demo-2",
"deal_id": "demo-deal-2",
"deal_title": "تجديد سنوي",
"deal_stage": "negotiation",
"deal_value_sar": 12000.0,
"affiliate_name": "مسوّق تجريبي",
"affiliate_id": "demo-aff",
"amount_sar": 2400.0,
"rate": 0.2,
"plan_type": "enterprise",
"status": "pending",
"status_ar": "قيد المراجعة",
"payout_id": None,
"payout_status": None,
"payout_status_ar": None,
"payout_total_sar": None,
"approved_at": None,
"paid_at": None,
"created_at": datetime.now(timezone.utc).isoformat(),
},
],
"summary": {
"pending_review": 1,
"approved_unpaid": 1,
"paid": 0,
"total_amount_sar": 2540.0,
},
}
async def pipeline_value_open_deals(db: AsyncSession, tenant_id: UUID) -> float:
q = await db.execute(
select(func.coalesce(func.sum(Deal.value), 0)).where(
Deal.tenant_id == tenant_id,
Deal.stage.notin_(["closed_lost", "closed_won"]),
)
)
v = q.scalar()
return float(v) if v is not None else 0.0
async def pipeline_value_open_deals_scoped(
db: AsyncSession,
tenant_id: UUID,
*,
user_id: UUID,
role: str,
) -> float:
"""مندوب: أنبوبه فقط. مدير/مالك: كامل المستأجر."""
q = select(func.coalesce(func.sum(Deal.value), 0)).where(
Deal.tenant_id == tenant_id,
Deal.stage.notin_(["closed_lost", "closed_won"]),
)
if role == "agent":
q = q.where(Deal.assigned_to == user_id)
r = await db.execute(q)
v = r.scalar()
return float(v) if v is not None else 0.0
def _deal_scope_filter(user_id: UUID, role: str):
if role == "agent":
return Deal.assigned_to == user_id
return None
async def build_daily_digest(
db: AsyncSession,
tenant_id: UUID,
user_id: UUID,
role: str,
tenant_settings: dict,
) -> Dict[str, Any]:
tasks = await tasks_inbox_today(db, tenant_id, user_id)
pipeline = await pipeline_value_open_deals_scoped(db, tenant_id, user_id=user_id, role=role)
quota = merge_quota_view(tenant_settings, user_id, pipeline)
dq = select(Deal).where(
Deal.tenant_id == tenant_id,
Deal.stage.notin_(["closed_lost", "closed_won"]),
)
cond = _deal_scope_filter(user_id, role)
if cond is not None:
dq = dq.where(cond)
today = date.today()
week = today + timedelta(days=7)
dq = dq.where(Deal.expected_close_date.isnot(None)).where(Deal.expected_close_date <= week).where(Deal.expected_close_date >= today)
dq = dq.order_by(Deal.expected_close_date.asc()).limit(15)
upcoming = await db.execute(dq)
upcoming_rows = []
for d in upcoming.scalars().all():
upcoming_rows.append(
{
"deal_id": str(d.id),
"title": d.title,
"stage": d.stage,
"expected_close_date": d.expected_close_date.isoformat() if d.expected_close_date else None,
"value_sar": float(d.value) if d.value is not None else None,
}
)
suggested: List[str] = []
if not tasks:
suggested.append("لا أنشطة حديثة — جدّد أول اتصال أو رسالة متابعة لأعلى صفقة قيمة.")
if quota.get("attainment_ratio", 0) < 0.25:
suggested.append("الأنبوب أقل من ربع الهدف الشهري — ركّز على تأهيل 3 فرص جديدة هذا الأسبوع.")
if upcoming_rows:
suggested.append(f"لديك {len(upcoming_rows)} صفقة بإغلاق متوقع خلال 7 أيام — راجع المراحل والاعتراضات.")
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"tasks_preview": tasks[:12],
"quota": quota,
"upcoming_closes": upcoming_rows,
"suggested_actions_ar": suggested[:6],
}
async def build_manager_team_summary(db: AsyncSession, tenant_id: UUID) -> Dict[str, Any]:
q = await db.execute(
select(
Deal.assigned_to,
func.count(Deal.id),
func.coalesce(func.sum(Deal.value), 0),
)
.where(Deal.tenant_id == tenant_id, Deal.stage.notin_(["closed_lost", "closed_won"]))
.group_by(Deal.assigned_to)
)
rows = q.all()
user_ids = [r[0] for r in rows if r[0] is not None]
names: Dict[str, str] = {}
if len(user_ids) > 0:
uq = await db.execute(select(User).where(User.id.in_(user_ids)))
for u in uq.scalars().all():
names[str(u.id)] = u.full_name_ar or u.full_name or u.email
reps = []
open_pipeline_total = 0.0
for assigned_to, cnt, val in rows:
if assigned_to is None:
continue
v = float(val) if val is not None else 0.0
open_pipeline_total += v
reps.append(
{
"user_id": str(assigned_to),
"name": names.get(str(assigned_to), ""),
"open_deals": int(cnt),
"open_pipeline_sar": round(v, 2),
}
)
reps.sort(key=lambda x: x["open_pipeline_sar"], reverse=True)
total_open = await db.execute(
select(func.count(Deal.id)).where(
Deal.tenant_id == tenant_id,
Deal.stage.notin_(["closed_lost", "closed_won"]),
)
)
return {
"open_pipeline_total_sar": round(open_pipeline_total, 2),
"open_deals_total": int(total_open.scalar() or 0),
"reps": reps,
"note_ar": "ملخّص الفريق — أنبوب مفتوح حسب المندوب المسند.",
}
async def build_deal_health(
db: AsyncSession,
tenant_id: UUID,
user_id: UUID,
role: str,
*,
limit: int = 40,
) -> Dict[str, Any]:
q = select(Deal).where(Deal.tenant_id == tenant_id, Deal.stage.notin_(["closed_lost", "closed_won"]))
cond = _deal_scope_filter(user_id, role)
if cond is not None:
q = q.where(cond)
q = q.order_by(Deal.updated_at.asc()).limit(limit)
result = await db.execute(q)
deals = result.scalars().all()
now = datetime.now(timezone.utc)
today = date.today()
items: List[Dict[str, Any]] = []
for d in deals:
flags: List[str] = []
score = 100
if d.updated_at:
du = d.updated_at
if du.tzinfo is None:
du = du.replace(tzinfo=timezone.utc)
age = now - du
if age.days >= 14:
flags.append("لا تحديث على الصفقة منذ 14+ يوماً")
score -= 25
else:
flags.append("بلا تاريخ تحديث")
score -= 10
if d.expected_close_date and d.expected_close_date < today:
flags.append("تاريخ إغلاق متوقع متجاوز")
score -= 20
if d.stage == "new" and d.probability and d.probability > 40:
flags.append("احتمالية عالية لكن المرحلة ما زالت جديدة — راجع الجودة")
score -= 10
score = max(0, min(100, score))
risk = "high" if score < 45 else "medium" if score < 70 else "low"
items.append(
{
"deal_id": str(d.id),
"title": d.title,
"stage": d.stage,
"health_score": score,
"risk_level": risk,
"flags_ar": flags,
"value_sar": float(d.value) if d.value is not None else None,
}
)
return {"items": items, "note_ar": "مؤشر أولي من التحديث والمواعيد — يُعزّى لاحقاً بسجل المكالمات."}
async def tasks_inbox_today(
db: AsyncSession,
tenant_id: UUID,
user_id: UUID,
) -> List[Dict[str, Any]]:
q = await db.execute(
select(Activity)
.where(
Activity.tenant_id == tenant_id,
Activity.user_id == user_id,
)
.order_by(Activity.created_at.desc())
.limit(40)
)
out: List[Dict[str, Any]] = []
for a in q.scalars().all():
out.append(
{
"id": str(a.id),
"type": a.type,
"subject": a.subject or "",
"description": (a.description or "")[:500],
"scheduled_at": a.scheduled_at.isoformat() if a.scheduled_at else None,
"completed_at": a.completed_at.isoformat() if a.completed_at else None,
"deal_id": str(a.deal_id) if a.deal_id else None,
"lead_id": str(a.lead_id) if a.lead_id else None,
}
)
return out
def default_sales_os_settings() -> Dict[str, Any]:
return {
"default_monthly_quota_sar": 500_000,
"rep_quotas": {},
"currency": "SAR",
}
def merge_quota_view(
tenant_settings: dict,
user_id: UUID,
pipeline_open_sar: float,
) -> Dict[str, Any]:
raw = (tenant_settings or {}).get("sales_os") or {}
base = {**default_sales_os_settings(), **raw}
target = float(base["rep_quotas"].get(str(user_id), base.get("default_monthly_quota_sar", 500_000)))
ratio = (pipeline_open_sar / target) if target > 0 else 0.0
return {
"monthly_target_sar": target,
"pipeline_open_sar": round(pipeline_open_sar, 2),
"attainment_ratio": round(min(ratio, 2.0), 3),
"note_ar": "الهدف مقابل أنبوب مفتوح (تقريبي) — يُحدَّث من إعدادات المستأجر.",
}
def rep_onboarding_playbook() -> Dict[str, Any]:
return {
"title_ar": "تأهيل مندوب المبيعات — 30 يوماً",
"phases": [
{
"day_range": "17",
"title_ar": "الأسبوع الأول — الأدوات والقنوات",
"tasks_ar": [
"إكمال الملف والقطاع في النظام",
"ربط واتساب التجريبي أو القناة المعتمدة",
"قراءة سكربت الافتتاحية + تسجيل محاكاة واحدة",
],
},
{
"day_range": "814",
"title_ar": "الأسبوع الثاني — الأنبوب والمتابعة",
"tasks_ar": [
"10 اتصالات/محادثات مؤهّلة في CRM",
"استخدام تذكير المتابعة التلقائي",
"اجتماع مراجعة مع المدير (15 دقيقة)",
],
},
{
"day_range": "1530",
"title_ar": "الأسبوعان 34 — الإغلاق والعمولة",
"tasks_ar": [
"عرض سعر واحد على الأقل في مرحلة متأخرة",
"فهم شفافية العمولة من لوحة «دفتر العمولات»",
"تحليل أسبوعي: معدل التحويل مقابل الهدف",
],
},
],
"kpi_ar": [
"عدد اللقاءات المؤهّلة",
"قيمة الأنبوب المفتوح",
"صفقات مغلقة / عمولة معتمدة",
],
}

View File

@ -0,0 +1,117 @@
import httpx
import logging
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, Optional
from app.config import get_settings
logger = logging.getLogger("dealix.salesforce")
settings = get_settings()
class SalesforceAgentforceSync:
"""
Layer 4: Deep Integration with Salesforce Agentforce 360.
Treats Salesforce structured data as external ground truth and allows Native Agents (Agentforce)
to interact with Dealix outputs.
"""
def __init__(self):
domain = settings.SALESFORCE_DOMAIN.strip() or "login.salesforce.com"
self.base_auth_url = f"https://{domain}"
self.api_url = f"{self.base_auth_url}/services/data/{settings.SALESFORCE_API_VERSION}"
self.client_id = settings.SALESFORCE_CLIENT_ID
self.client_secret = settings.SALESFORCE_CLIENT_SECRET
self.refresh_token = settings.SALESFORCE_REFRESH_TOKEN
self.access_token = settings.SALESFORCE_ACCESS_TOKEN
self.token_expiry: Optional[datetime] = None
async def _refresh_access_token(self) -> bool:
if not (self.client_id and self.client_secret and self.refresh_token):
return False
try:
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
f"{self.base_auth_url}/services/oauth2/token",
data={
"grant_type": "refresh_token",
"client_id": self.client_id,
"client_secret": self.client_secret,
"refresh_token": self.refresh_token,
},
)
resp.raise_for_status()
data = resp.json()
self.access_token = data.get("access_token", "")
self.token_expiry = datetime.now(timezone.utc) + timedelta(minutes=50)
instance_url = data.get("instance_url")
if instance_url:
self.api_url = f"{instance_url}/services/data/{settings.SALESFORCE_API_VERSION}"
return bool(self.access_token)
except Exception as e:
logger.error(f"Salesforce token refresh failed: {e}")
return False
async def _ensure_token(self) -> bool:
if self.access_token and self.token_expiry and datetime.now(timezone.utc) < self.token_expiry:
return True
return await self._refresh_access_token()
async def get_account_360(self, account_name: str) -> Dict[str, Any]:
if not await self._ensure_token():
return {
"account_name": account_name,
"mode": "mock",
"opportunities": [],
"note": "Salesforce OAuth not configured",
}
soql = f"SELECT Id, Name, Industry FROM Account WHERE Name = '{account_name}' LIMIT 1"
query_url = f"{self.api_url}/query"
try:
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(
query_url,
params={"q": soql},
headers={"Authorization": f"Bearer {self.access_token}"},
)
resp.raise_for_status()
records = resp.json().get("records", [])
return {"account_name": account_name, "mode": "live", "records": records}
except Exception as e:
logger.error(f"Salesforce Account 360 fetch failed: {e}")
return {"account_name": account_name, "mode": "error", "error": str(e)}
async def sync_deal(self, deal_state: Dict[str, Any]) -> bool:
"""
Synchronizes Dealix's final deal state with Salesforce Pipeline Management Agent.
"""
company = deal_state.get("company_name", "Unknown")
stage = deal_state.get("deal_stage", "Prospecting")
action = deal_state.get("next_action_payload", "")
payload = {
"Name": f"Dealix Auto: {company}",
"StageName": stage,
"CloseDate": "2026-12-31",
"Description": f"Generated by Dealix Autonomous OS.\n\nLatest AI Action: {action[:200]}..."
}
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json"
}
if not await self._ensure_token():
logger.info(f"Mock Sync to Salesforce Agentforce: {company} -> {stage}")
return True
try:
async with httpx.AsyncClient() as client:
resp = await client.post(f"{self.api_url}/sobjects/Opportunity/", json=payload, headers=headers)
resp.raise_for_status()
logger.info(f"Salesforce Sync Successful: {resp.json().get('id')}")
return True
except Exception as e:
logger.error(f"Salesforce Agentforce Sync Failed: {e}")
return False
# Singleton
agentforce_service = SalesforceAgentforceSync()

View File

@ -0,0 +1,23 @@
from __future__ import annotations
from typing import Any, Dict, List
class SignalSellingService:
def aggregate_signals(
self,
web_signals: List[Dict[str, Any]],
email_signals: List[Dict[str, Any]],
call_signals: List[Dict[str, Any]],
linkedin_signals: List[Dict[str, Any]],
) -> Dict[str, Any]:
all_signals = web_signals + email_signals + call_signals + linkedin_signals
score = sum(float(s.get("score", 0)) for s in all_signals[:20])
return {
"signal_count": len(all_signals),
"buying_intent_score": min(100.0, round(score / 5.0, 2)),
"top_signals": sorted(all_signals, key=lambda x: float(x.get("score", 0)), reverse=True)[:5],
}
signal_selling_service = SignalSellingService()

View File

@ -0,0 +1,31 @@
from __future__ import annotations
import httpx
from typing import Any, Dict
from app.config import get_settings
settings = get_settings()
class StripeService:
async def create_payment_intent(self, amount_sar: int, customer_id: str) -> Dict[str, Any]:
if not settings.STRIPE_SECRET_KEY:
return {"status": "mock", "amount_sar": amount_sar, "customer_id": customer_id}
amount_halalas = int(amount_sar * 100)
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
"https://api.stripe.com/v1/payment_intents",
headers={"Authorization": f"Bearer {settings.STRIPE_SECRET_KEY}"},
data={
"amount": str(amount_halalas),
"currency": "sar",
"customer": customer_id,
"automatic_payment_methods[enabled]": "true",
},
)
resp.raise_for_status()
return resp.json()
stripe_service = StripeService()

View File

@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Any, Dict
try:
from twilio.rest import Client
TWILIO_AVAILABLE = True
except ImportError:
TWILIO_AVAILABLE = False
from app.config import get_settings
settings = get_settings()
class VoiceService:
async def trigger_sales_call(self, to_number: str, objective: str) -> Dict[str, Any]:
if not TWILIO_AVAILABLE:
return {"status": "mock", "reason": "twilio_not_installed", "to": to_number, "objective": objective}
if not (settings.TWILIO_ACCOUNT_SID and settings.TWILIO_AUTH_TOKEN and settings.TWILIO_FROM_NUMBER):
return {"status": "mock", "to": to_number, "objective": objective}
client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)
call = client.calls.create(
to=to_number,
from_=settings.TWILIO_FROM_NUMBER,
twiml=f"<Response><Say language='ar-SA'>مرحبا. هذه مكالمة مبيعات ذكية من Dealix. الهدف: {objective}</Say></Response>",
)
return {"status": "queued", "call_sid": call.sid, "to": to_number, "objective": objective}
voice_service = VoiceService()

View File

@ -151,6 +151,6 @@ def apply_patch():
sys.modules["pgvector"] = pgvector_root
sys.modules["pgvector.sqlalchemy"] = pgvector_sa
print("🔧 SQLite patch applied — UUID/JSONB/Vector → SQLite types")
print("[sqlite_patch] SQLite patch applied: UUID/JSONB/Vector -> SQLite types")
else:
print(f" DB: {db_url.split(':')[0]} no patch needed")
print(f"[sqlite_patch] DB: {db_url.split(':')[0]} - no patch needed")

View File

@ -1,19 +1,31 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
import bcrypt
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def _password_bytes(password: str) -> bytes:
"""bcrypt limit 72 bytes — truncate UTF-8 safely."""
return password.encode("utf-8")[:72]
def hash_password(password: str) -> str:
return pwd_context.hash(password)
"""Bcrypt via native `bcrypt` (avoids passlib init issues on newer Python/bcrypt combos)."""
return bcrypt.hashpw(_password_bytes(password), bcrypt.gensalt(rounds=12)).decode("ascii")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
if not hashed_password:
return False
try:
return bcrypt.checkpw(_password_bytes(plain_password), hashed_password.encode("ascii"))
except ValueError:
return False
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:

View File

@ -0,0 +1,8 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
filterwarnings =
ignore::DeprecationWarning
markers =
launch: pre-release surface & scenario checks (run with: pytest -m launch)
slow: tests that hit external IO or long LangGraph paths

View File

@ -0,0 +1,4 @@
# Dev / CI — not required in production images
pytest>=8.0.0
pytest-asyncio>=0.24.0
aiosqlite>=0.20.0

View File

@ -24,6 +24,7 @@ pandas==2.2.3
numpy==2.1.3
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt>=4.0.1,<5
python-decouple==3.8
redis==5.2.0
paramiko==3.5.0
@ -31,3 +32,6 @@ qrcode==8.0
Pillow==11.0.0
xmltodict==0.14.2
email-validator>=2.1.0
crewai==0.80.0
mem0ai==0.1.18
langchain-anthropic==0.2.0

View File

@ -0,0 +1,281 @@
"""
اختبار إطلاق شامل: pytest اختياري، ثم صحة API، DB، Go-Live، تدفقات أساسية، Marketing/Strategy،
وحالة الإمبراطورية، صحة LangGraph، وتشغيل دورة صفقة CEO عبر LangGraph (واقعي، مهلة أطول).
يشغّل محلياً ضد BASE_URL (افتراضي http://127.0.0.1:8000).
استخدام:
py scripts/full_stack_launch_test.py
py scripts/full_stack_launch_test.py --pytest
py scripts/full_stack_launch_test.py --pytest --soft-ready
py scripts/full_stack_launch_test.py --skip-http
py scripts/full_stack_launch_test.py --http-only --soft-ready
py scripts/full_stack_launch_test.py --http-only --quick --soft-ready
$env:DEALIX_BASE_URL="http://127.0.0.1:8000"; py scripts/full_stack_launch_test.py
PowerShell مسارات صحيحة:
أنت داخل ...\\salesflow-saas\\backend لا تكتب cd salesflow-saas\\backend (يضاعف المسار).
للفرونت من الـ backend: cd ..\\frontend
ثم: npm run test:e2e:install
قبل --http-only: شغّل API في طرفية أخرى:
py -m uvicorn app.main:app --host 127.0.0.1 --port 8000
أوامر منفصلة (لا تلصق سطرين بدون مسافة بينهما):
py -m pytest tests -q --tb=line
py scripts/launch_gate_runner.py -- -m launch -q
"""
from __future__ import annotations
import argparse
import asyncio
import os
import subprocess
import sys
from pathlib import Path
from typing import Any, List, Tuple
import httpx
BASE = os.environ.get("DEALIX_BASE_URL", "http://127.0.0.1:8000").rstrip("/")
def _looks_like_no_server_running(detail: str) -> bool:
d = (detail or "").lower()
needles = (
"connection attempts failed",
"connection refused",
"connecterror",
"errno 111",
"name or service not known",
"getaddrinfo failed",
"actively refused",
"no connection could be made",
"failed to establish",
)
return any(n in d for n in needles)
def _safe_console(s: str, max_len: int = 320) -> str:
"""Avoid UnicodeEncodeError on Windows consoles (cp1252)."""
chunk = (s or "")[:max_len]
return chunk.encode("ascii", errors="replace").decode("ascii")
def run_pytest() -> int:
backend = Path(__file__).resolve().parent.parent
return subprocess.call(
[sys.executable, "-m", "pytest", str(backend / "tests"), "-q", "--tb=line"],
cwd=str(backend),
)
async def check(
name: str,
method: str,
path: str,
*,
allow_client_error: bool = False,
allowed_statuses: Tuple[int, ...] | None = None,
timeout: float = 15.0,
**kw: Any,
) -> Tuple[str, bool, str]:
"""Launch checks expect 2xx unless allow_client_error (4xx counts as OK) or allowed_statuses is set."""
url = f"{BASE}{path}"
try:
async with httpx.AsyncClient(timeout=timeout) as client:
r = await client.request(method, url, **kw)
if allowed_statuses is not None:
ok = r.status_code in allowed_statuses
elif allow_client_error:
ok = r.status_code < 500
else:
ok = 200 <= r.status_code < 300
body = _safe_console(r.text or "", 300)
return name, ok, f"{r.status_code} {body}"
except Exception as e:
return name, False, _safe_console(str(e), 300)
async def main() -> int:
parser = argparse.ArgumentParser(description="Dealix full-stack launch verification")
parser.add_argument(
"--pytest",
action="store_true",
help="Run backend/tests with pytest before HTTP checks",
)
parser.add_argument(
"--skip-http",
action="store_true",
help="Only run pytest (if --pytest); skip HTTP checks (CI without running API)",
)
parser.add_argument(
"--soft-ready",
action="store_true",
help="Do not fail the run if /api/v1/ready fails (e.g. Postgres not running locally)",
)
parser.add_argument(
"--http-only",
action="store_true",
help="Only run HTTP checks (no pytest); API must be reachable at DEALIX_BASE_URL",
)
parser.add_argument(
"--quick",
action="store_true",
help="Skip long LangGraph CEO deal cycle POST (~2 min); use for fast HTTP smoke against live server",
)
args = parser.parse_args()
if args.skip_http and not args.pytest:
print("Use --pytest with --skip-http to run tests only without starting the API.", flush=True)
return 2
if args.http_only and (args.pytest or args.skip_http):
print("Do not combine --http-only with --pytest or --skip-http.", flush=True)
return 2
if args.pytest:
print("Running pytest (backend/tests)...", flush=True)
rc = run_pytest()
if rc != 0:
print("pytest failed; fix tests before launch.", flush=True)
return rc
print("pytest OK.\n", flush=True)
if args.skip_http:
print("HTTP checks skipped (--skip-http).")
return 0
if args.http_only:
print("HTTP-only mode (--http-only).\n")
print(f"Full stack launch test -> {BASE}\n")
results: List[Tuple[str, bool, str]] = []
results.append(await check("health", "GET", "/api/v1/health"))
results.append(await check("ready (DB)", "GET", "/api/v1/ready"))
results.append(await check("marketing hub", "GET", "/api/v1/marketing/hub"))
results.append(await check("strategy summary", "GET", "/api/v1/strategy/summary"))
results.append(await check("value proposition", "GET", "/api/v1/value-proposition/"))
results.append(await check("customer onboarding journey", "GET", "/api/v1/customer-onboarding/journey"))
results.append(await check("sales-os overview", "GET", "/api/v1/sales-os/overview"))
results.append(await check("operations snapshot", "GET", "/api/v1/operations/snapshot"))
results.append(
await check(
"go-live gate",
"GET",
"/api/v1/autonomous-foundation/integrations/go-live-gate",
allowed_statuses=(200, 403),
)
)
results.append(
await check("live readiness report", "GET", "/api/v1/autonomous-foundation/integrations/live-readiness")
)
results.append(
await check(
"executive ROI",
"POST",
"/api/v1/autonomous-foundation/dashboard/executive-roi",
json={"baseline": {"revenue": 100000}, "current": {"revenue": 120000, "win_rate": 0.3, "pipeline_velocity_days": 20, "manual_work_reduction_percent": 75}},
)
)
results.append(
await check(
"MCP ping substitute - autonomous ping",
"POST",
"/api/v1/autonomous-foundation/flows/self-improvement",
json={"tenant_id": "launch_test", "deal": {"signals": []}},
)
)
results.append(await check("affiliates program (public)", "GET", "/api/v1/affiliates/program"))
results.append(await check("affiliates leaderboard", "GET", "/api/v1/affiliates/leaderboard/top"))
results.append(await check("agents list", "GET", "/api/v1/agents/list"))
results.append(await check("agents empire status", "GET", "/api/v1/agents/empire/status"))
results.append(await check("LangGraph orchestrator health", "GET", "/api/v1/agents/langgraph/health"))
results.append(
await check(
"integration connectivity matrix",
"POST",
"/api/v1/autonomous-foundation/integrations/connectivity-test",
json={},
)
)
if not args.quick:
results.append(
await check(
"LangGraph CEO deal cycle (realistic, slow)",
"POST",
"/api/v1/agents/ceo/langgraph-deal-cycle",
timeout=120.0,
json={
"company_name": "Launch Verification Co",
"deal_id": "LAUNCH-LG-1",
"tenant_id": "launch_verify",
"industry": "enterprise",
"city": "Riyadh",
},
)
)
else:
results.append(
(
"LangGraph CEO deal cycle (skipped --quick)",
True,
"200 SKIPPED use full_stack_launch_test without --quick for end-to-end graph against live LeadEngine",
)
)
failed = 0
soft_ready = args.soft_ready
for name, ok, detail in results:
status = "OK " if ok else "FAIL"
soft = soft_ready and name == "ready (DB)" and not ok
if soft:
status = "SOFT"
print(f"[{status}] {name}")
if not ok and not soft:
failed += 1
print(f" {detail[:200]}...\n" if len(detail) > 200 else f" {detail}\n")
# Note: /ready may fail if Postgres not running — use --soft-ready for local dev
print("---")
if failed == 0:
print("All launch checks passed (expected status codes, no 5xx).")
return 0
print(f"Some checks failed ({failed}). Fix server/DB or URLs.", flush=True)
failed_rows: List[Tuple[str, str]] = []
for name, ok, detail in results:
if ok:
continue
if soft_ready and name == "ready (DB)":
continue
failed_rows.append((name, detail))
if failed_rows and all(_looks_like_no_server_running(d) for _, d in failed_rows):
print(
"\n>>> تشخيص: يبدو أن لا خادم FastAPI يستمع على هذا العنوان.\n"
f" الهدف الحالي: {BASE}\n"
" رسالة httpx الشائعة: 'All connection attempts failed' = المنفذ فارغ أو جدار ناري.\n\n"
" افتح طرفية جديدة داخل مجلد backend وشغّل:\n"
" py -m uvicorn app.main:app --host 127.0.0.1 --port 8000\n\n"
" إن كان الخادم على منفذ آخر:\n"
' $env:DEALIX_BASE_URL="http://127.0.0.1:PORT"; py scripts/full_stack_launch_test.py --http-only\n\n'
" للتحقق بدون خادم حي استخدم pytest فقط (ASGI):\n"
" py -m pytest tests -q --tb=line\n",
flush=True,
)
for name, ok, detail in results:
if ok or name not in ("marketing hub", "strategy summary"):
continue
if "404" in detail or "Not Found" in detail:
print(
"Hint: 404 on marketing/strategy means the process on "
f"{BASE} is likely an OLD server build. Restart from repo root:\n"
" cd backend && py -m uvicorn app.main:app --host 127.0.0.1 --port 8000\n"
"Or set DEALIX_BASE_URL to a port running the current code.",
flush=True,
)
break
return 1
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

@ -0,0 +1,51 @@
<#
Go-Live Gate يستدعي الـ API بشكل صحيح من PowerShell.
لا تلصق الرابط وحده في الطرفية؛ PowerShell لا يعامله كأمر HTTP.
الاستخدام:
cd salesflow-saas\backend
.\scripts\go_live_gate.ps1
.\scripts\go_live_gate.ps1 -BaseUrl "http://127.0.0.1:8000"
#>
param(
[string]$BaseUrl = "http://localhost:8000"
)
$ErrorActionPreference = "Stop"
$uri = "$($BaseUrl.TrimEnd('/'))/api/v1/autonomous-foundation/integrations/go-live-gate"
Write-Host "GET $uri" -ForegroundColor Cyan
# curl.exe مدمج في Windows 10+ — يعرض الجسم حتى مع 403
$curl = Get-Command curl.exe -ErrorAction SilentlyContinue
if ($null -eq $curl) {
Write-Host "curl.exe not found. Use:" -ForegroundColor Yellow
Write-Host " Invoke-RestMethod -Uri `"$uri`" -Method Get | ConvertTo-Json -Depth 12" -ForegroundColor Gray
exit 2
}
$tmp = [System.IO.Path]::GetTempFileName()
try {
$code = & curl.exe -sS -o $tmp -w "%{http_code}" -- "$uri"
$raw = Get-Content -LiteralPath $tmp -Raw -Encoding utf8
} finally {
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
}
try {
$json = $raw | ConvertFrom-Json
} catch {
Write-Host "Invalid JSON response:" -ForegroundColor Red
Write-Host $raw
exit 3
}
$json | ConvertTo-Json -Depth 12
if ($code -eq "200" -and $json.launch_allowed -eq $true) {
Write-Host "`nOK — Go-live gate: ALLOWED (HTTP 200, 100%)" -ForegroundColor Green
exit 0
}
Write-Host "`nBLOCKED — HTTP $code — أصلح المتغيرات الناقصة (missing / blocked_reasons)." -ForegroundColor Red
exit 1

View File

@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""
بوابة إطلاق موحّدة: pytest كامل ثم (اختياري) فحوص HTTP ضد خادم حي.
1) دائماً: تشغيل كل اختبارات backend/tests (واقعية عبر ASGI، بدون خادم).
2) اختياري --live-http: يتصل بـ DEALIX_BASE_URL (يشغّل uvicorn أولاً).
أمثلة:
py scripts/launch_gate_runner.py
py scripts/launch_gate_runner.py -- -m launch -q
py scripts/launch_gate_runner.py --live-http --soft-ready
$env:DEALIX_BASE_URL="http://127.0.0.1:8000"; py scripts/launch_gate_runner.py --live-http --quick-http
أخطاء شائعة (PowerShell):
- أنت بالفعل داخل ...\\salesflow-saas\\backend فلا تستخدم cd salesflow-saas\\backend.
- npm و Playwright من مجلد frontend: cd ..\\frontend ثم npm run test:e2e
- لا تدمج أمرين: py -m pytest ... --tb=line ثم سطر جديد py scripts/...
(بدون مسافة تصبح --tb=linepy وتفشل pytest)
- --live-http يفشل إن لم يكن uvicorn شغّال على DEALIX_BASE_URL (افتراضي :8000).
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
BACKEND = Path(__file__).resolve().parent.parent
def _pytest(extra_args: list[str]) -> int:
cmd = [sys.executable, "-m", "pytest", str(BACKEND / "tests"), "--tb=short", *extra_args]
print("Running:", " ".join(cmd), flush=True)
return subprocess.call(cmd, cwd=str(BACKEND))
def _live_http(soft_ready: bool, quick: bool) -> int:
script = BACKEND / "scripts" / "full_stack_launch_test.py"
cmd = [sys.executable, str(script), "--http-only"]
if soft_ready:
cmd.append("--soft-ready")
if quick:
cmd.append("--quick")
print("Running live HTTP:", " ".join(cmd), flush=True)
return subprocess.call(cmd, cwd=str(BACKEND))
def main() -> int:
argv = sys.argv[1:]
pytest_extra: list[str] = []
if "--" in argv:
sep = argv.index("--")
pytest_extra = [x for x in argv[sep + 1 :] if x != "--"]
argv = argv[:sep]
p = argparse.ArgumentParser(description="Dealix launch gate: pytest + optional live HTTP smoke")
p.add_argument(
"--live-http",
action="store_true",
help="After pytest, run full_stack_launch_test against DEALIX_BASE_URL (start uvicorn first)",
)
p.add_argument(
"--soft-ready",
action="store_true",
help="Pass --soft-ready to HTTP script (/ready DB may fail locally)",
)
p.add_argument(
"--quick-http",
action="store_true",
help="Pass --quick to HTTP script (skip slow LangGraph POST)",
)
args = p.parse_args(argv)
extra = pytest_extra if pytest_extra else ["-q"]
rc = _pytest(extra)
if rc != 0:
print("pytest FAILED — fix tests before launch.", flush=True)
return rc
print("pytest OK.", flush=True)
if not args.live_http:
print(
"Live HTTP skipped. Start API then run:\n"
f" py scripts/full_stack_launch_test.py --http-only\n"
f"Or: py scripts/launch_gate_runner.py --live-http --soft-ready",
flush=True,
)
return 0
return _live_http(args.soft_ready, args.quick_http)
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,42 @@
import asyncio
import json
import httpx
BASE_URL = "http://localhost:8000"
async def main() -> None:
async with httpx.AsyncClient(timeout=30) as client:
health = await client.get(f"{BASE_URL}/api/v1/health")
print("HEALTH:", health.status_code, health.json())
connectivity = await client.post(
f"{BASE_URL}/api/v1/autonomous-foundation/integrations/connectivity-test",
json={},
)
print("CONNECTIVITY:", connectivity.status_code)
print(json.dumps(connectivity.json(), ensure_ascii=False, indent=2)[:2000])
flow = await client.post(
f"{BASE_URL}/api/v1/autonomous-foundation/flows/prospecting",
json={
"tenant_id": "launch_tenant",
"deal": {
"company_name": "Launch Check Co",
"decision_maker": "Founder",
"phone": "966500000002",
"approval_token": "launch_approved",
"web_signals": [{"score": 90}],
"email_signals": [{"score": 60}],
"call_signals": [{"score": 50}],
"linkedin_signals": [{"score": 70}],
},
},
)
print("FLOW:", flow.status_code)
print(json.dumps(flow.json(), ensure_ascii=False, indent=2)[:2000])
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,9 +1,23 @@
import asyncio
import os
# JWT-based API tests require this gate to be off (production may set .env).
os.environ["DEALIX_INTERNAL_API_TOKEN"] = ""
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
@pytest.fixture(scope="session", autouse=True)
def _init_database():
from app.database import init_db
asyncio.run(init_db())
@pytest_asyncio.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:

View File

@ -0,0 +1,19 @@
import pytest
@pytest.mark.asyncio
async def test_affiliate_program_public_json(client):
r = await client.get("/api/v1/affiliates/program")
assert r.status_code == 200
data = r.json()
assert "journey_ar" in data
assert "commission_rates" in data
assert len(data["journey_ar"]) >= 3
@pytest.mark.asyncio
async def test_leaderboard_path_not_confused_with_uuid(client):
"""Regression: /leaderboard/top must not match /{affiliate_id}."""
r = await client.get("/api/v1/affiliates/leaderboard/top")
assert r.status_code == 200
assert isinstance(r.json(), list)

View File

@ -0,0 +1,34 @@
"""Broad smoke tests for core public JSON and health endpoints."""
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_health_ready_strategy_value_prop():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
h = await ac.get("/api/v1/health")
assert h.status_code == 200
r = await ac.get("/api/v1/ready")
assert r.status_code == 200
assert r.json().get("status") in ("ready", "not_ready")
s = await ac.get("/api/v1/strategy/summary")
assert s.status_code == 200
assert s.json().get("product") == "Dealix"
v = await ac.get("/api/v1/value-proposition/")
assert v.status_code == 200
body = v.json()
assert len(body.get("pillars", [])) >= 4
m = await ac.get("/api/v1/marketing/hub")
assert m.status_code == 200
j = await ac.get("/api/v1/customer-onboarding/journey")
assert j.status_code == 200
assert j.json().get("phases")

View File

@ -0,0 +1,144 @@
"""عزل مندوب على الصفقات والعملاء المحتملين + رموز JWT."""
import uuid
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.api.deps import get_current_user
from app.database import async_session
from app.main import app
from app.models.user import User
@pytest_asyncio.fixture
async def rbac_ctx():
from app.database import async_session
from app.models.tenant import Tenant
from app.models.user import User
from app.models.deal import Deal
from app.models.lead import Lead
suffix = uuid.uuid4().hex[:10]
async with async_session() as db:
tenant = Tenant(
name=f"RBAC Co {suffix}",
slug=f"rbac-{suffix}",
email=f"rbac-{suffix}@example.com",
)
db.add(tenant)
await db.flush()
# JWT tests only — avoid bcrypt/env variance in CI
stub_hash = "$2b$12$dummyNotForLoginxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
agent1 = User(
tenant_id=tenant.id,
email=f"a1-{suffix}@example.com",
password_hash=stub_hash,
full_name="Agent One",
role="agent",
)
agent2 = User(
tenant_id=tenant.id,
email=f"a2-{suffix}@example.com",
password_hash=stub_hash,
full_name="Agent Two",
role="agent",
)
db.add_all([agent1, agent2])
await db.flush()
deal = Deal(
tenant_id=tenant.id,
assigned_to=agent1.id,
title="Deal for A1",
stage="new",
)
lead = Lead(tenant_id=tenant.id, assigned_to=agent1.id, name="Lead A1")
db.add_all([deal, lead])
await db.commit()
return {
"agent1_id": str(agent1.id),
"agent2_id": str(agent2.id),
"deal_id": str(deal.id),
"lead_id": str(lead.id),
}
def _user_override(user_id: str):
async def _dep():
async with async_session() as db:
row = (await db.execute(select(User).where(User.id == user_id))).scalar_one()
return row
return _dep
@pytest.mark.asyncio
async def test_agent_sees_only_assigned_deals(rbac_ctx):
transport = ASGITransport(app=app)
app.dependency_overrides[get_current_user] = _user_override(rbac_ctx["agent1_id"])
try:
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r1 = await ac.get("/api/v1/deals")
assert r1.status_code == 200
assert len(r1.json()) == 1
finally:
app.dependency_overrides.pop(get_current_user, None)
app.dependency_overrides[get_current_user] = _user_override(rbac_ctx["agent2_id"])
try:
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r2 = await ac.get("/api/v1/deals")
assert r2.status_code == 200
assert len(r2.json()) == 0
finally:
app.dependency_overrides.pop(get_current_user, None)
@pytest.mark.asyncio
async def test_agent_cannot_update_other_deal(rbac_ctx):
transport = ASGITransport(app=app)
app.dependency_overrides[get_current_user] = _user_override(rbac_ctx["agent2_id"])
try:
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.put(
f"/api/v1/deals/{rbac_ctx['deal_id']}",
json={"notes": "hack"},
)
assert r.status_code in (403, 404)
finally:
app.dependency_overrides.pop(get_current_user, None)
@pytest.mark.asyncio
async def test_agent_sees_only_assigned_leads(rbac_ctx):
transport = ASGITransport(app=app)
app.dependency_overrides[get_current_user] = _user_override(rbac_ctx["agent1_id"])
try:
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r1 = await ac.get("/api/v1/leads")
assert r1.status_code == 200
assert r1.json()["total"] == 1
finally:
app.dependency_overrides.pop(get_current_user, None)
app.dependency_overrides[get_current_user] = _user_override(rbac_ctx["agent2_id"])
try:
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r2 = await ac.get("/api/v1/leads")
assert r2.status_code == 200
assert r2.json()["total"] == 0
finally:
app.dependency_overrides.pop(get_current_user, None)
@pytest.mark.asyncio
async def test_agent_cannot_read_other_lead(rbac_ctx):
transport = ASGITransport(app=app)
app.dependency_overrides[get_current_user] = _user_override(rbac_ctx["agent2_id"])
try:
async with AsyncClient(transport=transport, base_url="http://test") as ac:
r = await ac.get(f"/api/v1/leads/{rbac_ctx['lead_id']}")
assert r.status_code in (403, 404)
finally:
app.dependency_overrides.pop(get_current_user, None)

View File

@ -0,0 +1,79 @@
import pytest
@pytest.mark.asyncio
async def test_executive_roi_endpoint(client):
response = await client.post(
"/api/v1/autonomous-foundation/dashboard/executive-roi",
json={
"baseline": {"revenue": 100000},
"current": {
"revenue": 130000,
"win_rate": 0.31,
"pipeline_velocity_days": 19,
"manual_work_reduction_percent": 72,
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["revenue_lift_percent"] == 30.0
assert payload["manual_work_reduction_percent"] == 72
@pytest.mark.asyncio
async def test_connectivity_endpoint(client):
response = await client.post(
"/api/v1/autonomous-foundation/integrations/connectivity-test",
json={},
)
assert response.status_code == 200
payload = response.json()
assert "salesforce" in payload
assert "whatsapp" in payload
assert "stripe" in payload
assert "summary" in payload
assert payload["salesforce"].get("status") in ("ok", "error")
@pytest.mark.asyncio
async def test_live_readiness_endpoint(client):
response = await client.get("/api/v1/autonomous-foundation/integrations/live-readiness")
assert response.status_code == 200
payload = response.json()
assert "overall" in payload
assert "checks" in payload
assert "salesforce_client_id" in payload["checks"]
assert "readiness_percent" in payload
assert "missing" in payload
assert "summary" in payload
assert "cli_examples" in payload
assert "powershell" in payload["cli_examples"]
assert payload.get("launch_mode") == "full_commercial"
assert "categories" in payload
assert "blocking" in payload
assert "integration_docs" in payload
@pytest.mark.asyncio
async def test_go_live_gate_returns_403_with_report_when_not_fully_ready(client):
response = await client.get("/api/v1/autonomous-foundation/integrations/go-live-gate")
payload = response.json()
assert "gate" in payload
assert payload["gate"] == "go_live"
assert "launch_allowed" in payload
assert "missing" in payload
assert "checks" in payload
assert "readiness_percent" in payload
assert "summary" in payload
assert "cli_examples" in payload
assert "warnings" in payload
if not payload["launch_allowed"]:
assert response.status_code == 403
assert payload["readiness_percent"] < 100.0
assert isinstance(payload["missing"], list)
assert payload["missing_count"] == len(payload["missing"])
else:
assert response.status_code == 200
assert payload["readiness_percent"] == 100.0
assert payload["missing_count"] == 0

View File

@ -0,0 +1,28 @@
import pytest
@pytest.mark.asyncio
async def test_customer_journey_shape(client):
r = await client.get("/api/v1/customer-onboarding/journey")
assert r.status_code == 200
data = r.json()
assert data.get("product") == "Dealix"
assert "roles" in data and len(data["roles"]) >= 4
assert "phases" in data and len(data["phases"]) >= 4
first = data["phases"][0]
assert "steps" in first and len(first["steps"]) >= 1
step = first["steps"][0]
assert step.get("id")
assert step.get("primary_owner_role")
assert "customer_must_provide_ar" in step
@pytest.mark.asyncio
async def test_acceptance_test_checklist(client):
r = await client.get("/api/v1/customer-onboarding/acceptance-test")
assert r.status_code == 200
body = r.json()
assert "sections" in body
assert any("automated" in s.get("id", "") for s in body["sections"]) or any(
"فحوص آلية" in s.get("title_ar", "") for s in body["sections"]
)

View File

@ -0,0 +1,25 @@
import pytest
from app.flows.prospecting_durable_flow import prospecting_durable_flow
@pytest.mark.asyncio
async def test_prospecting_flow_tenant_isolation():
deal = {
"company_name": "Isolation Co",
"decision_maker": "CEO",
"phone": "966500000001",
"approval_token": "approved",
"web_signals": [{"score": 80}],
"email_signals": [{"score": 70}],
"call_signals": [{"score": 60}],
"linkedin_signals": [{"score": 50}],
}
run_a = await prospecting_durable_flow.run("tenant_a", deal)
run_b = await prospecting_durable_flow.run("tenant_b", deal)
assert run_a["tenant_id"] == "tenant_a"
assert run_b["tenant_id"] == "tenant_b"
assert run_a["tenant_id"] != run_b["tenant_id"]
assert run_a["run_id"] != run_b["run_id"]

View File

@ -0,0 +1,20 @@
"""Go-live matrix structure (commercial blocking)."""
from app.config import get_settings
from app.services.go_live_matrix import build_matrix_report, build_check_definitions
def test_matrix_has_blocking_and_optional():
defs = build_check_definitions()
blocking = [d for d in defs if d.blocking]
optional = [d for d in defs if not d.blocking]
assert len(blocking) >= 10
assert len(optional) >= 1
def test_matrix_report_includes_categories():
r = build_matrix_report(get_settings())
assert "categories" in r
assert "checks" in r
assert "secret_key" in r["checks"]
assert r["blocking"]["total"] > 0
assert "launch_allowed" in r

View File

@ -0,0 +1,153 @@
"""
سيناريوهات إطلاق تغطية واسعة عبر ASGI (واقعية، بدون خادم منفصل).
تُكمِّل test_api_smoke وتختبر مسارات إضافية، حواف، وتدفق مسوّق + LangGraph عبر الـ API مع mock.
"""
from __future__ import annotations
import uuid
import pytest
# مسارات GET عامة يجب أن تبقى مستقرة قبل الإطلاق
LAUNCH_GET_MATRIX = [
"/api/v1/health",
"/api/v1/ready",
"/api/v1/marketing/hub",
"/api/v1/strategy/summary",
"/api/v1/value-proposition/",
"/api/v1/customer-onboarding/journey",
"/api/v1/sales-os/overview",
"/api/v1/operations/snapshot",
"/api/v1/affiliates/program",
"/api/v1/affiliates/leaderboard/top",
]
@pytest.mark.launch
@pytest.mark.parametrize("path", LAUNCH_GET_MATRIX)
@pytest.mark.asyncio
async def test_launch_public_get_matrix(client, path: str):
r = await client.get(path)
assert r.status_code == 200, f"{path} -> {r.status_code} {r.text[:200]}"
@pytest.mark.launch
@pytest.mark.asyncio
async def test_go_live_gate_semantics(client):
r = await client.get("/api/v1/autonomous-foundation/integrations/go-live-gate")
assert r.status_code in (200, 403)
p = r.json()
assert p.get("gate") == "go_live"
assert "launch_allowed" in p
assert "readiness_percent" in p
@pytest.mark.launch
@pytest.mark.asyncio
async def test_agents_list_and_empire_and_langgraph_health(client):
lst = await client.get("/api/v1/agents/list")
assert lst.status_code == 200
body = lst.json()
assert body.get("total", 0) >= 1
assert isinstance(body.get("agents"), list)
emp = await client.get("/api/v1/agents/empire/status")
assert emp.status_code == 200
assert "empire" in emp.json() or "status" in emp.json()
lg = await client.get("/api/v1/agents/langgraph/health")
assert lg.status_code == 200
lgj = lg.json()
assert "graph_version" in lgj or "error" in lgj
@pytest.mark.launch
@pytest.mark.asyncio
async def test_affiliate_register_minimal_flow(client):
"""تسجيل مسوّق بريد فريد ثم التحقق من وجود السجل عبر leaderboard (قد يظهر بعد التفعيل حسب الفلتر)."""
email = f"launch_{uuid.uuid4().hex[:16]}@verify.dealix.test"
reg = await client.post(
"/api/v1/affiliates/register",
json={
"full_name": "Launch Verify User",
"email": email,
"phone": "0501234567",
"city": "Riyadh",
},
)
assert reg.status_code == 201, reg.text
data = reg.json()
assert data.get("referral_code")
assert data.get("status") in ("pending", "active", "PENDING", "ACTIVE") or "pending" in str(data.get("status")).lower()
prog = await client.get("/api/v1/affiliates/program")
assert prog.status_code == 200
pj = prog.json()
assert "journey_ar" in pj and "commission_rates" in pj
@pytest.mark.launch
@pytest.mark.slow
@pytest.mark.asyncio
async def test_ceo_langgraph_deal_cycle_via_api_mocked_engine(client, monkeypatch):
"""نفس مسار الإنتاج مع LeadEngine وهمي — سريع ومستقر في CI."""
async def fake_execute(self, discovery_task):
name = discovery_task.get("lead_name") or "Co"
return {
"leads": [
{
"name": name,
"social_signals": ["x"],
"discovery_score": 58.0,
"personalized_opener": "Launch scenario test",
}
]
}
import app.agents.discovery.lead_engine as lead_engine_mod
monkeypatch.setattr(lead_engine_mod.LeadEngine, "execute", fake_execute)
r = await client.post(
"/api/v1/agents/ceo/langgraph-deal-cycle",
json={
"company_name": "Scenario Corp LaunchTest",
"deal_id": "SC-LT-1",
"tenant_id": "pytest_launch",
"industry": "enterprise",
"city": "Riyadh",
},
)
assert r.status_code == 200, r.text
payload = r.json()
assert payload.get("graph_engine") == "langgraph"
assert payload.get("company_name") == "Scenario Corp LaunchTest"
log = payload.get("history_log") or []
assert len(log) >= 3
@pytest.mark.launch
@pytest.mark.asyncio
async def test_self_improvement_flow_post(client):
r = await client.post(
"/api/v1/autonomous-foundation/flows/self-improvement",
json={"tenant_id": "launch_scenario", "deal": {"signals": ["demo"]}},
)
assert r.status_code == 200
assert isinstance(r.json(), dict)
@pytest.mark.launch
@pytest.mark.asyncio
async def test_connectivity_matrix_post(client):
r = await client.post(
"/api/v1/autonomous-foundation/integrations/connectivity-test",
json={},
)
assert r.status_code == 200
j = r.json()
for k in ("salesforce", "whatsapp", "stripe", "summary"):
assert k in j

View File

@ -0,0 +1,21 @@
"""Marketing hub JSON + static paths."""
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_marketing_hub_json():
r = client.get("/api/v1/marketing/hub")
assert r.status_code == 200
data = r.json()
assert "paths" in data
assert data["paths"]["marketing_zip"].endswith(".zip")
assert "/dealix-marketing/" in data["paths"]["marketing_index"]
def test_dealix_marketing_index_when_enabled():
r = client.get("/dealix-marketing/index.html")
assert r.status_code == 200
assert b"dealix-marketing-bundle" in r.content or b"Dealix" in r.content

View File

@ -0,0 +1,75 @@
"""LangGraph CEO orchestrator: async path, state merge, and graph shape."""
from __future__ import annotations
import pytest
from app.agents.master_langgraph import (
CEOLangGraphOrchestrator,
GRAPH_VERSION,
LANGGRAPH_AVAILABLE,
build_ceo_deal_state,
)
@pytest.mark.skipif(not LANGGRAPH_AVAILABLE, reason="langgraph not installed")
@pytest.mark.asyncio
async def test_langgraph_deal_cycle_async_happy_path(monkeypatch):
orch = CEOLangGraphOrchestrator()
if orch.graph is None:
pytest.skip("graph not compiled")
async def fake_execute(self, discovery_task):
name = discovery_task.get("lead_name") or "Co"
return {
"leads": [
{
"name": name,
"social_signals": ["signal_a"],
"discovery_score": 55.0,
"personalized_opener": "Hello from test",
}
]
}
import app.agents.discovery.lead_engine as lead_engine_mod
monkeypatch.setattr(lead_engine_mod.LeadEngine, "execute", fake_execute)
state = build_ceo_deal_state(
{
"company_name": "Acme Test SA",
"deal_id": "T-1",
"tenant_id": "pytest",
}
)
out = await orch.run_deal_cycle_async(state)
assert "error" not in out, out
assert out.get("graph_engine") == "langgraph"
assert out.get("graph_version") == GRAPH_VERSION
assert out.get("company_name") == "Acme Test SA"
assert out.get("deal_stage") == "QUALIFIED"
assert out.get("strategic_tier") in ("nurture", "engage", "accelerate")
log = out.get("history_log") or []
assert len(log) >= 4
assert any("Compliance" in x for x in log)
assert out.get("email_sent") is True or any("Email" in x for x in log)
@pytest.mark.skipif(not LANGGRAPH_AVAILABLE, reason="langgraph not installed")
def test_describe_includes_nodes():
orch = CEOLangGraphOrchestrator()
d = orch.describe()
assert d["graph_version"] == GRAPH_VERSION
assert "prospecting" in d["nodes"]
assert "strategic_gate" in d["nodes"]
def test_build_ceo_deal_state_defaults():
s = build_ceo_deal_state({"company_name": "X"})
assert s["company_name"] == "X"
assert s["industry"] == "enterprise"
assert s["city"] == "Riyadh"
assert s["strategic_tier"] == ""
assert s["history_log"] == ["Deal initialized."]

View File

@ -0,0 +1,100 @@
"""
مسار مشترك جديد واقعي: تسجيل شركة، اشتراك trial، JWT، لوحة تحكم، ثم تسجيل دخول لاحق.
يُكمّل test_launch_readiness_scenarios ويُحاكي ما يفعله عميل يضغط «اشترك».
"""
from __future__ import annotations
import uuid
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.launch
@pytest.mark.asyncio
async def test_new_company_full_subscribe_login_dashboard_affiliate_surface():
"""1) تسجيل 2) لوحة محمية 3) login نفس الحساب 4) سطح تسويق عام 5) برنامج مسوّق."""
suffix = uuid.uuid4().hex[:14]
email = f"new_sub_{suffix}@dealix.journey.test"
password = "Journey_Secure_Pass_9"
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
reg = await ac.post(
"/api/v1/auth/register",
json={
"company_name": f"Journey Test Co {suffix}",
"company_name_ar": "شركة رحلة الاختبار",
"full_name": "مالك الاختبار",
"email": email,
"password": password,
"phone": "0501112233",
"industry": "saas",
},
)
assert reg.status_code == 200, reg.text
rj = reg.json()
assert rj.get("role") == "owner"
assert rj.get("access_token")
assert rj.get("tenant_id")
token = rj["access_token"]
dash = await ac.get(
"/api/v1/dashboard/overview",
headers={"Authorization": f"Bearer {token}"},
)
assert dash.status_code == 200
overview = dash.json()
assert "total_leads" in overview
assert "conversion_rate" in overview
assert overview["total_leads"] >= 0
login = await ac.post(
"/api/v1/auth/login",
json={"email": email, "password": password},
)
assert login.status_code == 200
lj = login.json()
assert lj.get("access_token")
assert lj.get("user_id") == rj.get("user_id")
hub = await ac.get("/api/v1/marketing/hub")
assert hub.status_code == 200
prog = await ac.get("/api/v1/affiliates/program")
assert prog.status_code == 200
assert "journey_ar" in prog.json()
@pytest.mark.launch
@pytest.mark.asyncio
async def test_new_subscriber_refresh_token_roundtrip():
suffix = uuid.uuid4().hex[:14]
email = f"refresh_{suffix}@dealix.journey.test"
password = "Refresh_Pass_8"
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
reg = await ac.post(
"/api/v1/auth/register",
json={
"company_name": f"Refresh Co {suffix}",
"full_name": "User",
"email": email,
"password": password,
},
)
assert reg.status_code == 200
refresh = reg.json()["refresh_token"]
ref = await ac.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh},
)
assert ref.status_code == 200
nj = ref.json()
assert nj.get("access_token")
assert nj.get("refresh_token")

View File

@ -0,0 +1,20 @@
from app.openclaw.hooks import before_agent_reply
def test_before_agent_reply_blocks_sensitive_without_approval():
result = before_agent_reply(
action="send_whatsapp",
payload={},
tenant_id="tenant_1",
)
assert result["allowed"] is False
assert "approval_required" in result["reason"]
def test_before_agent_reply_allows_safe_action():
result = before_agent_reply(
action="read_status",
payload={},
tenant_id="tenant_1",
)
assert result == {"allowed": True, "reason": "ok"}

View File

@ -0,0 +1,11 @@
import pytest
@pytest.mark.asyncio
async def test_operations_snapshot_public_demo(client):
r = await client.get("/api/v1/operations/snapshot")
assert r.status_code == 200
data = r.json()
assert data.get("demo_mode") is True
assert "connectors" in data
assert len(data["connectors"]) >= 1

View File

@ -0,0 +1,28 @@
import pytest
@pytest.mark.asyncio
async def test_commission_ledger_public_demo(client):
r = await client.get("/api/v1/sales-os/commission-ledger")
assert r.status_code == 200
data = r.json()
assert data.get("demo_mode") is True
assert len(data.get("items", [])) >= 1
assert data["items"][0].get("amount_sar") is not None
@pytest.mark.asyncio
async def test_rep_onboarding(client):
r = await client.get("/api/v1/sales-os/rep-onboarding")
assert r.status_code == 200
assert "phases" in r.json()
@pytest.mark.asyncio
async def test_overview_public_partial(client):
r = await client.get("/api/v1/sales-os/overview")
assert r.status_code == 200
body = r.json()
assert body.get("commission_ledger", {}).get("demo_mode") is True
assert "rep_onboarding" in body
assert body.get("daily_digest") is None

View File

@ -0,0 +1,20 @@
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_strategy_summary_json():
r = client.get("/api/v1/strategy/summary")
assert r.status_code == 200
data = r.json()
assert data["product"] == "Dealix"
assert data.get("blueprint_version")
assert "moat_pillars" in data
assert len(data["phases"]) >= 4
assert "auditable_targets" in data and len(data["auditable_targets"]) >= 4
assert "design_principles" in data and len(data["design_principles"]) >= 4
assert data["doc_paths"].get("ultimate_execution_ar")
assert data["doc_paths"].get("integration_master_ar")
assert "competitive_moat" in data

View File

@ -0,0 +1,30 @@
import pytest
from app.middleware.internal_api import _exempt_path
def test_internal_api_exempt_paths():
assert _exempt_path("/api/v1/health")
assert _exempt_path("/api/v1/ready")
assert _exempt_path("/api/v1/webhooks/whatsapp")
assert _exempt_path("/api/v1/marketing/hub")
assert _exempt_path("/api/v1/strategy/summary")
assert _exempt_path("/api/v1/value-proposition/")
assert _exempt_path("/api/v1/customer-onboarding/journey")
assert _exempt_path("/api/v1/sales-os/overview")
assert _exempt_path("/api/v1/operations/snapshot")
assert _exempt_path("/api/v1/affiliates/program")
assert _exempt_path("/api/v1/affiliates/register")
assert _exempt_path("/api/v1/affiliates/leaderboard/top")
assert not _exempt_path("/api/v1/sales-os/quota")
assert not _exempt_path("/api/v1/deals")
@pytest.mark.asyncio
async def test_value_proposition_public_json(client):
r = await client.get("/api/v1/value-proposition/")
assert r.status_code == 200
data = r.json()
assert "pillars" in data
assert len(data["pillars"]) >= 4
assert data["pillars"][0]["title_ar"]

Some files were not shown because too many files have changed in this diff Show More