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

View File

@ -126,7 +126,7 @@ async def alert_stats(tenant_id: str):
@router.get("/digest", summary="Generate Arabic alert digest")
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)

View File

@ -7,6 +7,7 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
import asyncio
@ -79,6 +80,17 @@ async def lifespan(app: FastAPI):
_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(
title=f"{settings.APP_NAME} API",
description=(
@ -91,6 +103,7 @@ app = FastAPI(
redoc_url=_redoc,
openapi_url=_openapi,
lifespan=lifespan,
swagger_ui_parameters=_swagger_ui_parameters,
)
app.add_middleware(InternalApiTokenMiddleware)
@ -106,6 +119,27 @@ app.add_middleware(
# API Routes
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) ─────────
def _resolve_salesflow_root() -> Path:
if settings.MARKETING_STATIC_ROOT.strip():

View File

@ -35,6 +35,16 @@ def _exempt_path(path: str) -> bool:
return True
if path.startswith("/api/v1/affiliates/leaderboard"):
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

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 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("agents list", "GET", "/api/v1/agent-system/list"))
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 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(
await check(
"integration connectivity matrix",
@ -204,7 +204,7 @@ async def main() -> int:
await check(
"LangGraph CEO deal cycle (realistic, slow)",
"POST",
"/api/v1/agents/ceo/langgraph-deal-cycle",
"/api/v1/agent-system/ceo/langgraph-deal-cycle",
timeout=120.0,
json={
"company_name": "Launch Verification Co",

View File

@ -48,17 +48,17 @@ async def test_go_live_gate_semantics(client):
@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")
lst = await client.get("/api/v1/agent-system/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")
emp = await client.get("/api/v1/agent-system/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")
lg = await client.get("/api/v1/agent-system/langgraph/health")
assert lg.status_code == 200
lgj = lg.json()
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)
r = await client.post(
"/api/v1/agents/ceo/langgraph-deal-cycle",
"/api/v1/agent-system/ceo/langgraph-deal-cycle",
json={
"company_name": "Scenario Corp LaunchTest",
"deal_id": "SC-LT-1",

View File

@ -5,6 +5,7 @@
- [ ] `cd backend && py -m pytest tests -q` — يجب أن تمر كل الاختبارات.
- [ ] `cd frontend && npm run lint && 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)

View File

@ -10,8 +10,9 @@ import {
Landmark,
Compass,
} 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 = {
title: "موارد Dealix — عروض وحالات استخدام",

View File

@ -1,8 +1,7 @@
"use client";
import { useState, useEffect } from "react";
const API = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
import { getApiBaseUrl } from "@/lib/api-base";
interface AgentStatus {
role: string;
@ -40,7 +39,8 @@ export function IntelligenceDashboard() {
const fetchAgentStatus = async () => {
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) {
const data = await res.json();
setAgents(data.agents || []);
@ -50,7 +50,8 @@ export function IntelligenceDashboard() {
const fetchHealth = async () => {
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());
} catch {}
};
@ -59,7 +60,8 @@ export function IntelligenceDashboard() {
if (!leadForm.contact_name || !leadForm.company_name) return;
setLoading(true);
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",
headers: { "Content-Type": "application/json" },
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={{ fontSize: 13, color: "#64748b", marginBottom: 20 }}>{report.desc}</div>
<button
onClick={() => window.open(`${API}${report.endpoint}`, "_blank")}
onClick={() =>
window.open(
`${getApiBaseUrl().replace(/\/$/, "")}${report.endpoint}`,
"_blank"
)
}
style={{
padding: "10px 20px", background: "#0a0a0f", border: "1px solid #F5A623",
borderRadius: 8, color: "#F5A623", cursor: "pointer", fontWeight: 600, fontSize: 14

View File

@ -1,8 +1,7 @@
"use client";
import { useState } from "react";
const API = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
import { getApiBaseUrl } from "@/lib/api-base";
export function LeadGeneratorView() {
const [sector, setSector] = useState("تقنية المعلومات");
@ -28,7 +27,8 @@ export function LeadGeneratorView() {
setLoading(true);
setLeads([]);
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"
});
if (res.ok) {
@ -56,7 +56,8 @@ export function LeadGeneratorView() {
const runPipeline = async (lead: any) => {
setPipelineRunning(lead.company_name);
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",
headers: { "Content-Type": "application/json" },
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())