mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-08-16 13:14:07 +00:00
chore: snapshot Dealix salesflow phase2 for audit worktree
Made-with: Cursor
This commit is contained in:
parent
7cc4fafd3b
commit
378ea5f742
30
.cursorrules
Normal file
30
.cursorrules
Normal 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
57
.github/workflows/dealix-ci.yml
vendored
Normal 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
|
||||
35
sales_assets/Dealix_Marketing_Arsenal.md
Normal file
35
sales_assets/Dealix_Marketing_Arsenal.md
Normal 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.**
|
||||
@ -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
|
||||
|
||||
8
salesflow-saas/.gitignore
vendored
8
salesflow-saas/.gitignore
vendored
@ -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/
|
||||
|
||||
96
salesflow-saas/MASTER-BLUEPRINT.mdc
Normal file
96
salesflow-saas/MASTER-BLUEPRINT.mdc
Normal 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 | 3–5× annual lift (customer-specific baselines) |
|
||||
| Sales efficiency | 70–80% 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 18–36 months |
|
||||
|
||||
**Targets:** 3–5× revenue lift, 70–80% 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 (0–36 months)
|
||||
|
||||
| Phase | Window | Focus |
|
||||
|-------|--------|--------|
|
||||
| 0 — Foundation | 0–90 days | Production hardening, health checks, pilot, unified GTM assets |
|
||||
| 1 — MVP revenue | Mo 2–3 | Deeper qualification, proposals, baseline ROI, pilot→paid |
|
||||
| 2 — Scale | Mo 4–9 | Enterprise patterns, voice GA path, predictive layer, API surface |
|
||||
| 3 — Dominate | Mo 10–36 | 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.*
|
||||
@ -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
|
||||
|
||||
```
|
||||
|
||||
@ -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.
|
||||
82
salesflow-saas/backend/.env.phase2.example
Normal file
82
salesflow-saas/backend/.env.phase2.example
Normal 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
|
||||
61
salesflow-saas/backend/alembic/env.py
Normal file
61
salesflow-saas/backend/alembic/env.py
Normal 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()
|
||||
25
salesflow-saas/backend/alembic/script.py.mako
Normal file
25
salesflow-saas/backend/alembic/script.py.mako
Normal 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"}
|
||||
@ -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
|
||||
@ -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:
|
||||
|
||||
133
salesflow-saas/backend/app/agents/discovery/prospecting_crew.py
Normal file
133
salesflow-saas/backend/app/agents/discovery/prospecting_crew.py
Normal 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)}
|
||||
|
||||
@ -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")
|
||||
|
||||
342
salesflow-saas/backend/app/agents/master_langgraph.py
Normal file
342
salesflow-saas/backend/app/agents/master_langgraph.py
Normal 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",
|
||||
],
|
||||
}
|
||||
76
salesflow-saas/backend/app/agents/memory_layer.py
Normal file
76
salesflow-saas/backend/app/agents/memory_layer.py
Normal 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()
|
||||
@ -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",
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
]
|
||||
|
||||
@ -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."""
|
||||
|
||||
282
salesflow-saas/backend/app/api/v1/autonomous_foundation.py
Normal file
282
salesflow-saas/backend/app/api/v1/autonomous_foundation.py
Normal 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)
|
||||
@ -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)
|
||||
|
||||
|
||||
22
salesflow-saas/backend/app/api/v1/customer_onboarding.py
Normal file
22
salesflow-saas/backend/app/api/v1/customer_onboarding.py
Normal 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()
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
44
salesflow-saas/backend/app/api/v1/marketing_hub.py
Normal file
44
salesflow-saas/backend/app/api/v1/marketing_hub.py
Normal 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.",
|
||||
},
|
||||
}
|
||||
242
salesflow-saas/backend/app/api/v1/operations.py
Normal file
242
salesflow-saas/backend/app/api/v1/operations.py
Normal 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)}
|
||||
@ -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)
|
||||
|
||||
161
salesflow-saas/backend/app/api/v1/sales_os.py
Normal file
161
salesflow-saas/backend/app/api/v1/sales_os.py
Normal 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")
|
||||
101
salesflow-saas/backend/app/api/v1/strategy_summary.py
Normal file
101
salesflow-saas/backend/app/api/v1/strategy_summary.py
Normal 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": "3–5× سنوياً مقابل خط أساس", "unit": "growth_vs_baseline"},
|
||||
{"id": "efficiency", "label_ar": "كفاءة المبيعات", "target": "−70–80% عمل يدوي في المسار", "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": "0–90 يوماً",
|
||||
"deliverables": ["CI واختبارات حرجة", "go-live gate", "pilot", "أصول تسويق موحّدة"],
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"name_ar": "MVP إيرادي",
|
||||
"window": "شهر 2–3",
|
||||
"deliverables": ["تأهيل أعمق", "عروض وعقود مسار", "لوحة ROI أساسية", "امتثال تشغيلي"],
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name_ar": "توسع مؤسسي",
|
||||
"window": "شهر 4–9",
|
||||
"deliverables": ["أنماط multi-tenant أعمق", "مسار صوت", "طبقة تنبؤ إيرادات", "بوابة API"],
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"name_ar": "قيادة فئة",
|
||||
"window": "شهر 10–36",
|
||||
"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",
|
||||
},
|
||||
}
|
||||
52
salesflow-saas/backend/app/api/v1/value_proposition.py
Normal file
52
salesflow-saas/backend/app/api/v1/value_proposition.py
Normal 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": (
|
||||
"يقيس النظام أثراً مالياً عبر ارتفاع الإيراد، تحسين معدل الفوز، "
|
||||
"وتسريع الأنبوب مع تقليل العمل اليدوي — جاهز للعرض على الإدارة العليا."
|
||||
),
|
||||
}
|
||||
@ -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)}")
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
2
salesflow-saas/backend/app/flows/__init__.py
Normal file
2
salesflow-saas/backend/app/flows/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Durable orchestration flows."""
|
||||
|
||||
77
salesflow-saas/backend/app/flows/prospecting_durable_flow.py
Normal file
77
salesflow-saas/backend/app/flows/prospecting_durable_flow.py
Normal 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()
|
||||
26
salesflow-saas/backend/app/flows/self_improvement_flow.py
Normal file
26
salesflow-saas/backend/app/flows/self_improvement_flow.py
Normal 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()
|
||||
@ -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)")
|
||||
|
||||
1
salesflow-saas/backend/app/middleware/__init__.py
Normal file
1
salesflow-saas/backend/app/middleware/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""ASGI / Starlette middleware."""
|
||||
64
salesflow-saas/backend/app/middleware/internal_api.py
Normal file
64
salesflow-saas/backend/app/middleware/internal_api.py
Normal 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)
|
||||
@ -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",
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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])
|
||||
|
||||
47
salesflow-saas/backend/app/models/operations.py
Normal file
47
salesflow-saas/backend/app/models/operations.py
Normal 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)
|
||||
2
salesflow-saas/backend/app/openclaw/__init__.py
Normal file
2
salesflow-saas/backend/app/openclaw/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""OpenClaw-compatible orchestration utilities."""
|
||||
|
||||
51
salesflow-saas/backend/app/openclaw/durable_flow.py
Normal file
51
salesflow-saas/backend/app/openclaw/durable_flow.py
Normal 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
|
||||
],
|
||||
}
|
||||
32
salesflow-saas/backend/app/openclaw/hooks.py
Normal file
32
salesflow-saas/backend/app/openclaw/hooks.py
Normal 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"}
|
||||
2
salesflow-saas/backend/app/openclaw/plugins/__init__.py
Normal file
2
salesflow-saas/backend/app/openclaw/plugins/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""OpenClaw plugin boundary implementations."""
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -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)
|
||||
13
salesflow-saas/backend/app/openclaw/plugins/stripe_plugin.py
Normal file
13
salesflow-saas/backend/app/openclaw/plugins/stripe_plugin.py
Normal 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}
|
||||
13
salesflow-saas/backend/app/openclaw/plugins/voice_plugin.py
Normal file
13
salesflow-saas/backend/app/openclaw/plugins/voice_plugin.py
Normal 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}
|
||||
@ -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}
|
||||
@ -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]
|
||||
|
||||
80
salesflow-saas/backend/app/services/audit_service.py
Normal file
80
salesflow-saas/backend/app/services/audit_service.py
Normal 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
|
||||
@ -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 ────────────────────────────────
|
||||
|
||||
|
||||
@ -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()
|
||||
@ -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 تجريبي (إن أمكن)",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
19
salesflow-saas/backend/app/services/email_service.py
Normal file
19
salesflow-saas/backend/app/services/email_service.py
Normal 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()
|
||||
42
salesflow-saas/backend/app/services/esign_service.py
Normal file
42
salesflow-saas/backend/app/services/esign_service.py
Normal 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()
|
||||
20
salesflow-saas/backend/app/services/executive_roi_service.py
Normal file
20
salesflow-saas/backend/app/services/executive_roi_service.py
Normal 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()
|
||||
377
salesflow-saas/backend/app/services/go_live_matrix.py
Normal file
377
salesflow-saas/backend/app/services/go_live_matrix.py
Normal 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}",
|
||||
}
|
||||
18
salesflow-saas/backend/app/services/linkedin_service.py
Normal file
18
salesflow-saas/backend/app/services/linkedin_service.py
Normal 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()
|
||||
143
salesflow-saas/backend/app/services/operations_hub.py
Normal file
143
salesflow-saas/backend/app/services/operations_hub.py
Normal 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()
|
||||
67
salesflow-saas/backend/app/services/osint_service.py
Normal file
67
salesflow-saas/backend/app/services/osint_service.py
Normal 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()
|
||||
78
salesflow-saas/backend/app/services/outbound_governance.py
Normal file
78
salesflow-saas/backend/app/services/outbound_governance.py
Normal 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)}
|
||||
@ -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()
|
||||
444
salesflow-saas/backend/app/services/sales_os_service.py
Normal file
444
salesflow-saas/backend/app/services/sales_os_service.py
Normal 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": "1–7",
|
||||
"title_ar": "الأسبوع الأول — الأدوات والقنوات",
|
||||
"tasks_ar": [
|
||||
"إكمال الملف والقطاع في النظام",
|
||||
"ربط واتساب التجريبي أو القناة المعتمدة",
|
||||
"قراءة سكربت الافتتاحية + تسجيل محاكاة واحدة",
|
||||
],
|
||||
},
|
||||
{
|
||||
"day_range": "8–14",
|
||||
"title_ar": "الأسبوع الثاني — الأنبوب والمتابعة",
|
||||
"tasks_ar": [
|
||||
"10 اتصالات/محادثات مؤهّلة في CRM",
|
||||
"استخدام تذكير المتابعة التلقائي",
|
||||
"اجتماع مراجعة مع المدير (15 دقيقة)",
|
||||
],
|
||||
},
|
||||
{
|
||||
"day_range": "15–30",
|
||||
"title_ar": "الأسبوعان 3–4 — الإغلاق والعمولة",
|
||||
"tasks_ar": [
|
||||
"عرض سعر واحد على الأقل في مرحلة متأخرة",
|
||||
"فهم شفافية العمولة من لوحة «دفتر العمولات»",
|
||||
"تحليل أسبوعي: معدل التحويل مقابل الهدف",
|
||||
],
|
||||
},
|
||||
],
|
||||
"kpi_ar": [
|
||||
"عدد اللقاءات المؤهّلة",
|
||||
"قيمة الأنبوب المفتوح",
|
||||
"صفقات مغلقة / عمولة معتمدة",
|
||||
],
|
||||
}
|
||||
117
salesflow-saas/backend/app/services/salesforce_agentforce.py
Normal file
117
salesflow-saas/backend/app/services/salesforce_agentforce.py
Normal 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()
|
||||
@ -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()
|
||||
31
salesflow-saas/backend/app/services/stripe_service.py
Normal file
31
salesflow-saas/backend/app/services/stripe_service.py
Normal 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()
|
||||
32
salesflow-saas/backend/app/services/voice_service.py
Normal file
32
salesflow-saas/backend/app/services/voice_service.py
Normal 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()
|
||||
@ -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")
|
||||
|
||||
@ -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:
|
||||
|
||||
8
salesflow-saas/backend/pytest.ini
Normal file
8
salesflow-saas/backend/pytest.ini
Normal 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
|
||||
4
salesflow-saas/backend/requirements-dev.txt
Normal file
4
salesflow-saas/backend/requirements-dev.txt
Normal 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
|
||||
@ -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
|
||||
|
||||
281
salesflow-saas/backend/scripts/full_stack_launch_test.py
Normal file
281
salesflow-saas/backend/scripts/full_stack_launch_test.py
Normal 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()))
|
||||
51
salesflow-saas/backend/scripts/go_live_gate.ps1
Normal file
51
salesflow-saas/backend/scripts/go_live_gate.ps1
Normal 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
|
||||
95
salesflow-saas/backend/scripts/launch_gate_runner.py
Normal file
95
salesflow-saas/backend/scripts/launch_gate_runner.py
Normal 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())
|
||||
42
salesflow-saas/backend/scripts/phase2_launch_check.py
Normal file
42
salesflow-saas/backend/scripts/phase2_launch_check.py
Normal 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())
|
||||
@ -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:
|
||||
|
||||
19
salesflow-saas/backend/tests/test_affiliates_public.py
Normal file
19
salesflow-saas/backend/tests/test_affiliates_public.py
Normal 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)
|
||||
34
salesflow-saas/backend/tests/test_api_smoke.py
Normal file
34
salesflow-saas/backend/tests/test_api_smoke.py
Normal 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")
|
||||
144
salesflow-saas/backend/tests/test_auth_rbac_api.py
Normal file
144
salesflow-saas/backend/tests/test_auth_rbac_api.py
Normal 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)
|
||||
@ -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
|
||||
28
salesflow-saas/backend/tests/test_customer_onboarding_api.py
Normal file
28
salesflow-saas/backend/tests/test_customer_onboarding_api.py
Normal 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"]
|
||||
)
|
||||
25
salesflow-saas/backend/tests/test_durable_flow_isolation.py
Normal file
25
salesflow-saas/backend/tests/test_durable_flow_isolation.py
Normal 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"]
|
||||
20
salesflow-saas/backend/tests/test_go_live_matrix.py
Normal file
20
salesflow-saas/backend/tests/test_go_live_matrix.py
Normal 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
|
||||
153
salesflow-saas/backend/tests/test_launch_readiness_scenarios.py
Normal file
153
salesflow-saas/backend/tests/test_launch_readiness_scenarios.py
Normal 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
|
||||
21
salesflow-saas/backend/tests/test_marketing_hub.py
Normal file
21
salesflow-saas/backend/tests/test_marketing_hub.py
Normal 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
|
||||
75
salesflow-saas/backend/tests/test_master_langgraph.py
Normal file
75
salesflow-saas/backend/tests/test_master_langgraph.py
Normal 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."]
|
||||
100
salesflow-saas/backend/tests/test_new_subscriber_journey.py
Normal file
100
salesflow-saas/backend/tests/test_new_subscriber_journey.py
Normal 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")
|
||||
20
salesflow-saas/backend/tests/test_openclaw_foundation.py
Normal file
20
salesflow-saas/backend/tests/test_openclaw_foundation.py
Normal 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"}
|
||||
11
salesflow-saas/backend/tests/test_operations_api.py
Normal file
11
salesflow-saas/backend/tests/test_operations_api.py
Normal 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
|
||||
28
salesflow-saas/backend/tests/test_sales_os_api.py
Normal file
28
salesflow-saas/backend/tests/test_sales_os_api.py
Normal 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
|
||||
20
salesflow-saas/backend/tests/test_strategy_summary.py
Normal file
20
salesflow-saas/backend/tests/test_strategy_summary.py
Normal 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
|
||||
30
salesflow-saas/backend/tests/test_value_proposition.py
Normal file
30
salesflow-saas/backend/tests/test_value_proposition.py
Normal 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
Loading…
Reference in New Issue
Block a user