fix(dealix): resolve Python deps, SQLAlchemy metadata, JWT, and frontend CI

- Align httpx, litellm, langchain, openai, mem0ai, crewai, numpy, requests, pydantic
- Rename SequenceEvent ORM attribute to event_metadata (DB column stays metadata)
- Use PyJWT instead of python-jose in security and auth service
- Mem0: MemoryConfig + graceful fallback when init fails (CI without keys)
- Frontend: I18nProvider in root layout, fix dashboard LeadScoreCard props, Section id,
  kpi-card useRef, en.json nameAr parity, e2e assertion for premium landing
- README: troubleshooting for connection refused and local E2E Playwright install

Co-authored-by: VoXc2 <VoXc2@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-12 10:32:05 +00:00
parent 1b2baf6bc8
commit 8c3d91c070
No known key found for this signature in database
15 changed files with 76 additions and 36 deletions

View File

@ -13,7 +13,7 @@
- FastAPI 0.115.6 on Python 3.12
- SQLAlchemy 2.0 async with PostgreSQL 16
- Celery 5.x with Redis broker
- JWT authentication (python-jose)
- JWT authentication (PyJWT)
- Multi-tenant data isolation via `tenant_id`
### Frontend (`salesflow-saas/frontend/`)

View File

@ -26,6 +26,12 @@ docker-compose up --build
Backend: `http://localhost:8000/docs`
Frontend: `http://localhost:3000`
**If the browser shows connection refused on `:3000` or `:8000`:** nothing is listening on that port yet. Start the stack (`docker compose up` from this folder) or run `uvicorn` / `npm run dev` manually. Confirm with `curl -sSf http://127.0.0.1:8000/api/v1/health` and ensure the browser is on the same machine as the server (not WSL/remote without port forwarding).
**Without Docker:** install Python 3.12+ and Node 22+, copy `.env` and `frontend/.env.local`, run Postgres/Redis (or point `DATABASE_URL` / `REDIS_URL` at existing instances), then `cd backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000` and `cd frontend && npm run dev`.
**E2E locally:** after `npm ci`, run `npx playwright install chromium` once, then `npm run test:e2e` (matches CI).
**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).

View File

@ -4,17 +4,23 @@ import json
try:
from mem0 import Memory
from mem0.configs.base import MemoryConfig
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})
Memory = None # type: ignore[misc, assignment]
MemoryConfig = None # type: ignore[misc, assignment]
class _MockMemory:
"""Used when mem0 is unavailable or cannot initialize (CI, missing API keys)."""
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:
"""
@ -33,7 +39,12 @@ class SelfHealingMemory:
}
}
}
self.memory = Memory(config=self.config)
self.memory = _MockMemory()
if Memory is not None and MemoryConfig is not None:
try:
self.memory = Memory(config=MemoryConfig.model_validate(self.config))
except Exception:
self.memory = _MockMemory()
def get_context(self, company_name: str, context_type: str = "general") -> str:
"""

View File

@ -101,7 +101,7 @@ class SequenceEvent(BaseModel):
channel = Column(String(50), nullable=False)
status = Column(String(50), nullable=False, default=SequenceEventStatus.SENT.value)
sent_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
metadata = Column(JSONB, default=dict)
event_metadata = Column("metadata", JSONB, default=dict)
enrollment = relationship("SequenceEnrollment", back_populates="events")
step = relationship("SequenceStep", back_populates="events")

View File

@ -8,7 +8,8 @@ from datetime import datetime, timedelta, timezone
from typing import Optional
from uuid import UUID
from jose import JWTError, jwt
import jwt
from jwt.exceptions import PyJWTError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@ -75,7 +76,7 @@ class AuthService:
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
return payload
except JWTError:
except PyJWTError:
return None
# ── OTP ───────────────────────────────────────

View File

