feat(dealix): FE/BE audit — agent-system prefix, OpenAPI path check, Swagger theme

- Move agent_system router to /api/v1/agent-system to avoid /agents conflicts
- Exempt demo UI API paths from internal token when DEALIX_INTERNAL_API_TOKEN is set
- Replace deprecated Query(regex=) with pattern= in intelligence
- GET / redirects to /api/docs; mount docs-assets + custom Swagger CSS
- Frontend: use getApiBaseUrl() for API URLs; fix intelligence dashboard base
- Add scripts/verify_frontend_openapi_paths.py; note in LAUNCH_CHECKLIST

Made-with: Cursor
This commit is contained in:
Sami Assiri 2026-04-12 14:57:30 +03:00
parent 8c3d91c070
commit fcdbc1f004
12 changed files with 313 additions and 31 deletions

View File

@ -10,7 +10,7 @@ from datetime import datetime, timezone
import logging import logging
logger = logging.getLogger("dealix.api.agents") logger = logging.getLogger("dealix.api.agents")
router = APIRouter(prefix="/agents", tags=["AI Agent System"]) router = APIRouter(prefix="/agent-system", tags=["AI Agent System"])
# ═══ Schemas ═══════════════════════════════════════════════ # ═══ Schemas ═══════════════════════════════════════════════
@ -631,16 +631,38 @@ async def agent_system_overview():
for k, v in sorted(layers.items()) for k, v in sorted(layers.items())
}, },
"api_endpoints": { "api_endpoints": {
"Empire": ["/agents/empire/status", "/agents/list", "/agents/overview"], "Empire": ["/agent-system/empire/status", "/agent-system/list", "/agent-system/overview"],
"Discovery": ["/agents/prospect", "/agents/prospect/sectors", "/agents/prospect/market-analysis", "Discovery": [
"/agents/leads/discover", "/agents/leads/sources", "/agents/leads/verify-phone"], "/agent-system/prospect",
"Engagement": ["/agents/whatsapp/campaign", "/agents/whatsapp/stats", "/agents/email/start-sequence"], "/agent-system/prospect/sectors",
"Qualification": ["/agents/qualify/lead", "/agents/qualify/score", "/agents/qualify/intent"], "/agent-system/prospect/market-analysis",
"Revenue": ["/agents/close/handle-objection", "/agents/close/proposal", "/agents/forecast/revenue"], "/agent-system/leads/discover",
"Intelligence": ["/agents/intelligence/analyze-conversation", "/agents/intelligence/deal-health", "/agents/market/competitors"], "/agent-system/leads/sources",
"CRM": ["/agents/crm/deal", "/agents/crm/pipeline"], "/agent-system/leads/verify-phone",
"Content": ["/agents/content/generate"], ],
"CEO": ["/agents/ceo/daily-cycle", "/agents/ceo/optimize"], "Engagement": [
"/agent-system/whatsapp/campaign",
"/agent-system/whatsapp/stats",
"/agent-system/email/start-sequence",
],
"Qualification": [
"/agent-system/qualify/lead",
"/agent-system/qualify/score",
"/agent-system/qualify/intent",
],
"Revenue": [
"/agent-system/close/handle-objection",
"/agent-system/close/proposal",
"/agent-system/forecast/revenue",
],
"Intelligence": [
"/agent-system/intelligence/analyze-conversation",
"/agent-system/intelligence/deal-health",
"/agent-system/market/competitors",
],
"CRM": ["/agent-system/crm/deal", "/agent-system/crm/pipeline"],
"Content": ["/agent-system/content/generate"],
"CEO": ["/agent-system/ceo/daily-cycle", "/agent-system/ceo/optimize"],
}, },
} }
except Exception as e: except Exception as e:

View File

@ -126,7 +126,7 @@ async def alert_stats(tenant_id: str):
@router.get("/digest", summary="Generate Arabic alert digest") @router.get("/digest", summary="Generate Arabic alert digest")
async def generate_digest(tenant_id: str, user_id: Optional[str] = None, async def generate_digest(tenant_id: str, user_id: Optional[str] = None,
period: str = Query(default="daily", regex="^(daily|weekly)$")): period: str = Query(default="daily", pattern="^(daily|weekly)$")):
return await get_alert_delivery().generate_digest(tenant_id, user_id, period) return await get_alert_delivery().generate_digest(tenant_id, user_id, period)

View File

