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 - FastAPI 0.115.6 on Python 3.12
- SQLAlchemy 2.0 async with PostgreSQL 16 - SQLAlchemy 2.0 async with PostgreSQL 16
- Celery 5.x with Redis broker - Celery 5.x with Redis broker
- JWT authentication (python-jose) - JWT authentication (PyJWT)
- Multi-tenant data isolation via `tenant_id` - Multi-tenant data isolation via `tenant_id`
### Frontend (`salesflow-saas/frontend/`) ### Frontend (`salesflow-saas/frontend/`)

View File

@ -26,6 +26,12 @@ docker-compose up --build
Backend: `http://localhost:8000/docs` Backend: `http://localhost:8000/docs`
Frontend: `http://localhost:3000` 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: **مسار التشغيل مع العميل**. **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). **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: try:
from mem0 import Memory from mem0 import Memory
from mem0.configs.base import MemoryConfig
except ImportError: except ImportError:
# Fallback mock for testing environments where mem0ai isn't available yet Memory = None # type: ignore[misc, assignment]
class Memory: MemoryConfig = None # type: ignore[misc, assignment]
def __init__(self, config=None):
self.store = []
class _MockMemory:
def search(self, query: str, user_id: str, **kwargs): """Used when mem0 is unavailable or cannot initialize (CI, missing API keys)."""
return [{"text": "Mocked memory context."}]
def __init__(self, config=None):
def add(self, text: str, user_id: str, metadata: dict = None, **kwargs): self.store = []
self.store.append({"text": text, "user_id": user_id, "metadata": metadata})
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: 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: 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) channel = Column(String(50), nullable=False)
status = Column(String(50), nullable=False, default=SequenceEventStatus.SENT.value) status = Column(String(50), nullable=False, default=SequenceEventStatus.SENT.value)
sent_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) 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") enrollment = relationship("SequenceEnrollment", back_populates="events")
step = relationship("SequenceStep", 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 typing import Optional
from uuid import UUID from uuid import UUID
from jose import JWTError, jwt import jwt
from jwt.exceptions import PyJWTError
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -75,7 +76,7 @@ class AuthService:
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
) )
return payload return payload
except JWTError: except PyJWTError:
return None return None
# ── OTP ─────────────────────────────────────── # ── OTP ───────────────────────────────────────

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
# === Core Framework === # === Core Framework ===
fastapi==0.115.5 fastapi==0.115.5
uvicorn[standard]==0.32.1 uvicorn[standard]==0.32.1
pydantic==2.9.2 pydantic>=2.10.0,<3
pydantic-settings==2.6.1 pydantic-settings>=2.10.1,<3
pydantic-extra-types[phonenumbers]>=2.0.0 # Saudi phone validation (+966) pydantic-extra-types[phonenumbers]>=2.0.0 # Saudi phone validation (+966)
python-multipart==0.0.12 python-multipart==0.0.12
@ -14,17 +14,17 @@ alembic==1.14.0
pgvector==0.3.6 pgvector==0.3.6
# === AI / LLM Providers === # === 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 instructor>=1.14.0 # Structured LLM outputs via Pydantic models
groq==0.12.0 groq==0.12.0
openai==1.57.0 openai>=2.8.0,<3 # litellm 1.8x+ requires openai>=2.8; mem0ai 1.x supports it
langchain==0.3.9 langchain==0.3.28
langchain-groq==0.2.1 langchain-groq==0.2.1
langchain-community==0.3.9 langchain-community==0.3.28
langchain-anthropic==0.2.0 langchain-anthropic==0.2.0
langgraph==0.2.53 langgraph==0.2.53
crewai==0.80.0 crewai>=0.95.0,<1 # aligns with litellm/langchain stack; 0.80 pulled incompatible crewai-tools
mem0ai==0.1.18 mem0ai>=1.0.0,<2 # 0.1.x capped openai<2; incompatible with current litellm
# === Arabic NLP === # === Arabic NLP ===
camel-tools>=1.5.0 # Arabic morphology, NER, dialect detection (NYU Abu Dhabi) 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 twilio==9.3.7 # Twilio fallback
# === Communication === # === Communication ===
httpx==0.27.2 httpx>=0.28.1,<0.29.0
resend>=2.0.0 # Transactional email API (free tier, FastAPI-native) resend>=2.0.0 # Transactional email API (free tier, FastAPI-native)
# === Saudi-specific === # === Saudi-specific ===
@ -73,10 +73,10 @@ statsforecast>=1.7.0 # Fast statistical time-series forecasting
# === Data & Utilities === # === Data & Utilities ===
beautifulsoup4==4.12.3 beautifulsoup4==4.12.3
lxml==5.3.0 lxml==5.3.0
requests==2.32.3 requests>=2.32.5,<3
python-dateutil==2.9.0 python-dateutil==2.9.0
pandas==2.2.3 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 python-decouple==3.8
paramiko==3.5.0 paramiko==3.5.0
qrcode==8.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 }) => { test("home shows Dealix value and navigation affordances", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await expect(page.getByText("Dealix", { exact: false }).first()).toBeVisible(); 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 }) => { test("landing page loads CTA toward app", async ({ page }) => {

View File

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

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Noto_Kufi_Arabic } from "next/font/google"; import { Noto_Kufi_Arabic } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { I18nProvider } from "@/i18n";
const kufi = Noto_Kufi_Arabic({ const kufi = Noto_Kufi_Arabic({
subsets: ["arabic", "latin"], 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 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]" /> <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> </body>
</html> </html>
); );

View File

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

View File

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

View File

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