@ -216,7 +216,7 @@ class SequenceEngine:
self.db.add(SequenceEvent(
enrollment_id=enrollment.id, step_id=step.id, channel=step.channel,
status=SequenceEventStatus.FAILED.value,
metadata={"reason": "no_consent", "detail": cr.message},
event_metadata={"reason": "no_consent", "detail": cr.message},
))
enrollment.status = SequenceStatus.STOPPED.value
await self.db.flush()
@ -226,7 +226,7 @@ class SequenceEngine:
self.db.add(SequenceEvent(
enrollment_id=enrollment.id, step_id=step.id, channel=step.channel,
status=SequenceEventStatus.SENT.value,
metadata={"variant": step.variant, "preview": step.template_content[:80]},
event_metadata={"variant": step.variant, "preview": step.template_content[:80]},
))
enrollment.current_step = idx + 1
if enrollment.current_step >= len(steps):

View File

@ -2,7 +2,9 @@ from datetime import datetime, timedelta, timezone
from typing import Optional
import bcrypt
from jose import JWTError, jwt
import jwt
from jwt.exceptions import PyJWTError
from app.config import get_settings
@ -46,5 +48,5 @@ def decode_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
return payload
except JWTError:
except PyJWTError:
return None

View File

@ -92,7 +92,7 @@ def process_pending_sequences(self):
channel=step.channel,
status="failed",
sent_at=datetime.now(timezone.utc),
metadata={"reason": "pdpl_consent_missing"},
event_metadata={"reason": "pdpl_consent_missing"},
)
db.add(event)
enrollment.current_step += 1
@ -181,7 +181,7 @@ def execute_sequence_step(self, enrollment_id, step_id, lead_id, channel, conten
channel=channel,
status="sent" if success else "failed",
sent_at=datetime.now(timezone.utc),
metadata={"content_preview": content[:100] if content else ""},
event_metadata={"content_preview": content[:100] if content else ""},
)
db.add(event)

View File

@ -1,8 +1,8 @@
# === Core Framework ===
fastapi==0.115.5
uvicorn[standard]==0.32.1
pydantic==2.9.2
pydantic-settings==2.6.1
pydantic>=2.10.0,<3
pydantic-settings>=2.10.1,<3
pydantic-extra-types[phonenumbers]>=2.0.0 # Saudi phone validation (+966)
python-multipart==0.0.12
@ -14,17 +14,17 @@ alembic==1.14.0
pgvector==0.3.6
# === AI / LLM Providers ===
litellm>=1.40.0 # Unified LLM provider (Groq/OpenAI/Claude/Gemini) with fallback
litellm>=1.74.0,<2 # httpx>=0.28 compatible (older litellm capped httpx<0.28)
instructor>=1.14.0 # Structured LLM outputs via Pydantic models
groq==0.12.0
openai==1.57.0
langchain==0.3.9
openai>=2.8.0,<3 # litellm 1.8x+ requires openai>=2.8; mem0ai 1.x supports it
langchain==0.3.28
langchain-groq==0.2.1
langchain-community==0.3.9
langchain-community==0.3.28
langchain-anthropic==0.2.0
langgraph==0.2.53
crewai==0.80.0
mem0ai==0.1.18
crewai>=0.95.0,<1 # aligns with litellm/langchain stack; 0.80 pulled incompatible crewai-tools
mem0ai>=1.0.0,<2 # 0.1.x capped openai<2; incompatible with current litellm
# === Arabic NLP ===
camel-tools>=1.5.0 # Arabic morphology, NER, dialect detection (NYU Abu Dhabi)
@ -35,7 +35,7 @@ pywa>=3.0.0 # Direct WhatsApp Cloud API (async, webhooks, templ
twilio==9.3.7 # Twilio fallback
# === Communication ===
httpx==0.27.2
httpx>=0.28.1,<0.29.0
resend>=2.0.0 # Transactional email API (free tier, FastAPI-native)
# === Saudi-specific ===
@ -73,10 +73,10 @@ statsforecast>=1.7.0 # Fast statistical time-series forecasting
# === Data & Utilities ===
beautifulsoup4==4.12.3
lxml==5.3.0
requests==2.32.3
requests>=2.32.5,<3
python-dateutil==2.9.0
pandas==2.2.3
numpy==2.1.3
numpy>=1.26.2,<2 # camel-tools requires numpy<2; langchain needs >=1.26.2 on Py3.12
python-decouple==3.8
paramiko==3.5.0
qrcode==8.0