@ -7,6 +7,7 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import asyncio import asyncio
@ -79,6 +80,17 @@ async def lifespan(app: FastAPI):
_docs, _redoc, _openapi = _openapi_urls() _docs, _redoc, _openapi = _openapi_urls()
_docs_static_dir = Path(__file__).resolve().parent / "static" / "docs"
_swagger_ui_parameters = None
if _docs and (_docs_static_dir / "swagger-dealix.css").is_file():
_swagger_ui_parameters = {
"persistAuthorization": True,
"displayRequestDuration": True,
"filter": True,
"tryItOutEnabled": True,
"customCssUrl": "/api/docs-assets/swagger-dealix.css",
}
app = FastAPI( app = FastAPI(
title=f"{settings.APP_NAME} API", title=f"{settings.APP_NAME} API",
description=( description=(
@ -91,6 +103,7 @@ app = FastAPI(
redoc_url=_redoc, redoc_url=_redoc,
openapi_url=_openapi, openapi_url=_openapi,
lifespan=lifespan, lifespan=lifespan,
swagger_ui_parameters=_swagger_ui_parameters,
) )
app.add_middleware(InternalApiTokenMiddleware) app.add_middleware(InternalApiTokenMiddleware)
@ -106,6 +119,27 @@ app.add_middleware(
# API Routes # API Routes
app.include_router(api_router, prefix="/api/v1") app.include_router(api_router, prefix="/api/v1")
if _docs and _docs_static_dir.is_dir():
app.mount(
"/api/docs-assets",
StaticFiles(directory=str(_docs_static_dir)),
name="docs_assets",
)
@app.get("/", include_in_schema=False)
async def root_redirect():
"""Avoid bare 404 on API origin; send developers to interactive docs."""
if _docs:
return RedirectResponse(url=_docs, status_code=307)
return {
"service": settings.APP_NAME,
"api": "/api/v1",
"health": "/api/v1/health",
"note": "OpenAPI UI disabled (EXPOSE_OPENAPI=false).",
}
# ── Static marketing assets (browse + direct download) ───────── # ── Static marketing assets (browse + direct download) ─────────
def _resolve_salesflow_root() -> Path: def _resolve_salesflow_root() -> Path:
if settings.MARKETING_STATIC_ROOT.strip(): if settings.MARKETING_STATIC_ROOT.strip():

View File

@ -35,6 +35,16 @@ def _exempt_path(path: str) -> bool:
return True return True
if path.startswith("/api/v1/affiliates/leaderboard"): if path.startswith("/api/v1/affiliates/leaderboard"):
return True return True
# Dashboard / demo widgets that call the API from the browser without internal token
# (still require JWT on routes that use Depends(get_current_user).)
if path in (
"/api/v1/agents/status",
"/api/v1/intelligence/health",
"/api/v1/intelligence/run-pipeline",
"/api/v1/dealix/generate-leads",
"/api/v1/dealix/full-power",
):
return True
return False return False

View File

@ -0,0 +1,144 @@
/* Dealix API docs — Swagger UI theme (dark, RTL-friendly typography) */
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,600;0,9..40,700&family=IBM+Plex+Sans+Arabic:wght@400;600;700&display=swap");
.swagger-ui {
font-family: "DM Sans", "IBM Plex Sans Arabic", system-ui, sans-serif;
}
.swagger-ui,
.swagger-ui .wrapper {
background: linear-gradient(165deg, #070b12 0%, #0f172a 45%, #0c1222 100%);
color: #e2e8f0;
}
.swagger-ui .topbar {
display: none;
}
.swagger-ui .information-container,
.swagger-ui .scheme-container {
background: rgba(15, 23, 42, 0.72);
border: 1px solid rgba(45, 212, 191, 0.18);
border-radius: 14px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
margin: 1.25rem 0;
padding: 1.25rem 1.5rem;
}
.swagger-ui .info .title {
color: #f8fafc;
font-size: 1.85rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.swagger-ui .info .title small {
background: rgba(45, 212, 191, 0.15);
border: 1px solid rgba(45, 212, 191, 0.35);
border-radius: 999px;
color: #5eead4;
padding: 0.2rem 0.65rem;
vertical-align: middle;
}
.swagger-ui .info .base-url,
.swagger-ui .info .description,
.swagger-ui .info p,
.swagger-ui .info li {
color: #cbd5e1;
line-height: 1.65;
}
.swagger-ui .info a,
.swagger-ui a {
color: #5eead4;
}
.swagger-ui .info a:hover,
.swagger-ui a:hover {
color: #99f6e4;
}
.swagger-ui .btn.authorize {
border-color: rgba(45, 212, 191, 0.45);
color: #5eead4;
border-radius: 10px;
}
.swagger-ui .btn.authorize:hover {
background: rgba(45, 212, 191, 0.12);
}
.swagger-ui .filter .operation-filter-input {
background: rgba(15, 23, 42, 0.9);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 10px;
color: #f1f5f9;
padding: 0.55rem 0.85rem;
}
.swagger-ui .opblock {
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.15);
background: rgba(15, 23, 42, 0.55);
margin-bottom: 0.65rem;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
}
.swagger-ui .opblock .opblock-summary {
border-radius: 11px;
}
.swagger-ui .opblock.opblock-get .opblock-summary-method {
background: #2563eb;
}
.swagger-ui .opblock.opblock-post .opblock-summary-method {
background: #059669;
}
.swagger-ui .opblock.opblock-put .opblock-summary-method {
background: #d97706;
}
.swagger-ui .opblock.opblock-delete .opblock-summary-method {
background: #dc2626;
}
.swagger-ui .opblock .opblock-summary-path,
.swagger-ui .opblock .opblock-summary-description {
color: #e2e8f0;
}
.swagger-ui .opblock-body pre,
.swagger-ui .microlight {
background: #020617 !important;
border-radius: 8px;
border: 1px solid rgba(51, 65, 85, 0.6);
}
.swagger-ui table thead tr th,
.swagger-ui table tbody tr td {
border-color: rgba(51, 65, 85, 0.6);
color: #e2e8f0;
}
.swagger-ui .model-box,
.swagger-ui .model {
background: rgba(15, 23, 42, 0.65);
border-radius: 8px;
}
.swagger-ui section.models {
border-color: rgba(45, 212, 191, 0.2);
border-radius: 12px;
}
.swagger-ui .tab li button.tablinks {
color: #94a3b8;
}
.swagger-ui .tab li button.tablinks.active {
color: #5eead4;
}

View File

@ -186,11 +186,11 @@ async def main() -> int:
) )
results.append(await check("affiliates program (public)", "GET", "/api/v1/affiliates/program")) 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("affiliates leaderboard", "GET", "/api/v1/affiliates/leaderboard/top"))
results.append(await check("agents list", "GET", "/api/v1/agents/list")) results.append(await check("agents list", "GET", "/api/v1/agent-system/list"))
results.append(await check("agents empire status", "GET", "/api/v1/agents/empire/status")) results.append(await check("agents empire status", "GET", "/api/v1/agent-system/empire/status"))
results.append(await check("openclaw safe core health", "GET", "/api/v1/autonomous-foundation/openclaw/health")) results.append(await check("openclaw safe core health", "GET", "/api/v1/autonomous-foundation/openclaw/health"))
results.append(await check("openclaw runs telemetry", "GET", "/api/v1/autonomous-foundation/openclaw/runs")) results.append(await check("openclaw runs telemetry", "GET", "/api/v1/autonomous-foundation/openclaw/runs"))
results.append(await check("LangGraph orchestrator health", "GET", "/api/v1/agents/langgraph/health")) results.append(await check("LangGraph orchestrator health", "GET", "/api/v1/agent-system/langgraph/health"))
results.append( results.append(
await check( await check(
"integration connectivity matrix", "integration connectivity matrix",
@ -204,7 +204,7 @@ async def main() -> int:
await check( await check(
"LangGraph CEO deal cycle (realistic, slow)", "LangGraph CEO deal cycle (realistic, slow)",
"POST", "POST",
"/api/v1/agents/ceo/langgraph-deal-cycle", "/api/v1/agent-system/ceo/langgraph-deal-cycle",
timeout=120.0, timeout=120.0,
json={ json={
"company_name": "Launch Verification Co", "company_name": "Launch Verification Co",

View File

@ -48,17 +48,17 @@ async def test_go_live_gate_semantics(client):
@pytest.mark.launch @pytest.mark.launch
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_agents_list_and_empire_and_langgraph_health(client): async def test_agents_list_and_empire_and_langgraph_health(client):
lst = await client.get("/api/v1/agents/list") lst = await client.get("/api/v1/agent-system/list")
assert lst.status_code == 200 assert lst.status_code == 200
body = lst.json() body = lst.json()
assert body.get("total", 0) >= 1 assert body.get("total", 0) >= 1
assert isinstance(body.get("agents"), list) assert isinstance(body.get("agents"), list)
emp = await client.get("/api/v1/agents/empire/status") emp = await client.get("/api/v1/agent-system/empire/status")
assert emp.status_code == 200 assert emp.status_code == 200
assert "empire" in emp.json() or "status" in emp.json() assert "empire" in emp.json() or "status" in emp.json()
lg = await client.get("/api/v1/agents/langgraph/health") lg = await client.get("/api/v1/agent-system/langgraph/health")
assert lg.status_code == 200 assert lg.status_code == 200
lgj = lg.json() lgj = lg.json()
assert "graph_version" in lgj or "error" in lgj assert "graph_version" in lgj or "error" in lgj
@ -113,7 +113,7 @@ async def test_ceo_langgraph_deal_cycle_via_api_mocked_engine(client, monkeypatc
monkeypatch.setattr(lead_engine_mod.LeadEngine, "execute", fake_execute) monkeypatch.setattr(lead_engine_mod.LeadEngine, "execute", fake_execute)
r = await client.post( r = await client.post(
"/api/v1/agents/ceo/langgraph-deal-cycle", "/api/v1/agent-system/ceo/langgraph-deal-cycle",
json={ json={
"company_name": "Scenario Corp LaunchTest", "company_name": "Scenario Corp LaunchTest",
"deal_id": "SC-LT-1", "deal_id": "SC-LT-1",

View File

@ -5,6 +5,7 @@
- [ ] `cd backend && py -m pytest tests -q` — يجب أن تمر كل الاختبارات. - [ ] `cd backend && py -m pytest tests -q` — يجب أن تمر كل الاختبارات.
- [ ] `cd frontend && npm run lint && npm run build`. - [ ] `cd frontend && npm run lint && npm run build`.
- [ ] من جذر `salesflow-saas`: `node scripts/sync-marketing-to-public.cjs` (يُشغَّل أيضاً تلقائياً قبل `npm run build`). - [ ] من جذر `salesflow-saas`: `node scripts/sync-marketing-to-public.cjs` (يُشغَّل أيضاً تلقائياً قبل `npm run build`).
- [ ] (اختياري) من جذر `salesflow-saas`: `py scripts/verify_frontend_openapi_paths.py` — يطابق مسارات `/api/v1` الظاهرة حرفيًا في الفرونت مع OpenAPI.
## 2. الخادم (API) ## 2. الخادم (API)

View File

@ -10,8 +10,9 @@ import {
Landmark, Landmark,
Compass, Compass,
} from "lucide-react"; } from "lucide-react";
import { getApiBaseUrl } from "@/lib/api-base";
const API = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:8000"; const API = getApiBaseUrl();
export const metadata = { export const metadata = {
title: "موارد Dealix — عروض وحالات استخدام", title: "موارد Dealix — عروض وحالات استخدام",

View File

@ -1,8 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { getApiBaseUrl } from "@/lib/api-base";
const API = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
interface AgentStatus { interface AgentStatus {
role: string; role: string;
@ -40,7 +39,8 @@ export function IntelligenceDashboard() {
const fetchAgentStatus = async () => { const fetchAgentStatus = async () => {
try { try {
const res = await fetch(`${API}/api/v1/agents/status`); const base = getApiBaseUrl().replace(/\/$/, "");
const res = await fetch(`${base}/api/v1/agents/status`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setAgents(data.agents || []); setAgents(data.agents || []);
@ -50,7 +50,8 @@ export function IntelligenceDashboard() {
const fetchHealth = async () => { const fetchHealth = async () => {
try { try {
const res = await fetch(`${API}/api/v1/intelligence/health`); const base = getApiBaseUrl().replace(/\/$/, "");
const res = await fetch(`${base}/api/v1/intelligence/health`);
if (res.ok) setHealth(await res.json()); if (res.ok) setHealth(await res.json());
} catch {} } catch {}
}; };
@ -59,7 +60,8 @@ export function IntelligenceDashboard() {
if (!leadForm.contact_name || !leadForm.company_name) return; if (!leadForm.contact_name || !leadForm.company_name) return;
setLoading(true); setLoading(true);
try { try {
const res = await fetch(`${API}/api/v1/intelligence/run-pipeline`, { const base = getApiBaseUrl().replace(/\/$/, "");
const res = await fetch(`${base}/api/v1/intelligence/run-pipeline`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: `lead_${Date.now()}`, ...leadForm }), body: JSON.stringify({ id: `lead_${Date.now()}`, ...leadForm }),
@ -251,7 +253,12 @@ export function IntelligenceDashboard() {
<div style={{ fontWeight: 700, fontSize: 16, color: "#e2e8f0", marginBottom: 8 }}>{report.title}</div> <div style={{ fontWeight: 700, fontSize: 16, color: "#e2e8f0", marginBottom: 8 }}>{report.title}</div>
<div style={{ fontSize: 13, color: "#64748b", marginBottom: 20 }}>{report.desc}</div> <div style={{ fontSize: 13, color: "#64748b", marginBottom: 20 }}>{report.desc}</div>
<button <button
onClick={() => window.open(`${API}${report.endpoint}`, "_blank")} onClick={() =>
window.open(
`${getApiBaseUrl().replace(/\/$/, "")}${report.endpoint}`,
"_blank"
)
}
style={{ style={{
padding: "10px 20px", background: "#0a0a0f", border: "1px solid #F5A623", padding: "10px 20px", background: "#0a0a0f", border: "1px solid #F5A623",
borderRadius: 8, color: "#F5A623", cursor: "pointer", fontWeight: 600, fontSize: 14 borderRadius: 8, color: "#F5A623", cursor: "pointer", fontWeight: 600, fontSize: 14

View File

@ -1,8 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { getApiBaseUrl } from "@/lib/api-base";
const API = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
export function LeadGeneratorView() { export function LeadGeneratorView() {
const [sector, setSector] = useState("تقنية المعلومات"); const [sector, setSector] = useState("تقنية المعلومات");
@ -28,7 +27,8 @@ export function LeadGeneratorView() {
setLoading(true); setLoading(true);
setLeads([]); setLeads([]);
try { try {
const res = await fetch(`${API}/api/v1/dealix/generate-leads?sector=${encodeURIComponent(sector)}&city=${encodeURIComponent(city)}&count=${count}`, { const base = getApiBaseUrl().replace(/\/$/, "");
const res = await fetch(`${base}/api/v1/dealix/generate-leads?sector=${encodeURIComponent(sector)}&city=${encodeURIComponent(city)}&count=${count}`, {
method: "POST" method: "POST"
}); });
if (res.ok) { if (res.ok) {
@ -56,7 +56,8 @@ export function LeadGeneratorView() {
const runPipeline = async (lead: any) => { const runPipeline = async (lead: any) => {
setPipelineRunning(lead.company_name); setPipelineRunning(lead.company_name);
try { try {
const res = await fetch(`${API}/api/v1/dealix/full-power`, { const base = getApiBaseUrl().replace(/\/$/, "");
const res = await fetch(`${base}/api/v1/dealix/full-power`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({

View File

@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""
Scan frontend/src for literal /api/v1/... path strings and verify exact matches
against the FastAPI OpenAPI schema.
Run from anywhere:
py salesflow-saas/scripts/verify_frontend_openapi_paths.py
Requires backend deps on PYTHONPATH (run after: cd salesflow-saas/backend && py -m pip install -r requirements.txt).
"""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
def main() -> int:
saas = Path(__file__).resolve().parent.parent
backend = saas / "backend"
fe_src = saas / "frontend" / "src"
os.environ.setdefault("DATABASE_URL", "sqlite+aiosqlite:///./openapi_verify.db")
os.environ.setdefault("DEALIX_INTERNAL_API_TOKEN", "")
sys.path.insert(0, str(backend))
os.chdir(backend)
from app.main import app
schema = app.openapi()
open_paths = {p.rstrip("/") or "/" for p in schema.get("paths", {}).keys()}
# Literal path segments in quotes or template strings (no ${...} inside path)
pat = re.compile(r"""['"`]((/api/v1/[a-zA-Z0-9_\-./]+))['"`]""")
found: set[str] = set()
for p in fe_src.rglob("*"):
if p.suffix not in (".ts", ".tsx"):
continue
text = p.read_text(encoding="utf-8", errors="ignore")
for m in pat.finditer(text):
raw = m.group(1).rstrip("/")
if "${" in raw or "{" in raw:
continue
if raw.endswith("/api/v1"):
continue
found.add(raw)
missing = sorted(p for p in found if p not in open_paths)
if missing:
print("Frontend literal paths not found as exact OpenAPI paths (may use path params or be dynamic):")
for m in missing:
print(f" - {m}")
print("\nTip: paths with {{id}} in OpenAPI need manual review.")
return 1
print(f"OK: {len(found)} literal /api/v1 paths match OpenAPI.")
return 0
if __name__ == "__main__":
raise SystemExit(main())