View File

@ -13,7 +13,7 @@ test.describe("Subscriber journey (public shell)", () => {
test("home shows Dealix value and navigation affordances", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("Dealix", { exact: false }).first()).toBeVisible();
await expect(page.getByText(/لماذا Dealix/)).toBeVisible();
await expect(page.getByText(/هل تواجه هذه التحديات/)).toBeVisible();
});
test("landing page loads CTA toward app", async ({ page }) => {

View File

@ -24,6 +24,10 @@ import {
Receipt,
Layers,
LogOut,
MousePointerClick,
UserCheck,
TrendingUp,
Crosshair,
} from "lucide-react";
import { DashboardView } from "../../components/dealix/dashboard-view";
@ -48,6 +52,17 @@ import { PipelineKanban } from "../../components/dealix/pipeline-kanban";
import { UnifiedInbox } from "../../components/dealix/unified-inbox";
import { LeadScoreCard } from "../../components/dealix/lead-score-card";
const dashboardLeadScoreDemo = {
score: 82,
breakdown: [
{ key: "engagement", label: "التفاعل", value: 24, icon: MousePointerClick },
{ key: "profile", label: "الملف الشخصي", value: 20, icon: UserCheck },
{ key: "behavior", label: "السلوك", value: 22, icon: TrendingUp },
{ key: "intent", label: "نية الشراء", value: 16, icon: Crosshair },
],
recommendation: "عميل واعد — تابع خلال ٢٤ ساعة",
};
export default function DashboardPage() {
const auth = useRequireAuth();
const [activeTab, setActiveTab] = useState("overview");
@ -128,7 +143,7 @@ export default function DashboardPage() {
case "inbox":
return <UnifiedInbox />;
case "scoring":
return <LeadScoreCard score={82} breakdown={{ engagement: 24, profile: 20, behavior: 22, intent: 16 }} recommendation="عميل واعد — تابع خلال ٢٤ ساعة" />;
return <LeadScoreCard data={dashboardLeadScoreDemo} />;
case "onboarding":
return <OnboardingView />;
default:

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Noto_Kufi_Arabic } from "next/font/google";
import "./globals.css";
import { I18nProvider } from "@/i18n";
const kufi = Noto_Kufi_Arabic({
subsets: ["arabic", "latin"],
@ -26,7 +27,7 @@ export default function RootLayout({
<div className="fixed inset-0 z-[-1] bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-primary/10 via-background to-background pointer-events-none" />
<div className="fixed top-20 left-10 w-96 h-96 bg-accent/10 rounded-full mix-blend-multiply filter blur-[100px] opacity-50 z-[-1]" />
{children}
<I18nProvider>{children}</I18nProvider>
</body>
</html>
);

View File

@ -40,15 +40,18 @@ const stagger = {
function Section({
children,
className = "",
id,
}: {
children: React.ReactNode;
className?: string;
id?: string;
}) {
const ref = useRef(null);
const inView = useInView(ref, { once: true, margin: "-60px" });
return (
<motion.section
ref={ref}
id={id}
initial="hidden"
animate={inView ? "visible" : "hidden"}
variants={stagger}

View File

@ -19,7 +19,7 @@ interface KpiCardProps {
function useCountUp(target: number, duration: number = 1200) {
const [current, setCurrent] = useState(0);
const frameRef = useRef<number>();
const frameRef = useRef<number | undefined>(undefined);
useEffect(() => {
const start = performance.now();

View File

@ -108,6 +108,7 @@
"guarantee": "Full refund within 30 days if not satisfied",
"plan": {
"name": "Dealix All-in-One",
"nameAr": "ديلكس الشاملة",
"priceMonthly": "1,499",
"priceYearly": "14,999",
"priceYearlySave": "Save 2 months",