Seth commited on
Commit ·
698ffee
1
Parent(s): 168b3d6
update
Browse files- SETUP.md +2 -0
- backend/app/auth_routes.py +225 -14
- backend/app/database.py +118 -7
- backend/app/main.py +305 -84
- backend/app/tenant_deps.py +44 -0
- backend/app/tenant_routes.py +106 -0
- frontend/src/components/layout/AppShell.jsx +1 -1
- frontend/src/components/layout/GoogleAuthBar.jsx +150 -11
- frontend/src/components/sequences/SequenceViewer.jsx +5 -4
- frontend/src/components/smartlead/SmartleadPanel.jsx +4 -3
- frontend/src/components/upload/UploadStep.jsx +2 -1
- frontend/src/components/workspace/DealLinkSearch.jsx +3 -2
- frontend/src/lib/api.js +4 -0
- frontend/src/pages/Contacts.jsx +11 -10
- frontend/src/pages/Deals.jsx +9 -8
- frontend/src/pages/EmailSequenceGenerator.jsx +2 -1
- frontend/src/pages/Leads.jsx +11 -10
- frontend/src/pages/RunHistory.jsx +2 -1
SETUP.md
CHANGED
|
@@ -65,6 +65,8 @@
|
|
| 65 |
|
| 66 |
For HTTPS deployments, also set `HTTPS_ONLY_COOKIES=1` and add your public origin to `CORS_ORIGINS` if the browser origin differs from the API host.
|
| 67 |
|
|
|
|
|
|
|
| 68 |
3. **Run Backend**:
|
| 69 |
```bash
|
| 70 |
cd backend
|
|
|
|
| 65 |
|
| 66 |
For HTTPS deployments, also set `HTTPS_ONLY_COOKIES=1` and add your public origin to `CORS_ORIGINS` if the browser origin differs from the API host.
|
| 67 |
|
| 68 |
+
**Multi-tenant / workspaces:** After sign-in, each user gets a workspace; the first user on an empty database becomes **admin** of the default workspace. Admins can **invite** others (Google accounts only) via the header; invitees open the link, then sign in with the **same email** as invited. **Smartlead webhook URL** must include your workspace id, e.g. `https://your-host/api/webhooks/smartlead?tenant_id=1` (replace `1` with your workspace id from the header switcher).
|
| 69 |
+
|
| 70 |
3. **Run Backend**:
|
| 71 |
```bash
|
| 72 |
cd backend
|
backend/app/auth_routes.py
CHANGED
|
@@ -5,15 +5,28 @@ Configure GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and either GOOGLE_REDIRECT_URI
|
|
| 5 |
|
| 6 |
from __future__ import annotations
|
| 7 |
|
|
|
|
| 8 |
import os
|
| 9 |
import secrets
|
|
|
|
| 10 |
from urllib.parse import urlencode
|
| 11 |
|
| 12 |
import httpx
|
| 13 |
-
from fastapi import APIRouter, HTTPException, Request
|
| 14 |
from fastapi.responses import RedirectResponse
|
| 15 |
from google.auth.transport import requests as google_auth_requests
|
| 16 |
from google.oauth2 import id_token
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
| 19 |
|
|
@@ -21,6 +34,10 @@ GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
| 21 |
GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"
|
| 22 |
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
def _client_configured() -> bool:
|
| 25 |
return bool(os.environ.get("GOOGLE_CLIENT_ID", "").strip() and os.environ.get("GOOGLE_CLIENT_SECRET", "").strip())
|
| 26 |
|
|
@@ -51,7 +68,6 @@ def _public_request_base(request: Request) -> str:
|
|
| 51 |
host = request.headers.get("host") or request.url.netloc or ""
|
| 52 |
|
| 53 |
if host:
|
| 54 |
-
# Use Host / X-Forwarded-Host as-is (keeps localhost:5173 for dev proxy).
|
| 55 |
base = f"{proto}://{host}" if proto else f"https://{host}"
|
| 56 |
base = _normalize_https_public_origin(base)
|
| 57 |
return base.rstrip("/")
|
|
@@ -78,17 +94,159 @@ def _post_login_url(request: Request) -> str:
|
|
| 78 |
return _public_request_base(request) + "/"
|
| 79 |
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
@router.get("/status")
|
| 82 |
async def auth_status():
|
| 83 |
return {"googleConfigured": _client_configured()}
|
| 84 |
|
| 85 |
|
| 86 |
@router.get("/me")
|
| 87 |
-
async def auth_me(request: Request):
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
raise HTTPException(status_code=401, detail="Not signed in")
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
|
| 94 |
@router.post("/logout")
|
|
@@ -97,13 +255,38 @@ async def auth_logout(request: Request):
|
|
| 97 |
return {"ok": True}
|
| 98 |
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
@router.get("/google")
|
| 101 |
-
async def google_login(request: Request):
|
| 102 |
if not _client_configured():
|
| 103 |
raise HTTPException(
|
| 104 |
status_code=503,
|
| 105 |
detail="Google sign-in is not configured (set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET).",
|
| 106 |
)
|
|
|
|
|
|
|
| 107 |
client_id = os.environ["GOOGLE_CLIENT_ID"].strip()
|
| 108 |
state = secrets.token_urlsafe(32)
|
| 109 |
redirect_uri = _redirect_uri(request)
|
|
@@ -171,11 +354,39 @@ async def google_callback(
|
|
| 171 |
except ValueError as e:
|
| 172 |
raise HTTPException(status_code=400, detail=f"Invalid id token: {e}") from e
|
| 173 |
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
return RedirectResponse(url=dest)
|
|
|
|
|
|
| 5 |
|
| 6 |
from __future__ import annotations
|
| 7 |
|
| 8 |
+
import hashlib
|
| 9 |
import os
|
| 10 |
import secrets
|
| 11 |
+
from datetime import datetime
|
| 12 |
from urllib.parse import urlencode
|
| 13 |
|
| 14 |
import httpx
|
| 15 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 16 |
from fastapi.responses import RedirectResponse
|
| 17 |
from google.auth.transport import requests as google_auth_requests
|
| 18 |
from google.oauth2 import id_token
|
| 19 |
+
from pydantic import BaseModel, Field
|
| 20 |
+
from sqlalchemy.orm import Session
|
| 21 |
+
|
| 22 |
+
from .database import (
|
| 23 |
+
Invitation,
|
| 24 |
+
SessionLocal,
|
| 25 |
+
Tenant,
|
| 26 |
+
TenantMembership,
|
| 27 |
+
User,
|
| 28 |
+
get_db,
|
| 29 |
+
)
|
| 30 |
|
| 31 |
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
| 32 |
|
|
|
|
| 34 |
GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token"
|
| 35 |
|
| 36 |
|
| 37 |
+
class SwitchTenantBody(BaseModel):
|
| 38 |
+
tenant_id: int = Field(..., ge=1)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
def _client_configured() -> bool:
|
| 42 |
return bool(os.environ.get("GOOGLE_CLIENT_ID", "").strip() and os.environ.get("GOOGLE_CLIENT_SECRET", "").strip())
|
| 43 |
|
|
|
|
| 68 |
host = request.headers.get("host") or request.url.netloc or ""
|
| 69 |
|
| 70 |
if host:
|
|
|
|
| 71 |
base = f"{proto}://{host}" if proto else f"https://{host}"
|
| 72 |
base = _normalize_https_public_origin(base)
|
| 73 |
return base.rstrip("/")
|
|
|
|
| 94 |
return _public_request_base(request) + "/"
|
| 95 |
|
| 96 |
|
| 97 |
+
def _norm_email(s: str | None) -> str:
|
| 98 |
+
return (s or "").strip().lower()
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _invite_token_hash(raw: str) -> str:
|
| 102 |
+
return hashlib.sha256(raw.strip().encode("utf-8")).hexdigest()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _upsert_user(db: Session, id_info: dict) -> User:
|
| 106 |
+
sub = id_info.get("sub")
|
| 107 |
+
if not sub:
|
| 108 |
+
raise HTTPException(status_code=400, detail="Missing subject in id_token")
|
| 109 |
+
u = db.query(User).filter(User.google_sub == sub).first()
|
| 110 |
+
email = id_info.get("email") or ""
|
| 111 |
+
name = id_info.get("name")
|
| 112 |
+
picture = id_info.get("picture")
|
| 113 |
+
if u:
|
| 114 |
+
u.email = email or u.email
|
| 115 |
+
u.name = name or u.name
|
| 116 |
+
u.picture = picture or u.picture
|
| 117 |
+
else:
|
| 118 |
+
u = User(google_sub=sub, email=email, name=name, picture=picture)
|
| 119 |
+
db.add(u)
|
| 120 |
+
db.flush()
|
| 121 |
+
return u
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _try_accept_invite(db: Session, user: User, raw_token: str | None):
|
| 125 |
+
"""
|
| 126 |
+
Apply pending invitation. Returns tenant_id if membership was created or already existed via invite.
|
| 127 |
+
Raises ValueError('invite_email_mismatch') if email doesn't match.
|
| 128 |
+
Returns None if no token or invalid/expired invite (caller may bootstrap instead).
|
| 129 |
+
"""
|
| 130 |
+
if not raw_token:
|
| 131 |
+
return None
|
| 132 |
+
h = _invite_token_hash(raw_token)
|
| 133 |
+
inv = db.query(Invitation).filter(Invitation.token_hash == h).first()
|
| 134 |
+
if not inv or inv.accepted_at is not None:
|
| 135 |
+
return None
|
| 136 |
+
if inv.expires_at < datetime.utcnow():
|
| 137 |
+
return None
|
| 138 |
+
if _norm_email(user.email) != _norm_email(inv.email):
|
| 139 |
+
raise ValueError("invite_email_mismatch")
|
| 140 |
+
existing = (
|
| 141 |
+
db.query(TenantMembership)
|
| 142 |
+
.filter(
|
| 143 |
+
TenantMembership.user_id == user.id,
|
| 144 |
+
TenantMembership.tenant_id == inv.tenant_id,
|
| 145 |
+
)
|
| 146 |
+
.first()
|
| 147 |
+
)
|
| 148 |
+
if not existing:
|
| 149 |
+
db.add(
|
| 150 |
+
TenantMembership(
|
| 151 |
+
user_id=user.id,
|
| 152 |
+
tenant_id=inv.tenant_id,
|
| 153 |
+
role=inv.role or "member",
|
| 154 |
+
)
|
| 155 |
+
)
|
| 156 |
+
inv.accepted_at = datetime.utcnow()
|
| 157 |
+
return inv.tenant_id
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _bootstrap_membership_if_needed(db: Session, user: User) -> None:
|
| 161 |
+
if db.query(TenantMembership).filter(TenantMembership.user_id == user.id).count() > 0:
|
| 162 |
+
return
|
| 163 |
+
total_m = db.query(TenantMembership).count()
|
| 164 |
+
if total_m == 0:
|
| 165 |
+
t = db.query(Tenant).order_by(Tenant.id).first()
|
| 166 |
+
if not t:
|
| 167 |
+
t = Tenant(name="My workspace")
|
| 168 |
+
db.add(t)
|
| 169 |
+
db.flush()
|
| 170 |
+
db.add(TenantMembership(user_id=user.id, tenant_id=t.id, role="admin"))
|
| 171 |
+
else:
|
| 172 |
+
label = (user.email or "user").split("@")[0]
|
| 173 |
+
t = Tenant(name=f"{label}'s workspace")
|
| 174 |
+
db.add(t)
|
| 175 |
+
db.flush()
|
| 176 |
+
db.add(TenantMembership(user_id=user.id, tenant_id=t.id, role="admin"))
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _pick_current_tenant_id(db: Session, user: User, invite_tid: int | None) -> int | None:
|
| 180 |
+
if invite_tid is not None:
|
| 181 |
+
m = (
|
| 182 |
+
db.query(TenantMembership)
|
| 183 |
+
.filter(
|
| 184 |
+
TenantMembership.user_id == user.id,
|
| 185 |
+
TenantMembership.tenant_id == invite_tid,
|
| 186 |
+
)
|
| 187 |
+
.first()
|
| 188 |
+
)
|
| 189 |
+
if m:
|
| 190 |
+
return int(invite_tid)
|
| 191 |
+
m = (
|
| 192 |
+
db.query(TenantMembership)
|
| 193 |
+
.filter(TenantMembership.user_id == user.id)
|
| 194 |
+
.order_by(TenantMembership.id)
|
| 195 |
+
.first()
|
| 196 |
+
)
|
| 197 |
+
return int(m.tenant_id) if m else None
|
| 198 |
+
|
| 199 |
+
|
| 200 |
@router.get("/status")
|
| 201 |
async def auth_status():
|
| 202 |
return {"googleConfigured": _client_configured()}
|
| 203 |
|
| 204 |
|
| 205 |
@router.get("/me")
|
| 206 |
+
async def auth_me(request: Request, db: Session = Depends(get_db)):
|
| 207 |
+
profile = request.session.get("user")
|
| 208 |
+
uid = request.session.get("user_id")
|
| 209 |
+
if profile and uid is None and profile.get("sub"):
|
| 210 |
+
row = db.query(User).filter(User.google_sub == profile["sub"]).first()
|
| 211 |
+
if row:
|
| 212 |
+
request.session["user_id"] = row.id
|
| 213 |
+
uid = row.id
|
| 214 |
+
if not request.session.get("current_tenant_id"):
|
| 215 |
+
ms = (
|
| 216 |
+
db.query(TenantMembership)
|
| 217 |
+
.filter(TenantMembership.user_id == row.id)
|
| 218 |
+
.order_by(TenantMembership.id)
|
| 219 |
+
.first()
|
| 220 |
+
)
|
| 221 |
+
if ms:
|
| 222 |
+
request.session["current_tenant_id"] = ms.tenant_id
|
| 223 |
+
if not profile or uid is None:
|
| 224 |
raise HTTPException(status_code=401, detail="Not signed in")
|
| 225 |
+
|
| 226 |
+
uid = int(uid)
|
| 227 |
+
tenants_out = []
|
| 228 |
+
memberships = (
|
| 229 |
+
db.query(TenantMembership, Tenant)
|
| 230 |
+
.join(Tenant, Tenant.id == TenantMembership.tenant_id)
|
| 231 |
+
.filter(TenantMembership.user_id == uid)
|
| 232 |
+
.order_by(Tenant.name)
|
| 233 |
+
.all()
|
| 234 |
+
)
|
| 235 |
+
cur_tid = request.session.get("current_tenant_id")
|
| 236 |
+
current_role = None
|
| 237 |
+
for m, t in memberships:
|
| 238 |
+
tid = int(t.id)
|
| 239 |
+
tenants_out.append({"id": tid, "name": t.name, "role": m.role})
|
| 240 |
+
if cur_tid is not None and int(cur_tid) == tid:
|
| 241 |
+
current_role = m.role
|
| 242 |
+
|
| 243 |
+
return {
|
| 244 |
+
**profile,
|
| 245 |
+
"user_id": uid,
|
| 246 |
+
"tenants": tenants_out,
|
| 247 |
+
"current_tenant_id": int(cur_tid) if cur_tid is not None else None,
|
| 248 |
+
"current_role": current_role,
|
| 249 |
+
}
|
| 250 |
|
| 251 |
|
| 252 |
@router.post("/logout")
|
|
|
|
| 255 |
return {"ok": True}
|
| 256 |
|
| 257 |
|
| 258 |
+
@router.post("/switch-tenant")
|
| 259 |
+
async def auth_switch_tenant(
|
| 260 |
+
body: SwitchTenantBody,
|
| 261 |
+
request: Request,
|
| 262 |
+
db: Session = Depends(get_db),
|
| 263 |
+
):
|
| 264 |
+
uid = request.session.get("user_id")
|
| 265 |
+
if uid is None:
|
| 266 |
+
raise HTTPException(status_code=401, detail="Sign in required")
|
| 267 |
+
m = (
|
| 268 |
+
db.query(TenantMembership)
|
| 269 |
+
.filter(
|
| 270 |
+
TenantMembership.user_id == int(uid),
|
| 271 |
+
TenantMembership.tenant_id == body.tenant_id,
|
| 272 |
+
)
|
| 273 |
+
.first()
|
| 274 |
+
)
|
| 275 |
+
if not m:
|
| 276 |
+
raise HTTPException(status_code=403, detail="Not a member of this workspace")
|
| 277 |
+
request.session["current_tenant_id"] = body.tenant_id
|
| 278 |
+
return {"ok": True, "tenant_id": body.tenant_id, "role": m.role}
|
| 279 |
+
|
| 280 |
+
|
| 281 |
@router.get("/google")
|
| 282 |
+
async def google_login(request: Request, invite: str | None = None):
|
| 283 |
if not _client_configured():
|
| 284 |
raise HTTPException(
|
| 285 |
status_code=503,
|
| 286 |
detail="Google sign-in is not configured (set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET).",
|
| 287 |
)
|
| 288 |
+
if invite:
|
| 289 |
+
request.session["pending_invite_token"] = invite.strip()
|
| 290 |
client_id = os.environ["GOOGLE_CLIENT_ID"].strip()
|
| 291 |
state = secrets.token_urlsafe(32)
|
| 292 |
redirect_uri = _redirect_uri(request)
|
|
|
|
| 354 |
except ValueError as e:
|
| 355 |
raise HTTPException(status_code=400, detail=f"Invalid id token: {e}") from e
|
| 356 |
|
| 357 |
+
db = SessionLocal()
|
| 358 |
+
try:
|
| 359 |
+
user = _upsert_user(db, id_info)
|
| 360 |
+
invite_token = request.session.pop("pending_invite_token", None)
|
| 361 |
+
invite_tid: int | None = None
|
| 362 |
+
try:
|
| 363 |
+
invite_tid = _try_accept_invite(db, user, invite_token)
|
| 364 |
+
except ValueError as e:
|
| 365 |
+
if str(e) == "invite_email_mismatch":
|
| 366 |
+
db.rollback()
|
| 367 |
+
return RedirectResponse(url=f"{dest}?auth_error=invite_email_mismatch")
|
| 368 |
+
raise
|
| 369 |
+
_bootstrap_membership_if_needed(db, user)
|
| 370 |
+
current_tid = _pick_current_tenant_id(db, user, invite_tid)
|
| 371 |
+
if current_tid is None:
|
| 372 |
+
db.rollback()
|
| 373 |
+
raise HTTPException(status_code=500, detail="Could not assign workspace")
|
| 374 |
+
|
| 375 |
+
request.session["user_id"] = user.id
|
| 376 |
+
request.session["current_tenant_id"] = current_tid
|
| 377 |
+
request.session["user"] = {
|
| 378 |
+
"sub": id_info.get("sub"),
|
| 379 |
+
"email": id_info.get("email"),
|
| 380 |
+
"name": id_info.get("name"),
|
| 381 |
+
"picture": id_info.get("picture"),
|
| 382 |
+
"email_verified": id_info.get("email_verified"),
|
| 383 |
+
}
|
| 384 |
+
db.commit()
|
| 385 |
+
except Exception:
|
| 386 |
+
db.rollback()
|
| 387 |
+
raise
|
| 388 |
+
finally:
|
| 389 |
+
db.close()
|
| 390 |
+
|
| 391 |
return RedirectResponse(url=dest)
|
| 392 |
+
|
backend/app/database.py
CHANGED
|
@@ -1,4 +1,14 @@
|
|
| 1 |
-
from sqlalchemy import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from sqlalchemy.ext.declarative import declarative_base
|
| 3 |
from sqlalchemy.orm import sessionmaker
|
| 4 |
from datetime import datetime
|
|
@@ -15,10 +25,55 @@ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
| 15 |
Base = declarative_base()
|
| 16 |
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
class UploadedFile(Base):
|
| 19 |
__tablename__ = "uploaded_files"
|
| 20 |
-
|
| 21 |
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
| 22 |
file_id = Column(String, unique=True, index=True)
|
| 23 |
filename = Column(String)
|
| 24 |
contact_count = Column(Integer)
|
|
@@ -28,8 +83,9 @@ class UploadedFile(Base):
|
|
| 28 |
|
| 29 |
class Prompt(Base):
|
| 30 |
__tablename__ = "prompts"
|
| 31 |
-
|
| 32 |
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
| 33 |
file_id = Column(String, index=True)
|
| 34 |
product_name = Column(String)
|
| 35 |
prompt_template = Column(Text)
|
|
@@ -38,8 +94,9 @@ class Prompt(Base):
|
|
| 38 |
|
| 39 |
class GeneratedSequence(Base):
|
| 40 |
__tablename__ = "generated_sequences"
|
| 41 |
-
|
| 42 |
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
| 43 |
file_id = Column(String, index=True)
|
| 44 |
sequence_id = Column(Integer) # Contact sequence number
|
| 45 |
email_number = Column(Integer, default=1) # Email number in sequence (1-10)
|
|
@@ -56,8 +113,9 @@ class GeneratedSequence(Base):
|
|
| 56 |
|
| 57 |
class Contact(Base):
|
| 58 |
__tablename__ = "contacts"
|
| 59 |
-
|
| 60 |
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
| 61 |
file_id = Column(String, index=True)
|
| 62 |
row_index = Column(Integer)
|
| 63 |
first_name = Column(String)
|
|
@@ -72,9 +130,11 @@ class Contact(Base):
|
|
| 72 |
|
| 73 |
class CrmLead(Base):
|
| 74 |
"""Lead synced from Smartlead replies (webhook) — CRM pipeline status is local."""
|
|
|
|
| 75 |
__tablename__ = "crm_leads"
|
| 76 |
|
| 77 |
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
| 78 |
smartlead_lead_id = Column(String, index=True)
|
| 79 |
campaign_id = Column(String, index=True)
|
| 80 |
campaign_name = Column(String)
|
|
@@ -95,9 +155,11 @@ class CrmLead(Base):
|
|
| 95 |
|
| 96 |
class CrmDeal(Base):
|
| 97 |
"""Pipeline deal (often converted from a lead)."""
|
|
|
|
| 98 |
__tablename__ = "crm_deals"
|
| 99 |
|
| 100 |
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
| 101 |
name = Column(String, index=True)
|
| 102 |
stage = Column(String, default="new") # new|discovery|proposal|negotiation|won|lost
|
| 103 |
owner_initials = Column(String, default="")
|
|
@@ -117,8 +179,9 @@ class CrmDeal(Base):
|
|
| 117 |
|
| 118 |
class SmartleadRun(Base):
|
| 119 |
__tablename__ = "smartlead_runs"
|
| 120 |
-
|
| 121 |
id = Column(Integer, primary_key=True, index=True)
|
|
|
|
| 122 |
file_id = Column(String, index=True)
|
| 123 |
run_id = Column(String, unique=True, index=True)
|
| 124 |
campaign_id = Column(String, index=True)
|
|
@@ -136,8 +199,56 @@ class SmartleadRun(Base):
|
|
| 136 |
completed_at = Column(DateTime, nullable=True)
|
| 137 |
|
| 138 |
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
Base.metadata.create_all(bind=engine)
|
|
|
|
| 141 |
|
| 142 |
|
| 143 |
def get_db():
|
|
|
|
| 1 |
+
from sqlalchemy import (
|
| 2 |
+
create_engine,
|
| 3 |
+
Column,
|
| 4 |
+
Integer,
|
| 5 |
+
String,
|
| 6 |
+
Text,
|
| 7 |
+
DateTime,
|
| 8 |
+
JSON,
|
| 9 |
+
ForeignKey,
|
| 10 |
+
UniqueConstraint,
|
| 11 |
+
)
|
| 12 |
from sqlalchemy.ext.declarative import declarative_base
|
| 13 |
from sqlalchemy.orm import sessionmaker
|
| 14 |
from datetime import datetime
|
|
|
|
| 25 |
Base = declarative_base()
|
| 26 |
|
| 27 |
|
| 28 |
+
class User(Base):
|
| 29 |
+
__tablename__ = "users"
|
| 30 |
+
|
| 31 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 32 |
+
google_sub = Column(String, unique=True, index=True, nullable=False)
|
| 33 |
+
email = Column(String, index=True)
|
| 34 |
+
name = Column(String, nullable=True)
|
| 35 |
+
picture = Column(String, nullable=True)
|
| 36 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class Tenant(Base):
|
| 40 |
+
__tablename__ = "tenants"
|
| 41 |
+
|
| 42 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 43 |
+
name = Column(String, nullable=False)
|
| 44 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class TenantMembership(Base):
|
| 48 |
+
__tablename__ = "tenant_memberships"
|
| 49 |
+
|
| 50 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 51 |
+
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
| 52 |
+
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True)
|
| 53 |
+
role = Column(String, default="member", nullable=False) # admin | member
|
| 54 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 55 |
+
__table_args__ = (UniqueConstraint("user_id", "tenant_id", name="uq_user_tenant"),)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class Invitation(Base):
|
| 59 |
+
__tablename__ = "invitations"
|
| 60 |
+
|
| 61 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 62 |
+
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False, index=True)
|
| 63 |
+
email = Column(String, nullable=False, index=True)
|
| 64 |
+
token_hash = Column(String(64), unique=True, nullable=False, index=True)
|
| 65 |
+
role = Column(String, default="member")
|
| 66 |
+
invited_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
| 67 |
+
expires_at = Column(DateTime, nullable=False)
|
| 68 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 69 |
+
accepted_at = Column(DateTime, nullable=True)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
class UploadedFile(Base):
|
| 73 |
__tablename__ = "uploaded_files"
|
| 74 |
+
|
| 75 |
id = Column(Integer, primary_key=True, index=True)
|
| 76 |
+
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
|
| 77 |
file_id = Column(String, unique=True, index=True)
|
| 78 |
filename = Column(String)
|
| 79 |
contact_count = Column(Integer)
|
|
|
|
| 83 |
|
| 84 |
class Prompt(Base):
|
| 85 |
__tablename__ = "prompts"
|
| 86 |
+
|
| 87 |
id = Column(Integer, primary_key=True, index=True)
|
| 88 |
+
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
|
| 89 |
file_id = Column(String, index=True)
|
| 90 |
product_name = Column(String)
|
| 91 |
prompt_template = Column(Text)
|
|
|
|
| 94 |
|
| 95 |
class GeneratedSequence(Base):
|
| 96 |
__tablename__ = "generated_sequences"
|
| 97 |
+
|
| 98 |
id = Column(Integer, primary_key=True, index=True)
|
| 99 |
+
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
|
| 100 |
file_id = Column(String, index=True)
|
| 101 |
sequence_id = Column(Integer) # Contact sequence number
|
| 102 |
email_number = Column(Integer, default=1) # Email number in sequence (1-10)
|
|
|
|
| 113 |
|
| 114 |
class Contact(Base):
|
| 115 |
__tablename__ = "contacts"
|
| 116 |
+
|
| 117 |
id = Column(Integer, primary_key=True, index=True)
|
| 118 |
+
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
|
| 119 |
file_id = Column(String, index=True)
|
| 120 |
row_index = Column(Integer)
|
| 121 |
first_name = Column(String)
|
|
|
|
| 130 |
|
| 131 |
class CrmLead(Base):
|
| 132 |
"""Lead synced from Smartlead replies (webhook) — CRM pipeline status is local."""
|
| 133 |
+
|
| 134 |
__tablename__ = "crm_leads"
|
| 135 |
|
| 136 |
id = Column(Integer, primary_key=True, index=True)
|
| 137 |
+
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
|
| 138 |
smartlead_lead_id = Column(String, index=True)
|
| 139 |
campaign_id = Column(String, index=True)
|
| 140 |
campaign_name = Column(String)
|
|
|
|
| 155 |
|
| 156 |
class CrmDeal(Base):
|
| 157 |
"""Pipeline deal (often converted from a lead)."""
|
| 158 |
+
|
| 159 |
__tablename__ = "crm_deals"
|
| 160 |
|
| 161 |
id = Column(Integer, primary_key=True, index=True)
|
| 162 |
+
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
|
| 163 |
name = Column(String, index=True)
|
| 164 |
stage = Column(String, default="new") # new|discovery|proposal|negotiation|won|lost
|
| 165 |
owner_initials = Column(String, default="")
|
|
|
|
| 179 |
|
| 180 |
class SmartleadRun(Base):
|
| 181 |
__tablename__ = "smartlead_runs"
|
| 182 |
+
|
| 183 |
id = Column(Integer, primary_key=True, index=True)
|
| 184 |
+
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=True, index=True)
|
| 185 |
file_id = Column(String, index=True)
|
| 186 |
run_id = Column(String, unique=True, index=True)
|
| 187 |
campaign_id = Column(String, index=True)
|
|
|
|
| 199 |
completed_at = Column(DateTime, nullable=True)
|
| 200 |
|
| 201 |
|
| 202 |
+
def run_migrations(connection_engine):
|
| 203 |
+
"""Add tenant_id to legacy SQLite tables and attach rows to the default tenant."""
|
| 204 |
+
from sqlalchemy import inspect, text
|
| 205 |
+
|
| 206 |
+
insp = inspect(connection_engine)
|
| 207 |
+
tenant_tables = (
|
| 208 |
+
"uploaded_files",
|
| 209 |
+
"prompts",
|
| 210 |
+
"generated_sequences",
|
| 211 |
+
"contacts",
|
| 212 |
+
"crm_leads",
|
| 213 |
+
"crm_deals",
|
| 214 |
+
"smartlead_runs",
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
with connection_engine.begin() as conn:
|
| 218 |
+
if insp.has_table("tenants"):
|
| 219 |
+
n = conn.execute(text("SELECT COUNT(*) FROM tenants")).scalar()
|
| 220 |
+
if n == 0:
|
| 221 |
+
conn.execute(
|
| 222 |
+
text(
|
| 223 |
+
"INSERT INTO tenants (name, created_at) VALUES (:name, datetime('now'))"
|
| 224 |
+
),
|
| 225 |
+
{"name": "Default workspace"},
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
default_tid = conn.execute(text("SELECT id FROM tenants ORDER BY id LIMIT 1")).scalar()
|
| 229 |
+
if default_tid is None:
|
| 230 |
+
default_tid = 1
|
| 231 |
+
|
| 232 |
+
insp = inspect(connection_engine)
|
| 233 |
+
for tbl in tenant_tables:
|
| 234 |
+
if not insp.has_table(tbl):
|
| 235 |
+
continue
|
| 236 |
+
cols = [c["name"] for c in insp.get_columns(tbl)]
|
| 237 |
+
if "tenant_id" not in cols:
|
| 238 |
+
conn.execute(text(f"ALTER TABLE {tbl} ADD COLUMN tenant_id INTEGER"))
|
| 239 |
+
|
| 240 |
+
for tbl in tenant_tables:
|
| 241 |
+
if not insp.has_table(tbl):
|
| 242 |
+
continue
|
| 243 |
+
conn.execute(
|
| 244 |
+
text(f"UPDATE {tbl} SET tenant_id = :tid WHERE tenant_id IS NULL"),
|
| 245 |
+
{"tid": default_tid},
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# Create tables then migrate legacy SQLite schemas
|
| 250 |
Base.metadata.create_all(bind=engine)
|
| 251 |
+
run_migrations(engine)
|
| 252 |
|
| 253 |
|
| 254 |
def get_db():
|
backend/app/main.py
CHANGED
|
@@ -22,6 +22,7 @@ from datetime import datetime, timedelta
|
|
| 22 |
from .database import (
|
| 23 |
get_db,
|
| 24 |
SessionLocal,
|
|
|
|
| 25 |
UploadedFile,
|
| 26 |
Prompt,
|
| 27 |
GeneratedSequence,
|
|
@@ -47,6 +48,8 @@ from .models import (
|
|
| 47 |
from .gpt_service import generate_email_sequence, enrich_manual_contact_profile
|
| 48 |
from .smartlead_client import SmartleadClient
|
| 49 |
from .auth_routes import router as auth_router
|
|
|
|
|
|
|
| 50 |
|
| 51 |
app = FastAPI()
|
| 52 |
|
|
@@ -78,6 +81,7 @@ app.add_middleware(
|
|
| 78 |
)
|
| 79 |
|
| 80 |
app.include_router(auth_router)
|
|
|
|
| 81 |
|
| 82 |
# Create uploads directory
|
| 83 |
UPLOAD_DIR = Path("/data/uploads")
|
|
@@ -378,7 +382,10 @@ def _move_lead_to_contacts_core(db: Session, lead: CrmLead) -> dict:
|
|
| 378 |
return {"ok": False, "error": "Lead has no email"}
|
| 379 |
existing = (
|
| 380 |
db.query(Contact)
|
| 381 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
| 382 |
.first()
|
| 383 |
)
|
| 384 |
if existing:
|
|
@@ -395,6 +402,7 @@ def _move_lead_to_contacts_core(db: Session, lead: CrmLead) -> dict:
|
|
| 395 |
"smartlead_webhook": lead.raw_webhook,
|
| 396 |
}
|
| 397 |
contact = Contact(
|
|
|
|
| 398 |
file_id=SMARTLEAD_IMPORT_FILE_ID,
|
| 399 |
row_index=lead.id,
|
| 400 |
first_name=lead.first_name or "",
|
|
@@ -427,7 +435,10 @@ def _convert_contact_to_lead_core(db: Session, contact: Contact) -> dict:
|
|
| 427 |
return {"ok": False, "error": "Contact has no email"}
|
| 428 |
dup = (
|
| 429 |
db.query(CrmLead)
|
| 430 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
| 431 |
.first()
|
| 432 |
)
|
| 433 |
if dup:
|
|
@@ -455,6 +466,7 @@ def _convert_contact_to_lead_core(db: Session, contact: Contact) -> dict:
|
|
| 455 |
"company_details": company_snapshot,
|
| 456 |
}
|
| 457 |
lead = CrmLead(
|
|
|
|
| 458 |
smartlead_lead_id=f"from-contact-{contact.id}",
|
| 459 |
campaign_id=CONTACTS_TO_LEADS_CAMPAIGN_ID,
|
| 460 |
campaign_name=CONTACTS_TO_LEADS_CAMPAIGN_NAME,
|
|
@@ -533,8 +545,10 @@ def hello():
|
|
| 533 |
|
| 534 |
|
| 535 |
@app.post("/api/upload-csv")
|
| 536 |
-
async def upload_csv(file: UploadFile = File(...),
|
| 537 |
"""Upload and parse CSV file from Apollo"""
|
|
|
|
|
|
|
| 538 |
try:
|
| 539 |
# Generate unique file ID
|
| 540 |
file_id = str(uuid.uuid4())
|
|
@@ -551,6 +565,7 @@ async def upload_csv(file: UploadFile = File(...), db: Session = Depends(get_db)
|
|
| 551 |
|
| 552 |
# Save upload metadata
|
| 553 |
db_file = UploadedFile(
|
|
|
|
| 554 |
file_id=file_id,
|
| 555 |
filename=file.filename,
|
| 556 |
contact_count=contact_count,
|
|
@@ -563,6 +578,7 @@ async def upload_csv(file: UploadFile = File(...), db: Session = Depends(get_db)
|
|
| 563 |
row_dict = row.to_dict()
|
| 564 |
sanitized_raw_data = {str(k): _json_safe(v) for k, v in row_dict.items()}
|
| 565 |
contact = Contact(
|
|
|
|
| 566 |
file_id=file_id,
|
| 567 |
row_index=idx + 1,
|
| 568 |
first_name=_pick_field(row_dict, ["first name", "firstname", "first_name"]),
|
|
@@ -587,9 +603,10 @@ async def upload_csv(file: UploadFile = File(...), db: Session = Depends(get_db)
|
|
| 587 |
|
| 588 |
|
| 589 |
@app.get("/api/contact-fields")
|
| 590 |
-
async def contact_fields(
|
| 591 |
"""Return all available contact field names from uploaded Apollo rows."""
|
| 592 |
-
|
|
|
|
| 593 |
fields = set(["first_name", "last_name", "email", "company", "title", "file_id", "created_at"])
|
| 594 |
for item in contacts:
|
| 595 |
raw = item[0] or {}
|
|
@@ -615,9 +632,10 @@ async def list_contacts(
|
|
| 615 |
sort_dir: str = Query("desc", description="Sort direction: asc|desc"),
|
| 616 |
limit: int = Query(200, ge=1, le=1000),
|
| 617 |
offset: int = Query(0, ge=0),
|
| 618 |
-
|
| 619 |
):
|
| 620 |
-
|
|
|
|
| 621 |
if search:
|
| 622 |
pattern = f"%{search}%"
|
| 623 |
query = query.filter(
|
|
@@ -693,12 +711,20 @@ async def list_contacts(
|
|
| 693 |
|
| 694 |
|
| 695 |
@app.post("/api/contacts")
|
| 696 |
-
async def create_contact(body: ContactCreateRequest,
|
| 697 |
"""Create a contact manually (inline table add). Email must be unique."""
|
|
|
|
| 698 |
email = _safe_str(body.email).lower()
|
| 699 |
if not email:
|
| 700 |
raise HTTPException(status_code=400, detail="Email is required")
|
| 701 |
-
exists =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 702 |
if exists:
|
| 703 |
raise HTTPException(status_code=409, detail="A contact with this email already exists")
|
| 704 |
fn = _safe_str(body.first_name)
|
|
@@ -713,6 +739,7 @@ async def create_contact(body: ContactCreateRequest, db: Session = Depends(get_d
|
|
| 713 |
"Title": ti,
|
| 714 |
}
|
| 715 |
contact = Contact(
|
|
|
|
| 716 |
file_id=MANUAL_CONTACT_FILE_ID,
|
| 717 |
row_index=0,
|
| 718 |
first_name=fn,
|
|
@@ -741,13 +768,14 @@ async def create_contact(body: ContactCreateRequest, db: Session = Depends(get_d
|
|
| 741 |
|
| 742 |
|
| 743 |
@app.post("/api/contacts/bulk-delete")
|
| 744 |
-
async def bulk_delete_contacts(body: BulkContactIdsRequest,
|
|
|
|
| 745 |
if not body.contact_ids:
|
| 746 |
raise HTTPException(status_code=400, detail="contact_ids required")
|
| 747 |
ids = list({int(x) for x in body.contact_ids})
|
| 748 |
deleted = (
|
| 749 |
db.query(Contact)
|
| 750 |
-
.filter(Contact.id.in_(ids))
|
| 751 |
.delete(synchronize_session=False)
|
| 752 |
)
|
| 753 |
db.commit()
|
|
@@ -755,14 +783,19 @@ async def bulk_delete_contacts(body: BulkContactIdsRequest, db: Session = Depend
|
|
| 755 |
|
| 756 |
|
| 757 |
@app.post("/api/contacts/seed-demo")
|
| 758 |
-
async def seed_demo_contacts(
|
| 759 |
"""
|
| 760 |
Replace previous demo-seeded contacts and insert a variety of sample rows
|
| 761 |
(rich Apollo-style raw_data) for testing filters and UI.
|
| 762 |
"""
|
|
|
|
|
|
|
| 763 |
removed = (
|
| 764 |
db.query(Contact)
|
| 765 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
| 766 |
.delete(synchronize_session=False)
|
| 767 |
)
|
| 768 |
specs = [
|
|
@@ -946,6 +979,7 @@ async def seed_demo_contacts(db: Session = Depends(get_db)):
|
|
| 946 |
rd["First Name"] = s["fn"]
|
| 947 |
rd["Last Name"] = s["ln"]
|
| 948 |
row = Contact(
|
|
|
|
| 949 |
file_id=DEMO_CONTACTS_FILE_ID,
|
| 950 |
row_index=i,
|
| 951 |
first_name=s["fn"],
|
|
@@ -966,18 +1000,23 @@ async def seed_demo_contacts(db: Session = Depends(get_db)):
|
|
| 966 |
|
| 967 |
|
| 968 |
@app.post("/api/contacts/bulk-convert-to-leads")
|
| 969 |
-
async def bulk_convert_contacts_to_leads(body: BulkContactIdsRequest,
|
| 970 |
"""
|
| 971 |
For each contact: create a CrmLead (campaign «Contacts») copied from the contact.
|
| 972 |
Contacts are not removed; they remain until explicitly deleted. Fails per-row if the
|
| 973 |
contact has no email or a lead with the same email already exists.
|
| 974 |
"""
|
|
|
|
| 975 |
if not body.contact_ids:
|
| 976 |
raise HTTPException(status_code=400, detail="contact_ids required")
|
| 977 |
converted = 0
|
| 978 |
errors: List[dict] = []
|
| 979 |
for cid in body.contact_ids:
|
| 980 |
-
contact =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 981 |
if not contact:
|
| 982 |
errors.append({"contact_id": cid, "error": "not found"})
|
| 983 |
continue
|
|
@@ -994,14 +1033,16 @@ async def bulk_convert_contacts_to_leads(body: BulkContactIdsRequest, db: Sessio
|
|
| 994 |
async def search_company_names(
|
| 995 |
q: str = Query("", description="Substring match on Contact.company"),
|
| 996 |
limit: int = Query(25, ge=1, le=100),
|
| 997 |
-
|
| 998 |
):
|
| 999 |
"""Distinct company strings from contacts for deal/account linking."""
|
|
|
|
| 1000 |
raw_q = _safe_str(q)
|
| 1001 |
pattern = f"%{raw_q}%" if raw_q else "%"
|
| 1002 |
rows = (
|
| 1003 |
db.query(Contact.company)
|
| 1004 |
.filter(
|
|
|
|
| 1005 |
Contact.company.isnot(None),
|
| 1006 |
Contact.company != "",
|
| 1007 |
Contact.company.ilike(pattern),
|
|
@@ -1028,8 +1069,13 @@ async def search_company_names(
|
|
| 1028 |
|
| 1029 |
|
| 1030 |
@app.get("/api/contacts/{contact_id}")
|
| 1031 |
-
async def get_contact(contact_id: int,
|
| 1032 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1033 |
if not contact:
|
| 1034 |
raise HTTPException(status_code=404, detail="Contact not found")
|
| 1035 |
return {
|
|
@@ -1128,7 +1174,14 @@ def _enrich_deal_response(db: Session, row: CrmDeal) -> dict:
|
|
| 1128 |
out = _deal_to_dict(row)
|
| 1129 |
out["linked_contact"] = None
|
| 1130 |
if row.contact_id:
|
| 1131 |
-
c =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1132 |
if c:
|
| 1133 |
out["company_details"] = _contact_company_details_dict(c)
|
| 1134 |
out["company_details_contact_id"] = c.id
|
|
@@ -1157,8 +1210,13 @@ def _sync_contact_raw_from_columns(contact: Contact) -> None:
|
|
| 1157 |
|
| 1158 |
|
| 1159 |
@app.patch("/api/contacts/{contact_id}")
|
| 1160 |
-
async def patch_contact(contact_id: int, body: ContactPatchRequest,
|
| 1161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1162 |
if not contact:
|
| 1163 |
raise HTTPException(status_code=404, detail="Contact not found")
|
| 1164 |
data = body.model_dump(exclude_unset=True)
|
|
@@ -1170,7 +1228,11 @@ async def patch_contact(contact_id: int, body: ContactPatchRequest, db: Session
|
|
| 1170 |
raise HTTPException(status_code=400, detail="Email cannot be empty")
|
| 1171 |
taken = (
|
| 1172 |
db.query(Contact)
|
| 1173 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1174 |
.first()
|
| 1175 |
)
|
| 1176 |
if taken:
|
|
@@ -1204,7 +1266,7 @@ async def patch_contact(contact_id: int, body: ContactPatchRequest, db: Session
|
|
| 1204 |
|
| 1205 |
|
| 1206 |
@app.post("/api/contacts/{contact_id}/enrich")
|
| 1207 |
-
async def enrich_contact(contact_id: int,
|
| 1208 |
"""
|
| 1209 |
GPT-enrich company/contact fields for manually added contacts only.
|
| 1210 |
|
|
@@ -1213,9 +1275,14 @@ async def enrich_contact(contact_id: int, db: Session = Depends(get_db)):
|
|
| 1213 |
Google Search grounding (GEMINI_API_KEY); DuckDuckGo fallback;
|
| 1214 |
ENRICHMENT_CONTACT_EMAIL helps Wikipedia.
|
| 1215 |
"""
|
|
|
|
| 1216 |
if not os.getenv("OPENAI_API_KEY"):
|
| 1217 |
raise HTTPException(status_code=503, detail="OpenAI API key is not configured")
|
| 1218 |
-
contact =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1219 |
if not contact:
|
| 1220 |
raise HTTPException(status_code=404, detail="Contact not found")
|
| 1221 |
src = (contact.source or "").strip().lower()
|
|
@@ -1314,8 +1381,13 @@ async def enrich_contact(contact_id: int, db: Session = Depends(get_db)):
|
|
| 1314 |
|
| 1315 |
|
| 1316 |
@app.get("/api/contacts/{contact_id}/sequences")
|
| 1317 |
-
async def get_contact_sequences(contact_id: int,
|
| 1318 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1319 |
if not contact:
|
| 1320 |
raise HTTPException(status_code=404, detail="Contact not found")
|
| 1321 |
|
|
@@ -1325,8 +1397,9 @@ async def get_contact_sequences(contact_id: int, db: Session = Depends(get_db)):
|
|
| 1325 |
sequences = (
|
| 1326 |
db.query(GeneratedSequence)
|
| 1327 |
.filter(
|
|
|
|
| 1328 |
GeneratedSequence.file_id == contact.file_id,
|
| 1329 |
-
GeneratedSequence.email == contact.email
|
| 1330 |
)
|
| 1331 |
.order_by(GeneratedSequence.sequence_id, GeneratedSequence.email_number)
|
| 1332 |
.all()
|
|
@@ -1351,18 +1424,23 @@ async def get_contact_sequences(contact_id: int, db: Session = Depends(get_db)):
|
|
| 1351 |
|
| 1352 |
|
| 1353 |
@app.post("/api/save-prompts")
|
| 1354 |
-
async def save_prompts(request: PromptSaveRequest,
|
| 1355 |
"""Save prompt templates for products"""
|
|
|
|
| 1356 |
try:
|
| 1357 |
# Delete existing prompts for this file
|
| 1358 |
-
db.query(Prompt).filter(
|
| 1359 |
-
|
|
|
|
|
|
|
|
|
|
| 1360 |
# Save new prompts
|
| 1361 |
for product_name, prompt_template in request.prompts.items():
|
| 1362 |
prompt = Prompt(
|
|
|
|
| 1363 |
file_id=request.file_id,
|
| 1364 |
product_name=product_name,
|
| 1365 |
-
prompt_template=prompt_template
|
| 1366 |
)
|
| 1367 |
db.add(prompt)
|
| 1368 |
|
|
@@ -1373,15 +1451,26 @@ async def save_prompts(request: PromptSaveRequest, db: Session = Depends(get_db)
|
|
| 1373 |
|
| 1374 |
|
| 1375 |
@app.get("/api/generation-status")
|
| 1376 |
-
async def generation_status(file_id: str = Query(...),
|
| 1377 |
"""Return progress for sequence generation (so frontend can resume after sleep/reconnect)."""
|
| 1378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1379 |
if not db_file:
|
| 1380 |
raise HTTPException(status_code=404, detail="File not found")
|
| 1381 |
total_contacts = db_file.contact_count or 0
|
| 1382 |
completed = (
|
| 1383 |
db.query(GeneratedSequence.sequence_id)
|
| 1384 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
| 1385 |
.distinct()
|
| 1386 |
.count()
|
| 1387 |
)
|
|
@@ -1394,11 +1483,15 @@ async def generation_status(file_id: str = Query(...), db: Session = Depends(get
|
|
| 1394 |
|
| 1395 |
|
| 1396 |
@app.get("/api/sequences")
|
| 1397 |
-
async def get_sequences(file_id: str = Query(...),
|
| 1398 |
"""Return all generated sequences for a file (for catch-up after reconnect)."""
|
|
|
|
| 1399 |
sequences = (
|
| 1400 |
db.query(GeneratedSequence)
|
| 1401 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
| 1402 |
.order_by(GeneratedSequence.sequence_id, GeneratedSequence.email_number)
|
| 1403 |
.all()
|
| 1404 |
)
|
|
@@ -1423,20 +1516,33 @@ async def get_sequences(file_id: str = Query(...), db: Session = Depends(get_db)
|
|
| 1423 |
async def generate_sequences(
|
| 1424 |
file_id: str = Query(...),
|
| 1425 |
reset: bool = Query(True),
|
| 1426 |
-
|
| 1427 |
):
|
| 1428 |
"""Generate email sequences using GPT with Server-Sent Events streaming.
|
| 1429 |
Use reset=1 for a fresh run (clears existing). Use reset=0 to resume after disconnect/sleep."""
|
| 1430 |
-
|
|
|
|
|
|
|
| 1431 |
async def event_generator():
|
| 1432 |
try:
|
| 1433 |
-
db_file =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1434 |
if not db_file:
|
| 1435 |
yield f"data: {json.dumps({'type': 'error', 'error': 'File not found'})}\n\n"
|
| 1436 |
return
|
| 1437 |
|
| 1438 |
df = pd.read_csv(db_file.file_path)
|
| 1439 |
-
prompts =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1440 |
prompt_dict = {p.product_name: p.prompt_template for p in prompts}
|
| 1441 |
|
| 1442 |
if not prompt_dict:
|
|
@@ -1445,7 +1551,10 @@ async def generate_sequences(
|
|
| 1445 |
|
| 1446 |
products = list(prompt_dict.keys())
|
| 1447 |
if reset:
|
| 1448 |
-
db.query(GeneratedSequence).filter(
|
|
|
|
|
|
|
|
|
|
| 1449 |
db.commit()
|
| 1450 |
|
| 1451 |
total_contacts = len(df)
|
|
@@ -1455,6 +1564,7 @@ async def generate_sequences(
|
|
| 1455 |
existing = (
|
| 1456 |
db.query(GeneratedSequence)
|
| 1457 |
.filter(
|
|
|
|
| 1458 |
GeneratedSequence.file_id == file_id,
|
| 1459 |
GeneratedSequence.sequence_id == sequence_id,
|
| 1460 |
)
|
|
@@ -1498,6 +1608,7 @@ async def generate_sequences(
|
|
| 1498 |
|
| 1499 |
for seq_data in sequence_data_list:
|
| 1500 |
db_sequence = GeneratedSequence(
|
|
|
|
| 1501 |
file_id=file_id,
|
| 1502 |
sequence_id=sequence_id,
|
| 1503 |
email_number=seq_data["email_number"],
|
|
@@ -1543,13 +1654,20 @@ async def generate_sequences(
|
|
| 1543 |
|
| 1544 |
|
| 1545 |
@app.get("/api/download-sequences")
|
| 1546 |
-
async def download_sequences(file_id: str = Query(...),
|
| 1547 |
"""Download generated sequences as CSV with all subject/body fields"""
|
|
|
|
| 1548 |
try:
|
| 1549 |
# Get all sequences for this file, grouped by contact
|
| 1550 |
-
sequences =
|
| 1551 |
-
|
| 1552 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1553 |
|
| 1554 |
if not sequences:
|
| 1555 |
raise HTTPException(status_code=404, detail="No sequences found")
|
|
@@ -1654,15 +1772,22 @@ async def get_smartlead_campaigns():
|
|
| 1654 |
|
| 1655 |
|
| 1656 |
@app.post("/api/push-to-smartlead")
|
| 1657 |
-
async def push_to_smartlead(request: SmartleadPushRequest,
|
| 1658 |
"""Push generated sequences to Smartlead campaign (add leads to existing campaign)"""
|
| 1659 |
import uuid
|
| 1660 |
|
|
|
|
| 1661 |
try:
|
| 1662 |
# Get all sequences for this file
|
| 1663 |
-
sequences =
|
| 1664 |
-
|
| 1665 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1666 |
|
| 1667 |
if not sequences:
|
| 1668 |
raise HTTPException(status_code=404, detail="No sequences found")
|
|
@@ -1702,6 +1827,7 @@ async def push_to_smartlead(request: SmartleadPushRequest, db: Session = Depends
|
|
| 1702 |
# Create run record
|
| 1703 |
run_id = str(uuid.uuid4())
|
| 1704 |
run = SmartleadRun(
|
|
|
|
| 1705 |
run_id=run_id,
|
| 1706 |
file_id=request.file_id,
|
| 1707 |
mode='existing', # Always 'existing' now
|
|
@@ -1843,10 +1969,11 @@ async def push_to_smartlead(request: SmartleadPushRequest, db: Session = Depends
|
|
| 1843 |
|
| 1844 |
|
| 1845 |
@app.get("/api/smartlead-runs")
|
| 1846 |
-
async def get_smartlead_runs(file_id: str = Query(None),
|
| 1847 |
"""Get Smartlead run history"""
|
|
|
|
| 1848 |
try:
|
| 1849 |
-
query = db.query(SmartleadRun)
|
| 1850 |
if file_id:
|
| 1851 |
query = query.filter(SmartleadRun.file_id == file_id)
|
| 1852 |
|
|
@@ -1876,11 +2003,19 @@ async def get_smartlead_runs(file_id: str = Query(None), db: Session = Depends(g
|
|
| 1876 |
|
| 1877 |
|
| 1878 |
@app.post("/api/webhooks/smartlead")
|
| 1879 |
-
async def smartlead_webhook(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1880 |
"""
|
| 1881 |
Smartlead webhook — configure in Smartlead to POST reply events to this URL when a lead replies.
|
|
|
|
| 1882 |
Optional: set SMARTLEAD_WEBHOOK_SECRET and send the same value in header X-Webhook-Token (or Bearer).
|
| 1883 |
"""
|
|
|
|
|
|
|
|
|
|
| 1884 |
secret = os.getenv("SMARTLEAD_WEBHOOK_SECRET")
|
| 1885 |
if secret:
|
| 1886 |
token = request.headers.get("X-Webhook-Token") or ""
|
|
@@ -1900,7 +2035,7 @@ async def smartlead_webhook(request: Request, db: Session = Depends(get_db)):
|
|
| 1900 |
if not parsed:
|
| 1901 |
return {"ok": True, "ignored": True, "reason": "not_a_reply_or_missing_fields"}
|
| 1902 |
|
| 1903 |
-
q = db.query(CrmLead)
|
| 1904 |
row = None
|
| 1905 |
if parsed["smartlead_lead_id"] and parsed["campaign_id"]:
|
| 1906 |
row = q.filter(
|
|
@@ -1916,7 +2051,10 @@ async def smartlead_webhook(request: Request, db: Session = Depends(get_db)):
|
|
| 1916 |
if parsed["email"]:
|
| 1917 |
apollo = (
|
| 1918 |
db.query(Contact)
|
| 1919 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
| 1920 |
.first()
|
| 1921 |
)
|
| 1922 |
if apollo:
|
|
@@ -1947,6 +2085,7 @@ async def smartlead_webhook(request: Request, db: Session = Depends(get_db)):
|
|
| 1947 |
row.smartlead_lead_id = parsed["smartlead_lead_id"]
|
| 1948 |
else:
|
| 1949 |
row = CrmLead(
|
|
|
|
| 1950 |
smartlead_lead_id=parsed["smartlead_lead_id"] or "",
|
| 1951 |
campaign_id=parsed["campaign_id"] or "",
|
| 1952 |
campaign_name=parsed["campaign_name"] or "",
|
|
@@ -1969,13 +2108,19 @@ async def smartlead_webhook(request: Request, db: Session = Depends(get_db)):
|
|
| 1969 |
|
| 1970 |
|
| 1971 |
@app.post("/api/leads/seed-demo")
|
| 1972 |
-
async def seed_demo_leads(
|
| 1973 |
"""
|
| 1974 |
Insert sample leads so the Leads UI can be previewed without a real Smartlead webhook.
|
| 1975 |
Deletes any previous demo rows (emails like demo.lead.*@emailout.local) then inserts fresh ones.
|
| 1976 |
"""
|
|
|
|
|
|
|
| 1977 |
demo_email_filter = CrmLead.email.like("demo.lead.%@emailout.local")
|
| 1978 |
-
removed =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1979 |
|
| 1980 |
campaign_id = "88001"
|
| 1981 |
campaign_name = "Logistics & Supply Chain Outreach"
|
|
@@ -2092,6 +2237,7 @@ async def seed_demo_leads(db: Session = Depends(get_db)):
|
|
| 2092 |
}
|
| 2093 |
db.add(
|
| 2094 |
CrmLead(
|
|
|
|
| 2095 |
smartlead_lead_id=s["sl_id"],
|
| 2096 |
campaign_id=campaign_id,
|
| 2097 |
campaign_name=campaign_name,
|
|
@@ -2125,9 +2271,10 @@ async def list_leads(
|
|
| 2125 |
sort_dir: str = Query("desc"),
|
| 2126 |
limit: int = Query(50, ge=1, le=200),
|
| 2127 |
offset: int = Query(0, ge=0),
|
| 2128 |
-
|
| 2129 |
):
|
| 2130 |
-
|
|
|
|
| 2131 |
if search.strip():
|
| 2132 |
term = f"%{search.strip().lower()}%"
|
| 2133 |
q = q.filter(
|
|
@@ -2163,13 +2310,25 @@ async def list_leads(
|
|
| 2163 |
|
| 2164 |
|
| 2165 |
@app.get("/api/leads/{lead_id}")
|
| 2166 |
-
async def get_lead(lead_id: int,
|
| 2167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2168 |
if not row:
|
| 2169 |
raise HTTPException(status_code=404, detail="Lead not found")
|
| 2170 |
d = _crm_lead_to_dict(row)
|
| 2171 |
if row.contact_id:
|
| 2172 |
-
c =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2173 |
if c:
|
| 2174 |
d["contact"] = {
|
| 2175 |
"id": c.id,
|
|
@@ -2182,8 +2341,13 @@ async def get_lead(lead_id: int, db: Session = Depends(get_db)):
|
|
| 2182 |
|
| 2183 |
|
| 2184 |
@app.patch("/api/leads/{lead_id}")
|
| 2185 |
-
async def patch_lead(lead_id: int, body: CrmLeadPatchRequest,
|
| 2186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2187 |
if not row:
|
| 2188 |
raise HTTPException(status_code=404, detail="Lead not found")
|
| 2189 |
data = body.model_dump(exclude_unset=True)
|
|
@@ -2214,6 +2378,7 @@ async def patch_lead(lead_id: int, body: CrmLeadPatchRequest, db: Session = Depe
|
|
| 2214 |
taken = (
|
| 2215 |
db.query(CrmLead)
|
| 2216 |
.filter(
|
|
|
|
| 2217 |
CrmLead.id != lead_id,
|
| 2218 |
func.lower(CrmLead.email) == email,
|
| 2219 |
)
|
|
@@ -2240,8 +2405,13 @@ async def patch_lead(lead_id: int, body: CrmLeadPatchRequest, db: Session = Depe
|
|
| 2240 |
|
| 2241 |
|
| 2242 |
@app.post("/api/leads/{lead_id}/move-to-contacts")
|
| 2243 |
-
async def move_lead_to_contacts(lead_id: int,
|
| 2244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2245 |
if not lead:
|
| 2246 |
raise HTTPException(status_code=404, detail="Lead not found")
|
| 2247 |
r = _move_lead_to_contacts_core(db, lead)
|
|
@@ -2252,13 +2422,18 @@ async def move_lead_to_contacts(lead_id: int, db: Session = Depends(get_db)):
|
|
| 2252 |
|
| 2253 |
|
| 2254 |
@app.post("/api/leads/bulk-move-to-contacts")
|
| 2255 |
-
async def bulk_move_leads_to_contacts(body: BulkLeadIdsRequest,
|
|
|
|
| 2256 |
if not body.lead_ids:
|
| 2257 |
raise HTTPException(status_code=400, detail="lead_ids required")
|
| 2258 |
moved = 0
|
| 2259 |
errors: List[dict] = []
|
| 2260 |
for lid in body.lead_ids:
|
| 2261 |
-
lead =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2262 |
if not lead:
|
| 2263 |
errors.append({"lead_id": lid, "error": "not found"})
|
| 2264 |
continue
|
|
@@ -2272,12 +2447,13 @@ async def bulk_move_leads_to_contacts(body: BulkLeadIdsRequest, db: Session = De
|
|
| 2272 |
|
| 2273 |
|
| 2274 |
@app.post("/api/leads/bulk-delete")
|
| 2275 |
-
async def bulk_delete_leads(body: BulkLeadIdsRequest,
|
|
|
|
| 2276 |
if not body.lead_ids:
|
| 2277 |
raise HTTPException(status_code=400, detail="lead_ids required")
|
| 2278 |
deleted = (
|
| 2279 |
db.query(CrmLead)
|
| 2280 |
-
.filter(CrmLead.id.in_(body.lead_ids))
|
| 2281 |
.delete(synchronize_session=False)
|
| 2282 |
)
|
| 2283 |
db.commit()
|
|
@@ -2285,19 +2461,25 @@ async def bulk_delete_leads(body: BulkLeadIdsRequest, db: Session = Depends(get_
|
|
| 2285 |
|
| 2286 |
|
| 2287 |
@app.post("/api/deals/from-leads")
|
| 2288 |
-
async def deals_from_leads(body: BulkLeadIdsRequest,
|
| 2289 |
"""Create one deal per selected lead and remove those leads from the Leads table."""
|
|
|
|
| 2290 |
if not body.lead_ids:
|
| 2291 |
raise HTTPException(status_code=400, detail="lead_ids required")
|
| 2292 |
created: List[dict] = []
|
| 2293 |
errors: List[dict] = []
|
| 2294 |
for lid in body.lead_ids:
|
| 2295 |
-
lead =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2296 |
if not lead:
|
| 2297 |
errors.append({"lead_id": lid, "error": "not found"})
|
| 2298 |
continue
|
| 2299 |
person = " ".join(filter(None, [lead.first_name or "", lead.last_name or ""])).strip()
|
| 2300 |
deal = CrmDeal(
|
|
|
|
| 2301 |
name=_deal_name_from_lead(lead),
|
| 2302 |
stage="new",
|
| 2303 |
owner_initials=_owner_initials_from_lead(lead),
|
|
@@ -2327,9 +2509,10 @@ async def list_deals(
|
|
| 2327 |
sort_dir: str = Query("desc"),
|
| 2328 |
limit: int = Query(100, ge=1, le=500),
|
| 2329 |
offset: int = Query(0, ge=0),
|
| 2330 |
-
|
| 2331 |
):
|
| 2332 |
-
|
|
|
|
| 2333 |
if search.strip():
|
| 2334 |
term = f"%{search.strip().lower()}%"
|
| 2335 |
q = q.filter(
|
|
@@ -2357,16 +2540,18 @@ async def list_deals(
|
|
| 2357 |
|
| 2358 |
|
| 2359 |
@app.post("/api/deals")
|
| 2360 |
-
async def create_deal(body: CrmDealCreateRequest,
|
| 2361 |
stage = (body.stage or "new").strip().lower() or "new"
|
| 2362 |
if stage not in DEAL_STAGE_ALLOWED:
|
| 2363 |
raise HTTPException(
|
| 2364 |
status_code=400,
|
| 2365 |
detail=f"stage must be one of: {sorted(DEAL_STAGE_ALLOWED)}",
|
| 2366 |
)
|
|
|
|
| 2367 |
raw_name = _safe_str(body.name) if body.name is not None else ""
|
| 2368 |
name = raw_name or "Untitled deal"
|
| 2369 |
row = CrmDeal(
|
|
|
|
| 2370 |
name=name,
|
| 2371 |
stage=stage,
|
| 2372 |
owner_initials="",
|
|
@@ -2388,16 +2573,26 @@ async def create_deal(body: CrmDealCreateRequest, db: Session = Depends(get_db))
|
|
| 2388 |
|
| 2389 |
|
| 2390 |
@app.get("/api/deals/{deal_id}")
|
| 2391 |
-
async def get_deal(deal_id: int,
|
| 2392 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2393 |
if not row:
|
| 2394 |
raise HTTPException(status_code=404, detail="Deal not found")
|
| 2395 |
return _enrich_deal_response(db, row)
|
| 2396 |
|
| 2397 |
|
| 2398 |
@app.patch("/api/deals/{deal_id}")
|
| 2399 |
-
async def patch_deal(deal_id: int, body: CrmDealPatchRequest,
|
| 2400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2401 |
if not row:
|
| 2402 |
raise HTTPException(status_code=404, detail="Deal not found")
|
| 2403 |
data = body.model_dump(exclude_unset=True)
|
|
@@ -2408,7 +2603,14 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, db: Session = Depe
|
|
| 2408 |
if cid is None:
|
| 2409 |
row.contact_id = None
|
| 2410 |
else:
|
| 2411 |
-
contact =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2412 |
if not contact:
|
| 2413 |
raise HTTPException(status_code=404, detail="Contact not found")
|
| 2414 |
row.contact_id = contact.id
|
|
@@ -2445,7 +2647,14 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, db: Session = Depe
|
|
| 2445 |
|
| 2446 |
c = None
|
| 2447 |
if row.contact_id:
|
| 2448 |
-
c =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2449 |
if "account_name" in data and c:
|
| 2450 |
c.company = row.account_name
|
| 2451 |
if c:
|
|
@@ -2466,8 +2675,14 @@ async def patch_deal(deal_id: int, body: CrmDealPatchRequest, db: Session = Depe
|
|
| 2466 |
|
| 2467 |
|
| 2468 |
@app.post("/api/deals/seed-demo")
|
| 2469 |
-
async def seed_demo_deals(
|
| 2470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2471 |
now = datetime.utcnow()
|
| 2472 |
samples = [
|
| 2473 |
{
|
|
@@ -2518,6 +2733,7 @@ async def seed_demo_deals(db: Session = Depends(get_db)):
|
|
| 2518 |
for s in samples:
|
| 2519 |
db.add(
|
| 2520 |
CrmDeal(
|
|
|
|
| 2521 |
name=s["name"],
|
| 2522 |
stage=s["stage"],
|
| 2523 |
owner_initials=s["owner_initials"],
|
|
@@ -2537,9 +2753,14 @@ async def seed_demo_deals(db: Session = Depends(get_db)):
|
|
| 2537 |
|
| 2538 |
|
| 2539 |
@app.get("/api/leads/{lead_id}/smartlead-thread")
|
| 2540 |
-
async def lead_smartlead_thread(lead_id: int,
|
| 2541 |
"""Fetch full thread from Smartlead API (Admin API key required)."""
|
| 2542 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2543 |
if not row:
|
| 2544 |
raise HTTPException(status_code=404, detail="Lead not found")
|
| 2545 |
if not row.smartlead_lead_id or not row.campaign_id:
|
|
|
|
| 22 |
from .database import (
|
| 23 |
get_db,
|
| 24 |
SessionLocal,
|
| 25 |
+
Tenant,
|
| 26 |
UploadedFile,
|
| 27 |
Prompt,
|
| 28 |
GeneratedSequence,
|
|
|
|
| 48 |
from .gpt_service import generate_email_sequence, enrich_manual_contact_profile
|
| 49 |
from .smartlead_client import SmartleadClient
|
| 50 |
from .auth_routes import router as auth_router
|
| 51 |
+
from .tenant_deps import TenantContext, get_tenant_context
|
| 52 |
+
from .tenant_routes import router as tenant_router
|
| 53 |
|
| 54 |
app = FastAPI()
|
| 55 |
|
|
|
|
| 81 |
)
|
| 82 |
|
| 83 |
app.include_router(auth_router)
|
| 84 |
+
app.include_router(tenant_router)
|
| 85 |
|
| 86 |
# Create uploads directory
|
| 87 |
UPLOAD_DIR = Path("/data/uploads")
|
|
|
|
| 382 |
return {"ok": False, "error": "Lead has no email"}
|
| 383 |
existing = (
|
| 384 |
db.query(Contact)
|
| 385 |
+
.filter(
|
| 386 |
+
Contact.tenant_id == lead.tenant_id,
|
| 387 |
+
func.lower(Contact.email) == email.lower(),
|
| 388 |
+
)
|
| 389 |
.first()
|
| 390 |
)
|
| 391 |
if existing:
|
|
|
|
| 402 |
"smartlead_webhook": lead.raw_webhook,
|
| 403 |
}
|
| 404 |
contact = Contact(
|
| 405 |
+
tenant_id=lead.tenant_id,
|
| 406 |
file_id=SMARTLEAD_IMPORT_FILE_ID,
|
| 407 |
row_index=lead.id,
|
| 408 |
first_name=lead.first_name or "",
|
|
|
|
| 435 |
return {"ok": False, "error": "Contact has no email"}
|
| 436 |
dup = (
|
| 437 |
db.query(CrmLead)
|
| 438 |
+
.filter(
|
| 439 |
+
CrmLead.tenant_id == contact.tenant_id,
|
| 440 |
+
func.lower(CrmLead.email) == email.lower(),
|
| 441 |
+
)
|
| 442 |
.first()
|
| 443 |
)
|
| 444 |
if dup:
|
|
|
|
| 466 |
"company_details": company_snapshot,
|
| 467 |
}
|
| 468 |
lead = CrmLead(
|
| 469 |
+
tenant_id=contact.tenant_id,
|
| 470 |
smartlead_lead_id=f"from-contact-{contact.id}",
|
| 471 |
campaign_id=CONTACTS_TO_LEADS_CAMPAIGN_ID,
|
| 472 |
campaign_name=CONTACTS_TO_LEADS_CAMPAIGN_NAME,
|
|
|
|
| 545 |
|
| 546 |
|
| 547 |
@app.post("/api/upload-csv")
|
| 548 |
+
async def upload_csv(file: UploadFile = File(...), t: TenantContext = Depends(get_tenant_context)):
|
| 549 |
"""Upload and parse CSV file from Apollo"""
|
| 550 |
+
db = t.db
|
| 551 |
+
tenant_id = t.tenant_id
|
| 552 |
try:
|
| 553 |
# Generate unique file ID
|
| 554 |
file_id = str(uuid.uuid4())
|
|
|
|
| 565 |
|
| 566 |
# Save upload metadata
|
| 567 |
db_file = UploadedFile(
|
| 568 |
+
tenant_id=tenant_id,
|
| 569 |
file_id=file_id,
|
| 570 |
filename=file.filename,
|
| 571 |
contact_count=contact_count,
|
|
|
|
| 578 |
row_dict = row.to_dict()
|
| 579 |
sanitized_raw_data = {str(k): _json_safe(v) for k, v in row_dict.items()}
|
| 580 |
contact = Contact(
|
| 581 |
+
tenant_id=tenant_id,
|
| 582 |
file_id=file_id,
|
| 583 |
row_index=idx + 1,
|
| 584 |
first_name=_pick_field(row_dict, ["first name", "firstname", "first_name"]),
|
|
|
|
| 603 |
|
| 604 |
|
| 605 |
@app.get("/api/contact-fields")
|
| 606 |
+
async def contact_fields(t: TenantContext = Depends(get_tenant_context)):
|
| 607 |
"""Return all available contact field names from uploaded Apollo rows."""
|
| 608 |
+
db = t.db
|
| 609 |
+
contacts = db.query(Contact.raw_data).filter(Contact.tenant_id == t.tenant_id).all()
|
| 610 |
fields = set(["first_name", "last_name", "email", "company", "title", "file_id", "created_at"])
|
| 611 |
for item in contacts:
|
| 612 |
raw = item[0] or {}
|
|
|
|
| 632 |
sort_dir: str = Query("desc", description="Sort direction: asc|desc"),
|
| 633 |
limit: int = Query(200, ge=1, le=1000),
|
| 634 |
offset: int = Query(0, ge=0),
|
| 635 |
+
t: TenantContext = Depends(get_tenant_context),
|
| 636 |
):
|
| 637 |
+
db = t.db
|
| 638 |
+
query = db.query(Contact).filter(Contact.tenant_id == t.tenant_id)
|
| 639 |
if search:
|
| 640 |
pattern = f"%{search}%"
|
| 641 |
query = query.filter(
|
|
|
|
| 711 |
|
| 712 |
|
| 713 |
@app.post("/api/contacts")
|
| 714 |
+
async def create_contact(body: ContactCreateRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 715 |
"""Create a contact manually (inline table add). Email must be unique."""
|
| 716 |
+
db = t.db
|
| 717 |
email = _safe_str(body.email).lower()
|
| 718 |
if not email:
|
| 719 |
raise HTTPException(status_code=400, detail="Email is required")
|
| 720 |
+
exists = (
|
| 721 |
+
db.query(Contact)
|
| 722 |
+
.filter(
|
| 723 |
+
Contact.tenant_id == t.tenant_id,
|
| 724 |
+
func.lower(Contact.email) == email,
|
| 725 |
+
)
|
| 726 |
+
.first()
|
| 727 |
+
)
|
| 728 |
if exists:
|
| 729 |
raise HTTPException(status_code=409, detail="A contact with this email already exists")
|
| 730 |
fn = _safe_str(body.first_name)
|
|
|
|
| 739 |
"Title": ti,
|
| 740 |
}
|
| 741 |
contact = Contact(
|
| 742 |
+
tenant_id=t.tenant_id,
|
| 743 |
file_id=MANUAL_CONTACT_FILE_ID,
|
| 744 |
row_index=0,
|
| 745 |
first_name=fn,
|
|
|
|
| 768 |
|
| 769 |
|
| 770 |
@app.post("/api/contacts/bulk-delete")
|
| 771 |
+
async def bulk_delete_contacts(body: BulkContactIdsRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 772 |
+
db = t.db
|
| 773 |
if not body.contact_ids:
|
| 774 |
raise HTTPException(status_code=400, detail="contact_ids required")
|
| 775 |
ids = list({int(x) for x in body.contact_ids})
|
| 776 |
deleted = (
|
| 777 |
db.query(Contact)
|
| 778 |
+
.filter(Contact.tenant_id == t.tenant_id, Contact.id.in_(ids))
|
| 779 |
.delete(synchronize_session=False)
|
| 780 |
)
|
| 781 |
db.commit()
|
|
|
|
| 783 |
|
| 784 |
|
| 785 |
@app.post("/api/contacts/seed-demo")
|
| 786 |
+
async def seed_demo_contacts(t: TenantContext = Depends(get_tenant_context)):
|
| 787 |
"""
|
| 788 |
Replace previous demo-seeded contacts and insert a variety of sample rows
|
| 789 |
(rich Apollo-style raw_data) for testing filters and UI.
|
| 790 |
"""
|
| 791 |
+
db = t.db
|
| 792 |
+
tenant_id = t.tenant_id
|
| 793 |
removed = (
|
| 794 |
db.query(Contact)
|
| 795 |
+
.filter(
|
| 796 |
+
Contact.tenant_id == tenant_id,
|
| 797 |
+
Contact.file_id == DEMO_CONTACTS_FILE_ID,
|
| 798 |
+
)
|
| 799 |
.delete(synchronize_session=False)
|
| 800 |
)
|
| 801 |
specs = [
|
|
|
|
| 979 |
rd["First Name"] = s["fn"]
|
| 980 |
rd["Last Name"] = s["ln"]
|
| 981 |
row = Contact(
|
| 982 |
+
tenant_id=tenant_id,
|
| 983 |
file_id=DEMO_CONTACTS_FILE_ID,
|
| 984 |
row_index=i,
|
| 985 |
first_name=s["fn"],
|
|
|
|
| 1000 |
|
| 1001 |
|
| 1002 |
@app.post("/api/contacts/bulk-convert-to-leads")
|
| 1003 |
+
async def bulk_convert_contacts_to_leads(body: BulkContactIdsRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 1004 |
"""
|
| 1005 |
For each contact: create a CrmLead (campaign «Contacts») copied from the contact.
|
| 1006 |
Contacts are not removed; they remain until explicitly deleted. Fails per-row if the
|
| 1007 |
contact has no email or a lead with the same email already exists.
|
| 1008 |
"""
|
| 1009 |
+
db = t.db
|
| 1010 |
if not body.contact_ids:
|
| 1011 |
raise HTTPException(status_code=400, detail="contact_ids required")
|
| 1012 |
converted = 0
|
| 1013 |
errors: List[dict] = []
|
| 1014 |
for cid in body.contact_ids:
|
| 1015 |
+
contact = (
|
| 1016 |
+
db.query(Contact)
|
| 1017 |
+
.filter(Contact.tenant_id == t.tenant_id, Contact.id == int(cid))
|
| 1018 |
+
.first()
|
| 1019 |
+
)
|
| 1020 |
if not contact:
|
| 1021 |
errors.append({"contact_id": cid, "error": "not found"})
|
| 1022 |
continue
|
|
|
|
| 1033 |
async def search_company_names(
|
| 1034 |
q: str = Query("", description="Substring match on Contact.company"),
|
| 1035 |
limit: int = Query(25, ge=1, le=100),
|
| 1036 |
+
t: TenantContext = Depends(get_tenant_context),
|
| 1037 |
):
|
| 1038 |
"""Distinct company strings from contacts for deal/account linking."""
|
| 1039 |
+
db = t.db
|
| 1040 |
raw_q = _safe_str(q)
|
| 1041 |
pattern = f"%{raw_q}%" if raw_q else "%"
|
| 1042 |
rows = (
|
| 1043 |
db.query(Contact.company)
|
| 1044 |
.filter(
|
| 1045 |
+
Contact.tenant_id == t.tenant_id,
|
| 1046 |
Contact.company.isnot(None),
|
| 1047 |
Contact.company != "",
|
| 1048 |
Contact.company.ilike(pattern),
|
|
|
|
| 1069 |
|
| 1070 |
|
| 1071 |
@app.get("/api/contacts/{contact_id}")
|
| 1072 |
+
async def get_contact(contact_id: int, t: TenantContext = Depends(get_tenant_context)):
|
| 1073 |
+
db = t.db
|
| 1074 |
+
contact = (
|
| 1075 |
+
db.query(Contact)
|
| 1076 |
+
.filter(Contact.tenant_id == t.tenant_id, Contact.id == contact_id)
|
| 1077 |
+
.first()
|
| 1078 |
+
)
|
| 1079 |
if not contact:
|
| 1080 |
raise HTTPException(status_code=404, detail="Contact not found")
|
| 1081 |
return {
|
|
|
|
| 1174 |
out = _deal_to_dict(row)
|
| 1175 |
out["linked_contact"] = None
|
| 1176 |
if row.contact_id:
|
| 1177 |
+
c = (
|
| 1178 |
+
db.query(Contact)
|
| 1179 |
+
.filter(
|
| 1180 |
+
Contact.tenant_id == row.tenant_id,
|
| 1181 |
+
Contact.id == row.contact_id,
|
| 1182 |
+
)
|
| 1183 |
+
.first()
|
| 1184 |
+
)
|
| 1185 |
if c:
|
| 1186 |
out["company_details"] = _contact_company_details_dict(c)
|
| 1187 |
out["company_details_contact_id"] = c.id
|
|
|
|
| 1210 |
|
| 1211 |
|
| 1212 |
@app.patch("/api/contacts/{contact_id}")
|
| 1213 |
+
async def patch_contact(contact_id: int, body: ContactPatchRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 1214 |
+
db = t.db
|
| 1215 |
+
contact = (
|
| 1216 |
+
db.query(Contact)
|
| 1217 |
+
.filter(Contact.tenant_id == t.tenant_id, Contact.id == contact_id)
|
| 1218 |
+
.first()
|
| 1219 |
+
)
|
| 1220 |
if not contact:
|
| 1221 |
raise HTTPException(status_code=404, detail="Contact not found")
|
| 1222 |
data = body.model_dump(exclude_unset=True)
|
|
|
|
| 1228 |
raise HTTPException(status_code=400, detail="Email cannot be empty")
|
| 1229 |
taken = (
|
| 1230 |
db.query(Contact)
|
| 1231 |
+
.filter(
|
| 1232 |
+
Contact.tenant_id == t.tenant_id,
|
| 1233 |
+
Contact.id != contact_id,
|
| 1234 |
+
func.lower(Contact.email) == email,
|
| 1235 |
+
)
|
| 1236 |
.first()
|
| 1237 |
)
|
| 1238 |
if taken:
|
|
|
|
| 1266 |
|
| 1267 |
|
| 1268 |
@app.post("/api/contacts/{contact_id}/enrich")
|
| 1269 |
+
async def enrich_contact(contact_id: int, t: TenantContext = Depends(get_tenant_context)):
|
| 1270 |
"""
|
| 1271 |
GPT-enrich company/contact fields for manually added contacts only.
|
| 1272 |
|
|
|
|
| 1275 |
Google Search grounding (GEMINI_API_KEY); DuckDuckGo fallback;
|
| 1276 |
ENRICHMENT_CONTACT_EMAIL helps Wikipedia.
|
| 1277 |
"""
|
| 1278 |
+
db = t.db
|
| 1279 |
if not os.getenv("OPENAI_API_KEY"):
|
| 1280 |
raise HTTPException(status_code=503, detail="OpenAI API key is not configured")
|
| 1281 |
+
contact = (
|
| 1282 |
+
db.query(Contact)
|
| 1283 |
+
.filter(Contact.tenant_id == t.tenant_id, Contact.id == contact_id)
|
| 1284 |
+
.first()
|
| 1285 |
+
)
|
| 1286 |
if not contact:
|
| 1287 |
raise HTTPException(status_code=404, detail="Contact not found")
|
| 1288 |
src = (contact.source or "").strip().lower()
|
|
|
|
| 1381 |
|
| 1382 |
|
| 1383 |
@app.get("/api/contacts/{contact_id}/sequences")
|
| 1384 |
+
async def get_contact_sequences(contact_id: int, t: TenantContext = Depends(get_tenant_context)):
|
| 1385 |
+
db = t.db
|
| 1386 |
+
contact = (
|
| 1387 |
+
db.query(Contact)
|
| 1388 |
+
.filter(Contact.tenant_id == t.tenant_id, Contact.id == contact_id)
|
| 1389 |
+
.first()
|
| 1390 |
+
)
|
| 1391 |
if not contact:
|
| 1392 |
raise HTTPException(status_code=404, detail="Contact not found")
|
| 1393 |
|
|
|
|
| 1397 |
sequences = (
|
| 1398 |
db.query(GeneratedSequence)
|
| 1399 |
.filter(
|
| 1400 |
+
GeneratedSequence.tenant_id == t.tenant_id,
|
| 1401 |
GeneratedSequence.file_id == contact.file_id,
|
| 1402 |
+
GeneratedSequence.email == contact.email,
|
| 1403 |
)
|
| 1404 |
.order_by(GeneratedSequence.sequence_id, GeneratedSequence.email_number)
|
| 1405 |
.all()
|
|
|
|
| 1424 |
|
| 1425 |
|
| 1426 |
@app.post("/api/save-prompts")
|
| 1427 |
+
async def save_prompts(request: PromptSaveRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 1428 |
"""Save prompt templates for products"""
|
| 1429 |
+
db = t.db
|
| 1430 |
try:
|
| 1431 |
# Delete existing prompts for this file
|
| 1432 |
+
db.query(Prompt).filter(
|
| 1433 |
+
Prompt.tenant_id == t.tenant_id,
|
| 1434 |
+
Prompt.file_id == request.file_id,
|
| 1435 |
+
).delete()
|
| 1436 |
+
|
| 1437 |
# Save new prompts
|
| 1438 |
for product_name, prompt_template in request.prompts.items():
|
| 1439 |
prompt = Prompt(
|
| 1440 |
+
tenant_id=t.tenant_id,
|
| 1441 |
file_id=request.file_id,
|
| 1442 |
product_name=product_name,
|
| 1443 |
+
prompt_template=prompt_template,
|
| 1444 |
)
|
| 1445 |
db.add(prompt)
|
| 1446 |
|
|
|
|
| 1451 |
|
| 1452 |
|
| 1453 |
@app.get("/api/generation-status")
|
| 1454 |
+
async def generation_status(file_id: str = Query(...), t: TenantContext = Depends(get_tenant_context)):
|
| 1455 |
"""Return progress for sequence generation (so frontend can resume after sleep/reconnect)."""
|
| 1456 |
+
db = t.db
|
| 1457 |
+
db_file = (
|
| 1458 |
+
db.query(UploadedFile)
|
| 1459 |
+
.filter(
|
| 1460 |
+
UploadedFile.tenant_id == t.tenant_id,
|
| 1461 |
+
UploadedFile.file_id == file_id,
|
| 1462 |
+
)
|
| 1463 |
+
.first()
|
| 1464 |
+
)
|
| 1465 |
if not db_file:
|
| 1466 |
raise HTTPException(status_code=404, detail="File not found")
|
| 1467 |
total_contacts = db_file.contact_count or 0
|
| 1468 |
completed = (
|
| 1469 |
db.query(GeneratedSequence.sequence_id)
|
| 1470 |
+
.filter(
|
| 1471 |
+
GeneratedSequence.tenant_id == t.tenant_id,
|
| 1472 |
+
GeneratedSequence.file_id == file_id,
|
| 1473 |
+
)
|
| 1474 |
.distinct()
|
| 1475 |
.count()
|
| 1476 |
)
|
|
|
|
| 1483 |
|
| 1484 |
|
| 1485 |
@app.get("/api/sequences")
|
| 1486 |
+
async def get_sequences(file_id: str = Query(...), t: TenantContext = Depends(get_tenant_context)):
|
| 1487 |
"""Return all generated sequences for a file (for catch-up after reconnect)."""
|
| 1488 |
+
db = t.db
|
| 1489 |
sequences = (
|
| 1490 |
db.query(GeneratedSequence)
|
| 1491 |
+
.filter(
|
| 1492 |
+
GeneratedSequence.tenant_id == t.tenant_id,
|
| 1493 |
+
GeneratedSequence.file_id == file_id,
|
| 1494 |
+
)
|
| 1495 |
.order_by(GeneratedSequence.sequence_id, GeneratedSequence.email_number)
|
| 1496 |
.all()
|
| 1497 |
)
|
|
|
|
| 1516 |
async def generate_sequences(
|
| 1517 |
file_id: str = Query(...),
|
| 1518 |
reset: bool = Query(True),
|
| 1519 |
+
t: TenantContext = Depends(get_tenant_context),
|
| 1520 |
):
|
| 1521 |
"""Generate email sequences using GPT with Server-Sent Events streaming.
|
| 1522 |
Use reset=1 for a fresh run (clears existing). Use reset=0 to resume after disconnect/sleep."""
|
| 1523 |
+
db = t.db
|
| 1524 |
+
tenant_id = t.tenant_id
|
| 1525 |
+
|
| 1526 |
async def event_generator():
|
| 1527 |
try:
|
| 1528 |
+
db_file = (
|
| 1529 |
+
db.query(UploadedFile)
|
| 1530 |
+
.filter(
|
| 1531 |
+
UploadedFile.tenant_id == tenant_id,
|
| 1532 |
+
UploadedFile.file_id == file_id,
|
| 1533 |
+
)
|
| 1534 |
+
.first()
|
| 1535 |
+
)
|
| 1536 |
if not db_file:
|
| 1537 |
yield f"data: {json.dumps({'type': 'error', 'error': 'File not found'})}\n\n"
|
| 1538 |
return
|
| 1539 |
|
| 1540 |
df = pd.read_csv(db_file.file_path)
|
| 1541 |
+
prompts = (
|
| 1542 |
+
db.query(Prompt)
|
| 1543 |
+
.filter(Prompt.tenant_id == tenant_id, Prompt.file_id == file_id)
|
| 1544 |
+
.all()
|
| 1545 |
+
)
|
| 1546 |
prompt_dict = {p.product_name: p.prompt_template for p in prompts}
|
| 1547 |
|
| 1548 |
if not prompt_dict:
|
|
|
|
| 1551 |
|
| 1552 |
products = list(prompt_dict.keys())
|
| 1553 |
if reset:
|
| 1554 |
+
db.query(GeneratedSequence).filter(
|
| 1555 |
+
GeneratedSequence.tenant_id == tenant_id,
|
| 1556 |
+
GeneratedSequence.file_id == file_id,
|
| 1557 |
+
).delete()
|
| 1558 |
db.commit()
|
| 1559 |
|
| 1560 |
total_contacts = len(df)
|
|
|
|
| 1564 |
existing = (
|
| 1565 |
db.query(GeneratedSequence)
|
| 1566 |
.filter(
|
| 1567 |
+
GeneratedSequence.tenant_id == tenant_id,
|
| 1568 |
GeneratedSequence.file_id == file_id,
|
| 1569 |
GeneratedSequence.sequence_id == sequence_id,
|
| 1570 |
)
|
|
|
|
| 1608 |
|
| 1609 |
for seq_data in sequence_data_list:
|
| 1610 |
db_sequence = GeneratedSequence(
|
| 1611 |
+
tenant_id=tenant_id,
|
| 1612 |
file_id=file_id,
|
| 1613 |
sequence_id=sequence_id,
|
| 1614 |
email_number=seq_data["email_number"],
|
|
|
|
| 1654 |
|
| 1655 |
|
| 1656 |
@app.get("/api/download-sequences")
|
| 1657 |
+
async def download_sequences(file_id: str = Query(...), t: TenantContext = Depends(get_tenant_context)):
|
| 1658 |
"""Download generated sequences as CSV with all subject/body fields"""
|
| 1659 |
+
db = t.db
|
| 1660 |
try:
|
| 1661 |
# Get all sequences for this file, grouped by contact
|
| 1662 |
+
sequences = (
|
| 1663 |
+
db.query(GeneratedSequence)
|
| 1664 |
+
.filter(
|
| 1665 |
+
GeneratedSequence.tenant_id == t.tenant_id,
|
| 1666 |
+
GeneratedSequence.file_id == file_id,
|
| 1667 |
+
)
|
| 1668 |
+
.order_by(GeneratedSequence.sequence_id, GeneratedSequence.email_number)
|
| 1669 |
+
.all()
|
| 1670 |
+
)
|
| 1671 |
|
| 1672 |
if not sequences:
|
| 1673 |
raise HTTPException(status_code=404, detail="No sequences found")
|
|
|
|
| 1772 |
|
| 1773 |
|
| 1774 |
@app.post("/api/push-to-smartlead")
|
| 1775 |
+
async def push_to_smartlead(request: SmartleadPushRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 1776 |
"""Push generated sequences to Smartlead campaign (add leads to existing campaign)"""
|
| 1777 |
import uuid
|
| 1778 |
|
| 1779 |
+
db = t.db
|
| 1780 |
try:
|
| 1781 |
# Get all sequences for this file
|
| 1782 |
+
sequences = (
|
| 1783 |
+
db.query(GeneratedSequence)
|
| 1784 |
+
.filter(
|
| 1785 |
+
GeneratedSequence.tenant_id == t.tenant_id,
|
| 1786 |
+
GeneratedSequence.file_id == request.file_id,
|
| 1787 |
+
)
|
| 1788 |
+
.order_by(GeneratedSequence.sequence_id, GeneratedSequence.email_number)
|
| 1789 |
+
.all()
|
| 1790 |
+
)
|
| 1791 |
|
| 1792 |
if not sequences:
|
| 1793 |
raise HTTPException(status_code=404, detail="No sequences found")
|
|
|
|
| 1827 |
# Create run record
|
| 1828 |
run_id = str(uuid.uuid4())
|
| 1829 |
run = SmartleadRun(
|
| 1830 |
+
tenant_id=t.tenant_id,
|
| 1831 |
run_id=run_id,
|
| 1832 |
file_id=request.file_id,
|
| 1833 |
mode='existing', # Always 'existing' now
|
|
|
|
| 1969 |
|
| 1970 |
|
| 1971 |
@app.get("/api/smartlead-runs")
|
| 1972 |
+
async def get_smartlead_runs(file_id: str = Query(None), t: TenantContext = Depends(get_tenant_context)):
|
| 1973 |
"""Get Smartlead run history"""
|
| 1974 |
+
db = t.db
|
| 1975 |
try:
|
| 1976 |
+
query = db.query(SmartleadRun).filter(SmartleadRun.tenant_id == t.tenant_id)
|
| 1977 |
if file_id:
|
| 1978 |
query = query.filter(SmartleadRun.file_id == file_id)
|
| 1979 |
|
|
|
|
| 2003 |
|
| 2004 |
|
| 2005 |
@app.post("/api/webhooks/smartlead")
|
| 2006 |
+
async def smartlead_webhook(
|
| 2007 |
+
request: Request,
|
| 2008 |
+
tenant_id: int = Query(..., description="Workspace ID — add ?tenant_id=<id> to the webhook URL in Smartlead"),
|
| 2009 |
+
db: Session = Depends(get_db),
|
| 2010 |
+
):
|
| 2011 |
"""
|
| 2012 |
Smartlead webhook — configure in Smartlead to POST reply events to this URL when a lead replies.
|
| 2013 |
+
Append **?tenant_id=<your workspace id>** to the webhook URL (same id as in the app URL bar after signing in).
|
| 2014 |
Optional: set SMARTLEAD_WEBHOOK_SECRET and send the same value in header X-Webhook-Token (or Bearer).
|
| 2015 |
"""
|
| 2016 |
+
if not db.query(Tenant).filter(Tenant.id == tenant_id).first():
|
| 2017 |
+
raise HTTPException(status_code=404, detail="Unknown workspace")
|
| 2018 |
+
|
| 2019 |
secret = os.getenv("SMARTLEAD_WEBHOOK_SECRET")
|
| 2020 |
if secret:
|
| 2021 |
token = request.headers.get("X-Webhook-Token") or ""
|
|
|
|
| 2035 |
if not parsed:
|
| 2036 |
return {"ok": True, "ignored": True, "reason": "not_a_reply_or_missing_fields"}
|
| 2037 |
|
| 2038 |
+
q = db.query(CrmLead).filter(CrmLead.tenant_id == tenant_id)
|
| 2039 |
row = None
|
| 2040 |
if parsed["smartlead_lead_id"] and parsed["campaign_id"]:
|
| 2041 |
row = q.filter(
|
|
|
|
| 2051 |
if parsed["email"]:
|
| 2052 |
apollo = (
|
| 2053 |
db.query(Contact)
|
| 2054 |
+
.filter(
|
| 2055 |
+
Contact.tenant_id == tenant_id,
|
| 2056 |
+
func.lower(Contact.email) == parsed["email"].lower(),
|
| 2057 |
+
)
|
| 2058 |
.first()
|
| 2059 |
)
|
| 2060 |
if apollo:
|
|
|
|
| 2085 |
row.smartlead_lead_id = parsed["smartlead_lead_id"]
|
| 2086 |
else:
|
| 2087 |
row = CrmLead(
|
| 2088 |
+
tenant_id=tenant_id,
|
| 2089 |
smartlead_lead_id=parsed["smartlead_lead_id"] or "",
|
| 2090 |
campaign_id=parsed["campaign_id"] or "",
|
| 2091 |
campaign_name=parsed["campaign_name"] or "",
|
|
|
|
| 2108 |
|
| 2109 |
|
| 2110 |
@app.post("/api/leads/seed-demo")
|
| 2111 |
+
async def seed_demo_leads(t: TenantContext = Depends(get_tenant_context)):
|
| 2112 |
"""
|
| 2113 |
Insert sample leads so the Leads UI can be previewed without a real Smartlead webhook.
|
| 2114 |
Deletes any previous demo rows (emails like demo.lead.*@emailout.local) then inserts fresh ones.
|
| 2115 |
"""
|
| 2116 |
+
db = t.db
|
| 2117 |
+
tid = t.tenant_id
|
| 2118 |
demo_email_filter = CrmLead.email.like("demo.lead.%@emailout.local")
|
| 2119 |
+
removed = (
|
| 2120 |
+
db.query(CrmLead)
|
| 2121 |
+
.filter(CrmLead.tenant_id == tid, demo_email_filter)
|
| 2122 |
+
.delete(synchronize_session=False)
|
| 2123 |
+
)
|
| 2124 |
|
| 2125 |
campaign_id = "88001"
|
| 2126 |
campaign_name = "Logistics & Supply Chain Outreach"
|
|
|
|
| 2237 |
}
|
| 2238 |
db.add(
|
| 2239 |
CrmLead(
|
| 2240 |
+
tenant_id=tid,
|
| 2241 |
smartlead_lead_id=s["sl_id"],
|
| 2242 |
campaign_id=campaign_id,
|
| 2243 |
campaign_name=campaign_name,
|
|
|
|
| 2271 |
sort_dir: str = Query("desc"),
|
| 2272 |
limit: int = Query(50, ge=1, le=200),
|
| 2273 |
offset: int = Query(0, ge=0),
|
| 2274 |
+
t: TenantContext = Depends(get_tenant_context),
|
| 2275 |
):
|
| 2276 |
+
db = t.db
|
| 2277 |
+
q = db.query(CrmLead).filter(CrmLead.tenant_id == t.tenant_id)
|
| 2278 |
if search.strip():
|
| 2279 |
term = f"%{search.strip().lower()}%"
|
| 2280 |
q = q.filter(
|
|
|
|
| 2310 |
|
| 2311 |
|
| 2312 |
@app.get("/api/leads/{lead_id}")
|
| 2313 |
+
async def get_lead(lead_id: int, t: TenantContext = Depends(get_tenant_context)):
|
| 2314 |
+
db = t.db
|
| 2315 |
+
row = (
|
| 2316 |
+
db.query(CrmLead)
|
| 2317 |
+
.filter(CrmLead.tenant_id == t.tenant_id, CrmLead.id == lead_id)
|
| 2318 |
+
.first()
|
| 2319 |
+
)
|
| 2320 |
if not row:
|
| 2321 |
raise HTTPException(status_code=404, detail="Lead not found")
|
| 2322 |
d = _crm_lead_to_dict(row)
|
| 2323 |
if row.contact_id:
|
| 2324 |
+
c = (
|
| 2325 |
+
db.query(Contact)
|
| 2326 |
+
.filter(
|
| 2327 |
+
Contact.tenant_id == t.tenant_id,
|
| 2328 |
+
Contact.id == row.contact_id,
|
| 2329 |
+
)
|
| 2330 |
+
.first()
|
| 2331 |
+
)
|
| 2332 |
if c:
|
| 2333 |
d["contact"] = {
|
| 2334 |
"id": c.id,
|
|
|
|
| 2341 |
|
| 2342 |
|
| 2343 |
@app.patch("/api/leads/{lead_id}")
|
| 2344 |
+
async def patch_lead(lead_id: int, body: CrmLeadPatchRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 2345 |
+
db = t.db
|
| 2346 |
+
row = (
|
| 2347 |
+
db.query(CrmLead)
|
| 2348 |
+
.filter(CrmLead.tenant_id == t.tenant_id, CrmLead.id == lead_id)
|
| 2349 |
+
.first()
|
| 2350 |
+
)
|
| 2351 |
if not row:
|
| 2352 |
raise HTTPException(status_code=404, detail="Lead not found")
|
| 2353 |
data = body.model_dump(exclude_unset=True)
|
|
|
|
| 2378 |
taken = (
|
| 2379 |
db.query(CrmLead)
|
| 2380 |
.filter(
|
| 2381 |
+
CrmLead.tenant_id == row.tenant_id,
|
| 2382 |
CrmLead.id != lead_id,
|
| 2383 |
func.lower(CrmLead.email) == email,
|
| 2384 |
)
|
|
|
|
| 2405 |
|
| 2406 |
|
| 2407 |
@app.post("/api/leads/{lead_id}/move-to-contacts")
|
| 2408 |
+
async def move_lead_to_contacts(lead_id: int, t: TenantContext = Depends(get_tenant_context)):
|
| 2409 |
+
db = t.db
|
| 2410 |
+
lead = (
|
| 2411 |
+
db.query(CrmLead)
|
| 2412 |
+
.filter(CrmLead.tenant_id == t.tenant_id, CrmLead.id == lead_id)
|
| 2413 |
+
.first()
|
| 2414 |
+
)
|
| 2415 |
if not lead:
|
| 2416 |
raise HTTPException(status_code=404, detail="Lead not found")
|
| 2417 |
r = _move_lead_to_contacts_core(db, lead)
|
|
|
|
| 2422 |
|
| 2423 |
|
| 2424 |
@app.post("/api/leads/bulk-move-to-contacts")
|
| 2425 |
+
async def bulk_move_leads_to_contacts(body: BulkLeadIdsRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 2426 |
+
db = t.db
|
| 2427 |
if not body.lead_ids:
|
| 2428 |
raise HTTPException(status_code=400, detail="lead_ids required")
|
| 2429 |
moved = 0
|
| 2430 |
errors: List[dict] = []
|
| 2431 |
for lid in body.lead_ids:
|
| 2432 |
+
lead = (
|
| 2433 |
+
db.query(CrmLead)
|
| 2434 |
+
.filter(CrmLead.tenant_id == t.tenant_id, CrmLead.id == lid)
|
| 2435 |
+
.first()
|
| 2436 |
+
)
|
| 2437 |
if not lead:
|
| 2438 |
errors.append({"lead_id": lid, "error": "not found"})
|
| 2439 |
continue
|
|
|
|
| 2447 |
|
| 2448 |
|
| 2449 |
@app.post("/api/leads/bulk-delete")
|
| 2450 |
+
async def bulk_delete_leads(body: BulkLeadIdsRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 2451 |
+
db = t.db
|
| 2452 |
if not body.lead_ids:
|
| 2453 |
raise HTTPException(status_code=400, detail="lead_ids required")
|
| 2454 |
deleted = (
|
| 2455 |
db.query(CrmLead)
|
| 2456 |
+
.filter(CrmLead.tenant_id == t.tenant_id, CrmLead.id.in_(body.lead_ids))
|
| 2457 |
.delete(synchronize_session=False)
|
| 2458 |
)
|
| 2459 |
db.commit()
|
|
|
|
| 2461 |
|
| 2462 |
|
| 2463 |
@app.post("/api/deals/from-leads")
|
| 2464 |
+
async def deals_from_leads(body: BulkLeadIdsRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 2465 |
"""Create one deal per selected lead and remove those leads from the Leads table."""
|
| 2466 |
+
db = t.db
|
| 2467 |
if not body.lead_ids:
|
| 2468 |
raise HTTPException(status_code=400, detail="lead_ids required")
|
| 2469 |
created: List[dict] = []
|
| 2470 |
errors: List[dict] = []
|
| 2471 |
for lid in body.lead_ids:
|
| 2472 |
+
lead = (
|
| 2473 |
+
db.query(CrmLead)
|
| 2474 |
+
.filter(CrmLead.tenant_id == t.tenant_id, CrmLead.id == lid)
|
| 2475 |
+
.first()
|
| 2476 |
+
)
|
| 2477 |
if not lead:
|
| 2478 |
errors.append({"lead_id": lid, "error": "not found"})
|
| 2479 |
continue
|
| 2480 |
person = " ".join(filter(None, [lead.first_name or "", lead.last_name or ""])).strip()
|
| 2481 |
deal = CrmDeal(
|
| 2482 |
+
tenant_id=lead.tenant_id,
|
| 2483 |
name=_deal_name_from_lead(lead),
|
| 2484 |
stage="new",
|
| 2485 |
owner_initials=_owner_initials_from_lead(lead),
|
|
|
|
| 2509 |
sort_dir: str = Query("desc"),
|
| 2510 |
limit: int = Query(100, ge=1, le=500),
|
| 2511 |
offset: int = Query(0, ge=0),
|
| 2512 |
+
t: TenantContext = Depends(get_tenant_context),
|
| 2513 |
):
|
| 2514 |
+
db = t.db
|
| 2515 |
+
q = db.query(CrmDeal).filter(CrmDeal.tenant_id == t.tenant_id)
|
| 2516 |
if search.strip():
|
| 2517 |
term = f"%{search.strip().lower()}%"
|
| 2518 |
q = q.filter(
|
|
|
|
| 2540 |
|
| 2541 |
|
| 2542 |
@app.post("/api/deals")
|
| 2543 |
+
async def create_deal(body: CrmDealCreateRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 2544 |
stage = (body.stage or "new").strip().lower() or "new"
|
| 2545 |
if stage not in DEAL_STAGE_ALLOWED:
|
| 2546 |
raise HTTPException(
|
| 2547 |
status_code=400,
|
| 2548 |
detail=f"stage must be one of: {sorted(DEAL_STAGE_ALLOWED)}",
|
| 2549 |
)
|
| 2550 |
+
db = t.db
|
| 2551 |
raw_name = _safe_str(body.name) if body.name is not None else ""
|
| 2552 |
name = raw_name or "Untitled deal"
|
| 2553 |
row = CrmDeal(
|
| 2554 |
+
tenant_id=t.tenant_id,
|
| 2555 |
name=name,
|
| 2556 |
stage=stage,
|
| 2557 |
owner_initials="",
|
|
|
|
| 2573 |
|
| 2574 |
|
| 2575 |
@app.get("/api/deals/{deal_id}")
|
| 2576 |
+
async def get_deal(deal_id: int, t: TenantContext = Depends(get_tenant_context)):
|
| 2577 |
+
db = t.db
|
| 2578 |
+
row = (
|
| 2579 |
+
db.query(CrmDeal)
|
| 2580 |
+
.filter(CrmDeal.tenant_id == t.tenant_id, CrmDeal.id == deal_id)
|
| 2581 |
+
.first()
|
| 2582 |
+
)
|
| 2583 |
if not row:
|
| 2584 |
raise HTTPException(status_code=404, detail="Deal not found")
|
| 2585 |
return _enrich_deal_response(db, row)
|
| 2586 |
|
| 2587 |
|
| 2588 |
@app.patch("/api/deals/{deal_id}")
|
| 2589 |
+
async def patch_deal(deal_id: int, body: CrmDealPatchRequest, t: TenantContext = Depends(get_tenant_context)):
|
| 2590 |
+
db = t.db
|
| 2591 |
+
row = (
|
| 2592 |
+
db.query(CrmDeal)
|
| 2593 |
+
.filter(CrmDeal.tenant_id == t.tenant_id, CrmDeal.id == deal_id)
|
| 2594 |
+
.first()
|
| 2595 |
+
)
|
| 2596 |
if not row:
|
| 2597 |
raise HTTPException(status_code=404, detail="Deal not found")
|
| 2598 |
data = body.model_dump(exclude_unset=True)
|
|
|
|
| 2603 |
if cid is None:
|
| 2604 |
row.contact_id = None
|
| 2605 |
else:
|
| 2606 |
+
contact = (
|
| 2607 |
+
db.query(Contact)
|
| 2608 |
+
.filter(
|
| 2609 |
+
Contact.tenant_id == t.tenant_id,
|
| 2610 |
+
Contact.id == int(cid),
|
| 2611 |
+
)
|
| 2612 |
+
.first()
|
| 2613 |
+
)
|
| 2614 |
if not contact:
|
| 2615 |
raise HTTPException(status_code=404, detail="Contact not found")
|
| 2616 |
row.contact_id = contact.id
|
|
|
|
| 2647 |
|
| 2648 |
c = None
|
| 2649 |
if row.contact_id:
|
| 2650 |
+
c = (
|
| 2651 |
+
db.query(Contact)
|
| 2652 |
+
.filter(
|
| 2653 |
+
Contact.tenant_id == t.tenant_id,
|
| 2654 |
+
Contact.id == row.contact_id,
|
| 2655 |
+
)
|
| 2656 |
+
.first()
|
| 2657 |
+
)
|
| 2658 |
if "account_name" in data and c:
|
| 2659 |
c.company = row.account_name
|
| 2660 |
if c:
|
|
|
|
| 2675 |
|
| 2676 |
|
| 2677 |
@app.post("/api/deals/seed-demo")
|
| 2678 |
+
async def seed_demo_deals(t: TenantContext = Depends(get_tenant_context)):
|
| 2679 |
+
db = t.db
|
| 2680 |
+
tid = t.tenant_id
|
| 2681 |
+
removed = (
|
| 2682 |
+
db.query(CrmDeal)
|
| 2683 |
+
.filter(CrmDeal.tenant_id == tid, CrmDeal.name.like("DEMO: %"))
|
| 2684 |
+
.delete(synchronize_session=False)
|
| 2685 |
+
)
|
| 2686 |
now = datetime.utcnow()
|
| 2687 |
samples = [
|
| 2688 |
{
|
|
|
|
| 2733 |
for s in samples:
|
| 2734 |
db.add(
|
| 2735 |
CrmDeal(
|
| 2736 |
+
tenant_id=tid,
|
| 2737 |
name=s["name"],
|
| 2738 |
stage=s["stage"],
|
| 2739 |
owner_initials=s["owner_initials"],
|
|
|
|
| 2753 |
|
| 2754 |
|
| 2755 |
@app.get("/api/leads/{lead_id}/smartlead-thread")
|
| 2756 |
+
async def lead_smartlead_thread(lead_id: int, t: TenantContext = Depends(get_tenant_context)):
|
| 2757 |
"""Fetch full thread from Smartlead API (Admin API key required)."""
|
| 2758 |
+
db = t.db
|
| 2759 |
+
row = (
|
| 2760 |
+
db.query(CrmLead)
|
| 2761 |
+
.filter(CrmLead.tenant_id == t.tenant_id, CrmLead.id == lead_id)
|
| 2762 |
+
.first()
|
| 2763 |
+
)
|
| 2764 |
if not row:
|
| 2765 |
raise HTTPException(status_code=404, detail="Lead not found")
|
| 2766 |
if not row.smartlead_lead_id or not row.campaign_id:
|
backend/app/tenant_deps.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Require signed-in user + active workspace membership for tenant-scoped APIs."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
from fastapi import Depends, HTTPException, Request
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
from .database import TenantMembership, get_db
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass
|
| 14 |
+
class TenantContext:
|
| 15 |
+
tenant_id: int
|
| 16 |
+
user_id: int
|
| 17 |
+
role: str
|
| 18 |
+
db: Session
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def get_tenant_context(request: Request, db: Session = Depends(get_db)) -> TenantContext:
|
| 22 |
+
uid = request.session.get("user_id")
|
| 23 |
+
if uid is None:
|
| 24 |
+
raise HTTPException(status_code=401, detail="Sign in required")
|
| 25 |
+
tid = request.session.get("current_tenant_id")
|
| 26 |
+
if tid is None:
|
| 27 |
+
raise HTTPException(status_code=400, detail="No workspace selected")
|
| 28 |
+
m = (
|
| 29 |
+
db.query(TenantMembership)
|
| 30 |
+
.filter(
|
| 31 |
+
TenantMembership.user_id == int(uid),
|
| 32 |
+
TenantMembership.tenant_id == int(tid),
|
| 33 |
+
)
|
| 34 |
+
.first()
|
| 35 |
+
)
|
| 36 |
+
if not m:
|
| 37 |
+
raise HTTPException(status_code=403, detail="You are not a member of this workspace")
|
| 38 |
+
return TenantContext(tenant_id=int(tid), user_id=int(uid), role=m.role, db=db)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def require_tenant_admin(tc: TenantContext = Depends(get_tenant_context)) -> TenantContext:
|
| 42 |
+
if tc.role != "admin":
|
| 43 |
+
raise HTTPException(status_code=403, detail="Admin role required")
|
| 44 |
+
return tc
|
backend/app/tenant_routes.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Workspace list, switch active workspace, admin invitations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import hashlib
|
| 6 |
+
import os
|
| 7 |
+
import secrets
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 11 |
+
from pydantic import BaseModel, Field
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
+
|
| 14 |
+
from sqlalchemy import func
|
| 15 |
+
|
| 16 |
+
from .database import Invitation, Tenant, TenantMembership, User, get_db
|
| 17 |
+
from .tenant_deps import TenantContext, require_tenant_admin
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/api/tenants", tags=["tenants"])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class InviteBody(BaseModel):
|
| 24 |
+
email: str = Field(..., min_length=3, max_length=320)
|
| 25 |
+
role: str = Field(default="member", pattern="^(admin|member)$")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _frontend_origin() -> str:
|
| 29 |
+
fe = os.environ.get("FRONTEND_ORIGIN", "").strip()
|
| 30 |
+
if fe:
|
| 31 |
+
return fe.rstrip("/")
|
| 32 |
+
return ""
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _invite_link(raw_token: str) -> str:
|
| 36 |
+
base = _frontend_origin()
|
| 37 |
+
if base:
|
| 38 |
+
return f"{base}/?invite={raw_token}"
|
| 39 |
+
return f"/?invite={raw_token}"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@router.get("")
|
| 43 |
+
def list_my_workspaces(request: Request, db: Session = Depends(get_db)):
|
| 44 |
+
uid = request.session.get("user_id")
|
| 45 |
+
if uid is None:
|
| 46 |
+
raise HTTPException(status_code=401, detail="Sign in required")
|
| 47 |
+
rows = (
|
| 48 |
+
db.query(TenantMembership, Tenant)
|
| 49 |
+
.join(Tenant, Tenant.id == TenantMembership.tenant_id)
|
| 50 |
+
.filter(TenantMembership.user_id == int(uid))
|
| 51 |
+
.order_by(Tenant.name)
|
| 52 |
+
.all()
|
| 53 |
+
)
|
| 54 |
+
current = request.session.get("current_tenant_id")
|
| 55 |
+
out = []
|
| 56 |
+
for m, t in rows:
|
| 57 |
+
out.append(
|
| 58 |
+
{
|
| 59 |
+
"id": t.id,
|
| 60 |
+
"name": t.name,
|
| 61 |
+
"role": m.role,
|
| 62 |
+
"current": int(current) == int(t.id) if current is not None else False,
|
| 63 |
+
}
|
| 64 |
+
)
|
| 65 |
+
return {"tenants": out, "current_tenant_id": current}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@router.post("/invite")
|
| 69 |
+
def create_invitation(body: InviteBody, tc: TenantContext = Depends(require_tenant_admin)):
|
| 70 |
+
db = tc.db
|
| 71 |
+
email_n = body.email.strip().lower()
|
| 72 |
+
if not email_n or "@" not in email_n:
|
| 73 |
+
raise HTTPException(status_code=400, detail="Invalid email")
|
| 74 |
+
|
| 75 |
+
existing_user = db.query(User).filter(func.lower(User.email) == email_n).first()
|
| 76 |
+
if existing_user:
|
| 77 |
+
already = (
|
| 78 |
+
db.query(TenantMembership)
|
| 79 |
+
.filter(
|
| 80 |
+
TenantMembership.user_id == existing_user.id,
|
| 81 |
+
TenantMembership.tenant_id == tc.tenant_id,
|
| 82 |
+
)
|
| 83 |
+
.first()
|
| 84 |
+
)
|
| 85 |
+
if already:
|
| 86 |
+
raise HTTPException(status_code=400, detail="User is already in this workspace")
|
| 87 |
+
|
| 88 |
+
raw = secrets.token_urlsafe(32)
|
| 89 |
+
th = hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
| 90 |
+
exp = datetime.utcnow() + timedelta(days=7)
|
| 91 |
+
inv = Invitation(
|
| 92 |
+
tenant_id=tc.tenant_id,
|
| 93 |
+
email=email_n,
|
| 94 |
+
token_hash=th,
|
| 95 |
+
role=body.role if body.role in ("admin", "member") else "member",
|
| 96 |
+
invited_by_user_id=tc.user_id,
|
| 97 |
+
expires_at=exp,
|
| 98 |
+
)
|
| 99 |
+
db.add(inv)
|
| 100 |
+
db.commit()
|
| 101 |
+
return {
|
| 102 |
+
"ok": True,
|
| 103 |
+
"invite_url": _invite_link(raw),
|
| 104 |
+
"expires_at": exp.isoformat() + "Z",
|
| 105 |
+
"email": email_n,
|
| 106 |
+
}
|
frontend/src/components/layout/AppShell.jsx
CHANGED
|
@@ -82,7 +82,7 @@ export default function AppShell({ title, subtitle, rightContent, children }) {
|
|
| 82 |
<h2 className="text-xl font-bold text-slate-800">{title}</h2>
|
| 83 |
{subtitle && <p className="text-sm text-slate-500">{subtitle}</p>}
|
| 84 |
</div>
|
| 85 |
-
<div className="flex shrink-0 items-center gap-2">
|
| 86 |
<GoogleAuthBar />
|
| 87 |
{rightContent}
|
| 88 |
</div>
|
|
|
|
| 82 |
<h2 className="text-xl font-bold text-slate-800">{title}</h2>
|
| 83 |
{subtitle && <p className="text-sm text-slate-500">{subtitle}</p>}
|
| 84 |
</div>
|
| 85 |
+
<div className="relative flex shrink-0 items-center gap-2">
|
| 86 |
<GoogleAuthBar />
|
| 87 |
{rightContent}
|
| 88 |
</div>
|
frontend/src/components/layout/GoogleAuthBar.jsx
CHANGED
|
@@ -1,25 +1,36 @@
|
|
| 1 |
import React, { useCallback, useEffect, useState } from 'react';
|
| 2 |
import { useSearchParams } from 'react-router-dom';
|
| 3 |
-
import { LogOut } from 'lucide-react';
|
| 4 |
import { Button } from '@/components/ui/button';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import { cn } from '@/lib/utils';
|
|
|
|
| 6 |
|
| 7 |
/**
|
| 8 |
-
*
|
| 9 |
*/
|
| 10 |
export default function GoogleAuthBar() {
|
| 11 |
const [searchParams, setSearchParams] = useSearchParams();
|
| 12 |
-
const [phase, setPhase] = useState('loading');
|
| 13 |
const [googleOn, setGoogleOn] = useState(false);
|
| 14 |
const [user, setUser] = useState(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
const refresh = useCallback(async () => {
|
| 17 |
try {
|
| 18 |
const [st, me] = await Promise.all([
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
r.ok ? r.json() : null
|
| 22 |
-
),
|
| 23 |
]);
|
| 24 |
setGoogleOn(!!st.googleConfigured);
|
| 25 |
setUser(me);
|
|
@@ -38,6 +49,10 @@ export default function GoogleAuthBar() {
|
|
| 38 |
if (!err) return;
|
| 39 |
if (err === 'access_denied') {
|
| 40 |
console.info('Google sign-in was cancelled.');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
} else {
|
| 42 |
console.warn('Google sign-in error:', err);
|
| 43 |
}
|
|
@@ -48,13 +63,61 @@ export default function GoogleAuthBar() {
|
|
| 48 |
|
| 49 |
const logout = async () => {
|
| 50 |
try {
|
| 51 |
-
await
|
| 52 |
setUser(null);
|
| 53 |
} catch (e) {
|
| 54 |
console.error(e);
|
| 55 |
}
|
| 56 |
};
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
if (phase === 'loading' || (phase === 'ready' && !googleOn)) {
|
| 59 |
if (phase === 'loading') {
|
| 60 |
return (
|
|
@@ -72,8 +135,84 @@ export default function GoogleAuthBar() {
|
|
| 72 |
}
|
| 73 |
|
| 74 |
if (user) {
|
|
|
|
|
|
|
|
|
|
| 75 |
return (
|
| 76 |
-
<div className="flex max-w-[min(100vw-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
{user.picture ? (
|
| 78 |
<img
|
| 79 |
src={user.picture}
|
|
@@ -82,7 +221,7 @@ export default function GoogleAuthBar() {
|
|
| 82 |
referrerPolicy="no-referrer"
|
| 83 |
/>
|
| 84 |
) : null}
|
| 85 |
-
<span className="hidden min-w-0 truncate text-sm text-slate-600
|
| 86 |
{user.name || user.email || 'Signed in'}
|
| 87 |
</span>
|
| 88 |
<Button
|
|
@@ -110,7 +249,7 @@ export default function GoogleAuthBar() {
|
|
| 110 |
'hover:bg-slate-50'
|
| 111 |
)}
|
| 112 |
>
|
| 113 |
-
<a href=
|
| 114 |
<GoogleMark className="h-4 w-4 shrink-0" />
|
| 115 |
<span className="whitespace-nowrap">Sign in with Google</span>
|
| 116 |
</a>
|
|
|
|
| 1 |
import React, { useCallback, useEffect, useState } from 'react';
|
| 2 |
import { useSearchParams } from 'react-router-dom';
|
| 3 |
+
import { LogOut, Building2, UserPlus } from 'lucide-react';
|
| 4 |
import { Button } from '@/components/ui/button';
|
| 5 |
+
import {
|
| 6 |
+
Select,
|
| 7 |
+
SelectContent,
|
| 8 |
+
SelectItem,
|
| 9 |
+
SelectTrigger,
|
| 10 |
+
SelectValue,
|
| 11 |
+
} from '@/components/ui/select';
|
| 12 |
+
import { Input } from '@/components/ui/input';
|
| 13 |
import { cn } from '@/lib/utils';
|
| 14 |
+
import { apiFetch } from '@/lib/api';
|
| 15 |
|
| 16 |
/**
|
| 17 |
+
* Sign-in (Google), workspace switcher, admin invitations; session cookie from OAuth callback.
|
| 18 |
*/
|
| 19 |
export default function GoogleAuthBar() {
|
| 20 |
const [searchParams, setSearchParams] = useSearchParams();
|
| 21 |
+
const [phase, setPhase] = useState('loading');
|
| 22 |
const [googleOn, setGoogleOn] = useState(false);
|
| 23 |
const [user, setUser] = useState(null);
|
| 24 |
+
const [inviteOpen, setInviteOpen] = useState(false);
|
| 25 |
+
const [inviteEmail, setInviteEmail] = useState('');
|
| 26 |
+
const [inviteBusy, setInviteBusy] = useState(false);
|
| 27 |
+
const [inviteResult, setInviteResult] = useState(null);
|
| 28 |
|
| 29 |
const refresh = useCallback(async () => {
|
| 30 |
try {
|
| 31 |
const [st, me] = await Promise.all([
|
| 32 |
+
apiFetch('/api/auth/status').then((r) => r.json()),
|
| 33 |
+
apiFetch('/api/auth/me').then((r) => (r.ok ? r.json() : null)),
|
|
|
|
|
|
|
| 34 |
]);
|
| 35 |
setGoogleOn(!!st.googleConfigured);
|
| 36 |
setUser(me);
|
|
|
|
| 49 |
if (!err) return;
|
| 50 |
if (err === 'access_denied') {
|
| 51 |
console.info('Google sign-in was cancelled.');
|
| 52 |
+
} else if (err === 'invite_email_mismatch') {
|
| 53 |
+
console.warn(
|
| 54 |
+
'Invitation email does not match your Google account. Sign in with the invited address.'
|
| 55 |
+
);
|
| 56 |
} else {
|
| 57 |
console.warn('Google sign-in error:', err);
|
| 58 |
}
|
|
|
|
| 63 |
|
| 64 |
const logout = async () => {
|
| 65 |
try {
|
| 66 |
+
await apiFetch('/api/auth/logout', { method: 'POST' });
|
| 67 |
setUser(null);
|
| 68 |
} catch (e) {
|
| 69 |
console.error(e);
|
| 70 |
}
|
| 71 |
};
|
| 72 |
|
| 73 |
+
const switchTenant = async (tid) => {
|
| 74 |
+
const id = parseInt(tid, 10);
|
| 75 |
+
if (!id || id === user?.current_tenant_id) return;
|
| 76 |
+
try {
|
| 77 |
+
const res = await apiFetch('/api/auth/switch-tenant', {
|
| 78 |
+
method: 'POST',
|
| 79 |
+
headers: { 'Content-Type': 'application/json' },
|
| 80 |
+
body: JSON.stringify({ tenant_id: id }),
|
| 81 |
+
});
|
| 82 |
+
if (!res.ok) return;
|
| 83 |
+
window.location.reload();
|
| 84 |
+
} catch (e) {
|
| 85 |
+
console.error(e);
|
| 86 |
+
}
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
const sendInvite = async () => {
|
| 90 |
+
const email = inviteEmail.trim().toLowerCase();
|
| 91 |
+
if (!email || !email.includes('@')) return;
|
| 92 |
+
setInviteBusy(true);
|
| 93 |
+
setInviteResult(null);
|
| 94 |
+
try {
|
| 95 |
+
const res = await apiFetch('/api/tenants/invite', {
|
| 96 |
+
method: 'POST',
|
| 97 |
+
headers: { 'Content-Type': 'application/json' },
|
| 98 |
+
body: JSON.stringify({ email, role: 'member' }),
|
| 99 |
+
});
|
| 100 |
+
const data = await res.json().catch(() => ({}));
|
| 101 |
+
if (!res.ok) {
|
| 102 |
+
setInviteResult({
|
| 103 |
+
error: typeof data.detail === 'string' ? data.detail : 'Invite failed',
|
| 104 |
+
});
|
| 105 |
+
return;
|
| 106 |
+
}
|
| 107 |
+
setInviteResult({ url: data.invite_url });
|
| 108 |
+
setInviteEmail('');
|
| 109 |
+
} catch (e) {
|
| 110 |
+
setInviteResult({ error: String(e) });
|
| 111 |
+
} finally {
|
| 112 |
+
setInviteBusy(false);
|
| 113 |
+
}
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
const inviteParam = searchParams.get('invite');
|
| 117 |
+
const googleHref = inviteParam
|
| 118 |
+
? `/api/auth/google?invite=${encodeURIComponent(inviteParam)}`
|
| 119 |
+
: '/api/auth/google';
|
| 120 |
+
|
| 121 |
if (phase === 'loading' || (phase === 'ready' && !googleOn)) {
|
| 122 |
if (phase === 'loading') {
|
| 123 |
return (
|
|
|
|
| 135 |
}
|
| 136 |
|
| 137 |
if (user) {
|
| 138 |
+
const isAdmin = user.current_role === 'admin';
|
| 139 |
+
const tenants = user.tenants || [];
|
| 140 |
+
|
| 141 |
return (
|
| 142 |
+
<div className="flex max-w-[min(100vw-6rem,28rem)] flex-wrap items-center justify-end gap-2">
|
| 143 |
+
{tenants.length > 0 ? (
|
| 144 |
+
<div className="flex items-center gap-1.5 min-w-0">
|
| 145 |
+
<Building2 className="h-4 w-4 shrink-0 text-slate-400 hidden sm:block" />
|
| 146 |
+
<Select
|
| 147 |
+
value={String(user.current_tenant_id ?? '')}
|
| 148 |
+
onValueChange={switchTenant}
|
| 149 |
+
>
|
| 150 |
+
<SelectTrigger className="h-9 max-w-[11rem] sm:max-w-[14rem] border-slate-200 text-xs sm:text-sm">
|
| 151 |
+
<SelectValue placeholder="Workspace" />
|
| 152 |
+
</SelectTrigger>
|
| 153 |
+
<SelectContent>
|
| 154 |
+
{tenants.map((tn) => (
|
| 155 |
+
<SelectItem key={tn.id} value={String(tn.id)}>
|
| 156 |
+
{tn.name}
|
| 157 |
+
{tn.role === 'admin' ? ' · admin' : ''}
|
| 158 |
+
</SelectItem>
|
| 159 |
+
))}
|
| 160 |
+
</SelectContent>
|
| 161 |
+
</Select>
|
| 162 |
+
</div>
|
| 163 |
+
) : null}
|
| 164 |
+
{isAdmin ? (
|
| 165 |
+
<>
|
| 166 |
+
<Button
|
| 167 |
+
type="button"
|
| 168 |
+
variant="outline"
|
| 169 |
+
size="sm"
|
| 170 |
+
className="h-9 gap-1 shrink-0"
|
| 171 |
+
onClick={() => {
|
| 172 |
+
setInviteOpen((o) => !o);
|
| 173 |
+
setInviteResult(null);
|
| 174 |
+
}}
|
| 175 |
+
>
|
| 176 |
+
<UserPlus className="h-3.5 w-3.5" />
|
| 177 |
+
<span className="hidden sm:inline">Invite</span>
|
| 178 |
+
</Button>
|
| 179 |
+
{inviteOpen ? (
|
| 180 |
+
<div className="absolute right-4 top-full z-50 mt-1 w-[min(100vw-2rem,22rem)] rounded-lg border border-slate-200 bg-white p-3 shadow-lg">
|
| 181 |
+
<p className="text-xs text-slate-600 mb-2">
|
| 182 |
+
Invite a Google user by email. They must sign in with that Google
|
| 183 |
+
account.
|
| 184 |
+
</p>
|
| 185 |
+
<div className="flex gap-2">
|
| 186 |
+
<Input
|
| 187 |
+
type="email"
|
| 188 |
+
placeholder="colleague@company.com"
|
| 189 |
+
value={inviteEmail}
|
| 190 |
+
onChange={(e) => setInviteEmail(e.target.value)}
|
| 191 |
+
className="h-9 text-sm"
|
| 192 |
+
/>
|
| 193 |
+
<Button
|
| 194 |
+
type="button"
|
| 195 |
+
size="sm"
|
| 196 |
+
className="shrink-0"
|
| 197 |
+
disabled={inviteBusy}
|
| 198 |
+
onClick={sendInvite}
|
| 199 |
+
>
|
| 200 |
+
{inviteBusy ? '…' : 'Send'}
|
| 201 |
+
</Button>
|
| 202 |
+
</div>
|
| 203 |
+
{inviteResult?.error ? (
|
| 204 |
+
<p className="text-xs text-red-600 mt-2">{inviteResult.error}</p>
|
| 205 |
+
) : null}
|
| 206 |
+
{inviteResult?.url ? (
|
| 207 |
+
<div className="mt-2 space-y-1">
|
| 208 |
+
<p className="text-xs text-slate-600">Invite link (7 days):</p>
|
| 209 |
+
<Input readOnly value={inviteResult.url} className="text-xs h-8" />
|
| 210 |
+
</div>
|
| 211 |
+
) : null}
|
| 212 |
+
</div>
|
| 213 |
+
) : null}
|
| 214 |
+
</>
|
| 215 |
+
) : null}
|
| 216 |
{user.picture ? (
|
| 217 |
<img
|
| 218 |
src={user.picture}
|
|
|
|
| 221 |
referrerPolicy="no-referrer"
|
| 222 |
/>
|
| 223 |
) : null}
|
| 224 |
+
<span className="hidden min-w-0 truncate text-sm text-slate-600 lg:inline">
|
| 225 |
{user.name || user.email || 'Signed in'}
|
| 226 |
</span>
|
| 227 |
<Button
|
|
|
|
| 249 |
'hover:bg-slate-50'
|
| 250 |
)}
|
| 251 |
>
|
| 252 |
+
<a href={googleHref} className="inline-flex items-center gap-2">
|
| 253 |
<GoogleMark className="h-4 w-4 shrink-0" />
|
| 254 |
<span className="whitespace-nowrap">Sign in with Google</span>
|
| 255 |
</a>
|
frontend/src/components/sequences/SequenceViewer.jsx
CHANGED
|
@@ -6,6 +6,7 @@ import { Progress } from "@/components/ui/progress";
|
|
| 6 |
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 7 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 8 |
import SequenceCard from './SequenceCard';
|
|
|
|
| 9 |
|
| 10 |
function applySequenceToContacts(prev, sequence, contactCount, setProgress) {
|
| 11 |
const existingContact = prev.find(c =>
|
|
@@ -69,7 +70,7 @@ export default function SequenceViewer({ isGenerating, generationRunId, contactC
|
|
| 69 |
|
| 70 |
const reset = isNewRun ? 1 : 0;
|
| 71 |
const url = `/api/generate-sequences?file_id=${encodeURIComponent(uploadedFile.fileId)}&reset=${reset}`;
|
| 72 |
-
const eventSource = new EventSource(url
|
| 73 |
|
| 74 |
eventSource.onmessage = (event) => {
|
| 75 |
try {
|
|
@@ -115,8 +116,8 @@ export default function SequenceViewer({ isGenerating, generationRunId, contactC
|
|
| 115 |
(async () => {
|
| 116 |
try {
|
| 117 |
const [statusRes, seqRes] = await Promise.all([
|
| 118 |
-
|
| 119 |
-
|
| 120 |
]);
|
| 121 |
if (cancelled) return;
|
| 122 |
if (statusRes.ok && seqRes.ok) {
|
|
@@ -174,7 +175,7 @@ export default function SequenceViewer({ isGenerating, generationRunId, contactC
|
|
| 174 |
|
| 175 |
const handleDownload = async () => {
|
| 176 |
try {
|
| 177 |
-
const response = await
|
| 178 |
if (response.ok) {
|
| 179 |
const blob = await response.blob();
|
| 180 |
const url = URL.createObjectURL(blob);
|
|
|
|
| 6 |
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 7 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 8 |
import SequenceCard from './SequenceCard';
|
| 9 |
+
import { apiFetch } from '@/lib/api';
|
| 10 |
|
| 11 |
function applySequenceToContacts(prev, sequence, contactCount, setProgress) {
|
| 12 |
const existingContact = prev.find(c =>
|
|
|
|
| 70 |
|
| 71 |
const reset = isNewRun ? 1 : 0;
|
| 72 |
const url = `/api/generate-sequences?file_id=${encodeURIComponent(uploadedFile.fileId)}&reset=${reset}`;
|
| 73 |
+
const eventSource = new EventSource(url);
|
| 74 |
|
| 75 |
eventSource.onmessage = (event) => {
|
| 76 |
try {
|
|
|
|
| 116 |
(async () => {
|
| 117 |
try {
|
| 118 |
const [statusRes, seqRes] = await Promise.all([
|
| 119 |
+
apiFetch(`/api/generation-status?file_id=${encodeURIComponent(uploadedFile.fileId)}`),
|
| 120 |
+
apiFetch(`/api/sequences?file_id=${encodeURIComponent(uploadedFile.fileId)}`)
|
| 121 |
]);
|
| 122 |
if (cancelled) return;
|
| 123 |
if (statusRes.ok && seqRes.ok) {
|
|
|
|
| 175 |
|
| 176 |
const handleDownload = async () => {
|
| 177 |
try {
|
| 178 |
+
const response = await apiFetch(`/api/download-sequences?file_id=${uploadedFile.fileId}`);
|
| 179 |
if (response.ok) {
|
| 180 |
const blob = await response.blob();
|
| 181 |
const url = URL.createObjectURL(blob);
|
frontend/src/components/smartlead/SmartleadPanel.jsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Send, Download, Loader2, CheckCircle2, AlertCircle, Settings, RefreshCw
|
|
| 3 |
import { Button } from "@/components/ui/button";
|
| 4 |
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 5 |
import { motion } from 'framer-motion';
|
|
|
|
| 6 |
|
| 7 |
export default function SmartleadPanel({ uploadedFile, sequencesCount, onPushComplete }) {
|
| 8 |
const [selectedCampaignId, setSelectedCampaignId] = useState('');
|
|
@@ -22,7 +23,7 @@ export default function SmartleadPanel({ uploadedFile, sequencesCount, onPushCom
|
|
| 22 |
setIsLoadingCampaigns(true);
|
| 23 |
setError(null);
|
| 24 |
try {
|
| 25 |
-
const response = await
|
| 26 |
const data = await response.json();
|
| 27 |
if (response.ok) {
|
| 28 |
setCampaigns(data.campaigns || []);
|
|
@@ -52,7 +53,7 @@ export default function SmartleadPanel({ uploadedFile, sequencesCount, onPushCom
|
|
| 52 |
setPushResult(null);
|
| 53 |
|
| 54 |
try {
|
| 55 |
-
const response = await
|
| 56 |
method: 'POST',
|
| 57 |
headers: { 'Content-Type': 'application/json' },
|
| 58 |
body: JSON.stringify({
|
|
@@ -79,7 +80,7 @@ export default function SmartleadPanel({ uploadedFile, sequencesCount, onPushCom
|
|
| 79 |
|
| 80 |
const handleDownloadCSV = async () => {
|
| 81 |
try {
|
| 82 |
-
const response = await
|
| 83 |
if (response.ok) {
|
| 84 |
const blob = await response.blob();
|
| 85 |
const url = URL.createObjectURL(blob);
|
|
|
|
| 3 |
import { Button } from "@/components/ui/button";
|
| 4 |
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
| 5 |
import { motion } from 'framer-motion';
|
| 6 |
+
import { apiFetch } from '@/lib/api';
|
| 7 |
|
| 8 |
export default function SmartleadPanel({ uploadedFile, sequencesCount, onPushComplete }) {
|
| 9 |
const [selectedCampaignId, setSelectedCampaignId] = useState('');
|
|
|
|
| 23 |
setIsLoadingCampaigns(true);
|
| 24 |
setError(null);
|
| 25 |
try {
|
| 26 |
+
const response = await apiFetch('/api/smartlead-campaigns');
|
| 27 |
const data = await response.json();
|
| 28 |
if (response.ok) {
|
| 29 |
setCampaigns(data.campaigns || []);
|
|
|
|
| 53 |
setPushResult(null);
|
| 54 |
|
| 55 |
try {
|
| 56 |
+
const response = await apiFetch('/api/push-to-smartlead', {
|
| 57 |
method: 'POST',
|
| 58 |
headers: { 'Content-Type': 'application/json' },
|
| 59 |
body: JSON.stringify({
|
|
|
|
| 80 |
|
| 81 |
const handleDownloadCSV = async () => {
|
| 82 |
try {
|
| 83 |
+
const response = await apiFetch(`/api/download-sequences?file_id=${uploadedFile.fileId}`);
|
| 84 |
if (response.ok) {
|
| 85 |
const blob = await response.blob();
|
| 86 |
const url = URL.createObjectURL(blob);
|
frontend/src/components/upload/UploadStep.jsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import React, { useState, useRef } from 'react';
|
| 2 |
import { Upload, FileSpreadsheet, CheckCircle2, X, Users } from 'lucide-react';
|
| 3 |
import { Button } from "@/components/ui/button";
|
|
|
|
| 4 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 5 |
|
| 6 |
export default function UploadStep({ onFileUploaded, uploadedFile, onRemoveFile }) {
|
|
@@ -38,7 +39,7 @@ export default function UploadStep({ onFileUploaded, uploadedFile, onRemoveFile
|
|
| 38 |
formData.append('file', file);
|
| 39 |
|
| 40 |
try {
|
| 41 |
-
const response = await
|
| 42 |
method: 'POST',
|
| 43 |
body: formData,
|
| 44 |
});
|
|
|
|
| 1 |
import React, { useState, useRef } from 'react';
|
| 2 |
import { Upload, FileSpreadsheet, CheckCircle2, X, Users } from 'lucide-react';
|
| 3 |
import { Button } from "@/components/ui/button";
|
| 4 |
+
import { apiFetch } from '@/lib/api';
|
| 5 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 6 |
|
| 7 |
export default function UploadStep({ onFileUploaded, uploadedFile, onRemoveFile }) {
|
|
|
|
| 39 |
formData.append('file', file);
|
| 40 |
|
| 41 |
try {
|
| 42 |
+
const response = await apiFetch('/api/upload-csv', {
|
| 43 |
method: 'POST',
|
| 44 |
body: formData,
|
| 45 |
});
|
frontend/src/components/workspace/DealLinkSearch.jsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Loader2, Search, UserPlus, Building2 } from 'lucide-react';
|
|
| 3 |
import { Button } from '@/components/ui/button';
|
| 4 |
import { Input } from '@/components/ui/input';
|
| 5 |
import { cn } from '@/lib/utils';
|
|
|
|
| 6 |
|
| 7 |
/** Search CRM contacts and link one to the deal (`PATCH contact_id`). */
|
| 8 |
export function DealContactSearch({ linkedContactId, onPatchDeal, className }) {
|
|
@@ -19,7 +20,7 @@ export function DealContactSearch({ linkedContactId, onPatchDeal, className }) {
|
|
| 19 |
const t = setTimeout(async () => {
|
| 20 |
setLoading(true);
|
| 21 |
try {
|
| 22 |
-
const res = await
|
| 23 |
`/api/contacts?search=${encodeURIComponent(q.trim())}&limit=12&sort_by=created_at&sort_dir=desc`
|
| 24 |
);
|
| 25 |
const data = await res.json().catch(() => ({}));
|
|
@@ -118,7 +119,7 @@ export function DealCompanySearch({ onPatchDeal, className }) {
|
|
| 118 |
const t = setTimeout(async () => {
|
| 119 |
setLoading(true);
|
| 120 |
try {
|
| 121 |
-
const res = await
|
| 122 |
const data = await res.json().catch(() => ({}));
|
| 123 |
setResults(res.ok ? data.names || [] : []);
|
| 124 |
} catch {
|
|
|
|
| 3 |
import { Button } from '@/components/ui/button';
|
| 4 |
import { Input } from '@/components/ui/input';
|
| 5 |
import { cn } from '@/lib/utils';
|
| 6 |
+
import { apiFetch } from '@/lib/api';
|
| 7 |
|
| 8 |
/** Search CRM contacts and link one to the deal (`PATCH contact_id`). */
|
| 9 |
export function DealContactSearch({ linkedContactId, onPatchDeal, className }) {
|
|
|
|
| 20 |
const t = setTimeout(async () => {
|
| 21 |
setLoading(true);
|
| 22 |
try {
|
| 23 |
+
const res = await apiFetch(
|
| 24 |
`/api/contacts?search=${encodeURIComponent(q.trim())}&limit=12&sort_by=created_at&sort_dir=desc`
|
| 25 |
);
|
| 26 |
const data = await res.json().catch(() => ({}));
|
|
|
|
| 119 |
const t = setTimeout(async () => {
|
| 120 |
setLoading(true);
|
| 121 |
try {
|
| 122 |
+
const res = await apiFetch(`/api/company-names?q=${encodeURIComponent(q.trim())}&limit=20`);
|
| 123 |
const data = await res.json().catch(() => ({}));
|
| 124 |
setResults(res.ok ? data.names || [] : []);
|
| 125 |
} catch {
|
frontend/src/lib/api.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Browser cookie session + tenant APIs require credentials on same-origin / proxied /api calls. */
|
| 2 |
+
export function apiFetch(input, init = {}) {
|
| 3 |
+
return fetch(input, { credentials: 'include', ...init });
|
| 4 |
+
}
|
frontend/src/pages/Contacts.jsx
CHANGED
|
@@ -20,6 +20,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|
| 20 |
import { Button } from '@/components/ui/button';
|
| 21 |
import AppShell from '@/components/layout/AppShell';
|
| 22 |
import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
|
|
|
| 23 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
| 24 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 25 |
import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
|
|
@@ -153,7 +154,7 @@ export default function Contacts() {
|
|
| 153 |
|
| 154 |
const fetchFields = async () => {
|
| 155 |
try {
|
| 156 |
-
const res = await
|
| 157 |
if (res.ok) {
|
| 158 |
const data = await res.json();
|
| 159 |
setFields(data.fields || []);
|
|
@@ -165,7 +166,7 @@ export default function Contacts() {
|
|
| 165 |
|
| 166 |
const patchContact = async (contactId, patch) => {
|
| 167 |
try {
|
| 168 |
-
const res = await
|
| 169 |
method: 'PATCH',
|
| 170 |
headers: { 'Content-Type': 'application/json' },
|
| 171 |
body: JSON.stringify(patch),
|
|
@@ -207,7 +208,7 @@ export default function Contacts() {
|
|
| 207 |
if (activeFilters.length > 0) {
|
| 208 |
params.set('filters', JSON.stringify(activeFilters));
|
| 209 |
}
|
| 210 |
-
const res = await
|
| 211 |
if (res.ok) {
|
| 212 |
const data = await res.json();
|
| 213 |
setContacts(data.contacts || []);
|
|
@@ -223,7 +224,7 @@ export default function Contacts() {
|
|
| 223 |
const seedDemoContacts = async () => {
|
| 224 |
setSeedBusy(true);
|
| 225 |
try {
|
| 226 |
-
const res = await
|
| 227 |
const data = await res.json().catch(() => ({}));
|
| 228 |
if (!res.ok) {
|
| 229 |
throw new Error(typeof data.detail === 'string' ? data.detail : 'Could not load demo data');
|
|
@@ -246,8 +247,8 @@ export default function Contacts() {
|
|
| 246 |
setSeqLoading(true);
|
| 247 |
try {
|
| 248 |
const [detailRes, seqRes] = await Promise.all([
|
| 249 |
-
|
| 250 |
-
|
| 251 |
]);
|
| 252 |
if (detailRes.ok) {
|
| 253 |
const detailData = await detailRes.json();
|
|
@@ -310,7 +311,7 @@ export default function Contacts() {
|
|
| 310 |
) {
|
| 311 |
return;
|
| 312 |
}
|
| 313 |
-
const res = await
|
| 314 |
method: 'POST',
|
| 315 |
headers: { 'Content-Type': 'application/json' },
|
| 316 |
body: JSON.stringify({ contact_ids: selectedIds }),
|
|
@@ -322,7 +323,7 @@ export default function Contacts() {
|
|
| 322 |
);
|
| 323 |
}
|
| 324 |
} else if (action === 'leads') {
|
| 325 |
-
const res = await
|
| 326 |
method: 'POST',
|
| 327 |
headers: { 'Content-Type': 'application/json' },
|
| 328 |
body: JSON.stringify({ contact_ids: selectedIds }),
|
|
@@ -411,7 +412,7 @@ export default function Contacts() {
|
|
| 411 |
setEnrichLoading(true);
|
| 412 |
setEnrichError('');
|
| 413 |
try {
|
| 414 |
-
const res = await
|
| 415 |
method: 'POST',
|
| 416 |
});
|
| 417 |
const data = await res.json().catch(() => ({}));
|
|
@@ -475,7 +476,7 @@ export default function Contacts() {
|
|
| 475 |
setInlineSaving(true);
|
| 476 |
setInlineError('');
|
| 477 |
try {
|
| 478 |
-
const res = await
|
| 479 |
method: 'POST',
|
| 480 |
headers: { 'Content-Type': 'application/json' },
|
| 481 |
body: JSON.stringify({
|
|
|
|
| 20 |
import { Button } from '@/components/ui/button';
|
| 21 |
import AppShell from '@/components/layout/AppShell';
|
| 22 |
import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
| 23 |
+
import { apiFetch } from '@/lib/api';
|
| 24 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
| 25 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
| 26 |
import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
|
|
|
|
| 154 |
|
| 155 |
const fetchFields = async () => {
|
| 156 |
try {
|
| 157 |
+
const res = await apiFetch('/api/contact-fields');
|
| 158 |
if (res.ok) {
|
| 159 |
const data = await res.json();
|
| 160 |
setFields(data.fields || []);
|
|
|
|
| 166 |
|
| 167 |
const patchContact = async (contactId, patch) => {
|
| 168 |
try {
|
| 169 |
+
const res = await apiFetch(`/api/contacts/${contactId}`, {
|
| 170 |
method: 'PATCH',
|
| 171 |
headers: { 'Content-Type': 'application/json' },
|
| 172 |
body: JSON.stringify(patch),
|
|
|
|
| 208 |
if (activeFilters.length > 0) {
|
| 209 |
params.set('filters', JSON.stringify(activeFilters));
|
| 210 |
}
|
| 211 |
+
const res = await apiFetch(`/api/contacts?${params.toString()}`);
|
| 212 |
if (res.ok) {
|
| 213 |
const data = await res.json();
|
| 214 |
setContacts(data.contacts || []);
|
|
|
|
| 224 |
const seedDemoContacts = async () => {
|
| 225 |
setSeedBusy(true);
|
| 226 |
try {
|
| 227 |
+
const res = await apiFetch('/api/contacts/seed-demo', { method: 'POST' });
|
| 228 |
const data = await res.json().catch(() => ({}));
|
| 229 |
if (!res.ok) {
|
| 230 |
throw new Error(typeof data.detail === 'string' ? data.detail : 'Could not load demo data');
|
|
|
|
| 247 |
setSeqLoading(true);
|
| 248 |
try {
|
| 249 |
const [detailRes, seqRes] = await Promise.all([
|
| 250 |
+
apiFetch(`/api/contacts/${contact.id}`),
|
| 251 |
+
apiFetch(`/api/contacts/${contact.id}/sequences`),
|
| 252 |
]);
|
| 253 |
if (detailRes.ok) {
|
| 254 |
const detailData = await detailRes.json();
|
|
|
|
| 311 |
) {
|
| 312 |
return;
|
| 313 |
}
|
| 314 |
+
const res = await apiFetch('/api/contacts/bulk-delete', {
|
| 315 |
method: 'POST',
|
| 316 |
headers: { 'Content-Type': 'application/json' },
|
| 317 |
body: JSON.stringify({ contact_ids: selectedIds }),
|
|
|
|
| 323 |
);
|
| 324 |
}
|
| 325 |
} else if (action === 'leads') {
|
| 326 |
+
const res = await apiFetch('/api/contacts/bulk-convert-to-leads', {
|
| 327 |
method: 'POST',
|
| 328 |
headers: { 'Content-Type': 'application/json' },
|
| 329 |
body: JSON.stringify({ contact_ids: selectedIds }),
|
|
|
|
| 412 |
setEnrichLoading(true);
|
| 413 |
setEnrichError('');
|
| 414 |
try {
|
| 415 |
+
const res = await apiFetch(`/api/contacts/${selectedContact.id}/enrich`, {
|
| 416 |
method: 'POST',
|
| 417 |
});
|
| 418 |
const data = await res.json().catch(() => ({}));
|
|
|
|
| 476 |
setInlineSaving(true);
|
| 477 |
setInlineError('');
|
| 478 |
try {
|
| 479 |
+
const res = await apiFetch('/api/contacts', {
|
| 480 |
method: 'POST',
|
| 481 |
headers: { 'Content-Type': 'application/json' },
|
| 482 |
body: JSON.stringify({
|
frontend/src/pages/Deals.jsx
CHANGED
|
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|
| 5 |
import { Input } from '@/components/ui/input';
|
| 6 |
import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select';
|
| 7 |
import AppShell from '@/components/layout/AppShell';
|
|
|
|
| 8 |
import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
| 9 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
| 10 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
|
@@ -611,7 +612,7 @@ export default function Deals() {
|
|
| 611 |
params.set('sort_by', 'created_at');
|
| 612 |
params.set('sort_dir', 'desc');
|
| 613 |
if (search.trim()) params.set('search', search.trim());
|
| 614 |
-
const res = await
|
| 615 |
if (res.ok) {
|
| 616 |
const data = await res.json();
|
| 617 |
setDeals(data.deals || []);
|
|
@@ -698,7 +699,7 @@ export default function Deals() {
|
|
| 698 |
const seedDemo = async () => {
|
| 699 |
setSeedBusy(true);
|
| 700 |
try {
|
| 701 |
-
const res = await
|
| 702 |
if (!res.ok) throw new Error('Seed failed');
|
| 703 |
await fetchDeals();
|
| 704 |
} catch (e) {
|
|
@@ -714,7 +715,7 @@ export default function Deals() {
|
|
| 714 |
const st = STAGES.some((s) => s.value === stage) ? stage : 'new';
|
| 715 |
setCreateBusy(true);
|
| 716 |
try {
|
| 717 |
-
const res = await
|
| 718 |
method: 'POST',
|
| 719 |
headers: { 'Content-Type': 'application/json' },
|
| 720 |
body: JSON.stringify({ name: 'Untitled deal', stage: st }),
|
|
@@ -749,7 +750,7 @@ export default function Deals() {
|
|
| 749 |
|
| 750 |
const patchDeal = async (dealId, patch) => {
|
| 751 |
try {
|
| 752 |
-
const res = await
|
| 753 |
method: 'PATCH',
|
| 754 |
headers: { 'Content-Type': 'application/json' },
|
| 755 |
body: JSON.stringify(patch),
|
|
@@ -769,7 +770,7 @@ export default function Deals() {
|
|
| 769 |
|
| 770 |
const refreshDealDetail = async (dealId) => {
|
| 771 |
try {
|
| 772 |
-
const res = await
|
| 773 |
if (res.ok) {
|
| 774 |
const d = await res.json();
|
| 775 |
setDealDetail(d);
|
|
@@ -781,7 +782,7 @@ export default function Deals() {
|
|
| 781 |
|
| 782 |
const patchLinkedContact = async (contactId, patch) => {
|
| 783 |
try {
|
| 784 |
-
const res = await
|
| 785 |
method: 'PATCH',
|
| 786 |
headers: { 'Content-Type': 'application/json' },
|
| 787 |
body: JSON.stringify(patch),
|
|
@@ -802,7 +803,7 @@ export default function Deals() {
|
|
| 802 |
setCompanyFetchLoading(true);
|
| 803 |
setCompanyFetchError('');
|
| 804 |
try {
|
| 805 |
-
const res = await
|
| 806 |
const data = await res.json().catch(() => ({}));
|
| 807 |
if (!res.ok) {
|
| 808 |
setCompanyFetchError(typeof data.detail === 'string' ? data.detail : 'Could not fetch enrichment');
|
|
@@ -824,7 +825,7 @@ export default function Deals() {
|
|
| 824 |
setDealDetail(deal);
|
| 825 |
setDealPanelForm(null);
|
| 826 |
try {
|
| 827 |
-
const res = await
|
| 828 |
if (res.ok) {
|
| 829 |
const d = await res.json();
|
| 830 |
setDealDetail(d);
|
|
|
|
| 5 |
import { Input } from '@/components/ui/input';
|
| 6 |
import { Select, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select';
|
| 7 |
import AppShell from '@/components/layout/AppShell';
|
| 8 |
+
import { apiFetch } from '@/lib/api';
|
| 9 |
import MainTableWorkspace from '@/components/workspace/MainTableWorkspace';
|
| 10 |
import SlideOverPanel from '@/components/workspace/SlideOverPanel';
|
| 11 |
import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
|
|
|
| 612 |
params.set('sort_by', 'created_at');
|
| 613 |
params.set('sort_dir', 'desc');
|
| 614 |
if (search.trim()) params.set('search', search.trim());
|
| 615 |
+
const res = await apiFetch(`/api/deals?${params.toString()}`);
|
| 616 |
if (res.ok) {
|
| 617 |
const data = await res.json();
|
| 618 |
setDeals(data.deals || []);
|
|
|
|
| 699 |
const seedDemo = async () => {
|
| 700 |
setSeedBusy(true);
|
| 701 |
try {
|
| 702 |
+
const res = await apiFetch('/api/deals/seed-demo', { method: 'POST' });
|
| 703 |
if (!res.ok) throw new Error('Seed failed');
|
| 704 |
await fetchDeals();
|
| 705 |
} catch (e) {
|
|
|
|
| 715 |
const st = STAGES.some((s) => s.value === stage) ? stage : 'new';
|
| 716 |
setCreateBusy(true);
|
| 717 |
try {
|
| 718 |
+
const res = await apiFetch('/api/deals', {
|
| 719 |
method: 'POST',
|
| 720 |
headers: { 'Content-Type': 'application/json' },
|
| 721 |
body: JSON.stringify({ name: 'Untitled deal', stage: st }),
|
|
|
|
| 750 |
|
| 751 |
const patchDeal = async (dealId, patch) => {
|
| 752 |
try {
|
| 753 |
+
const res = await apiFetch(`/api/deals/${dealId}`, {
|
| 754 |
method: 'PATCH',
|
| 755 |
headers: { 'Content-Type': 'application/json' },
|
| 756 |
body: JSON.stringify(patch),
|
|
|
|
| 770 |
|
| 771 |
const refreshDealDetail = async (dealId) => {
|
| 772 |
try {
|
| 773 |
+
const res = await apiFetch(`/api/deals/${dealId}`);
|
| 774 |
if (res.ok) {
|
| 775 |
const d = await res.json();
|
| 776 |
setDealDetail(d);
|
|
|
|
| 782 |
|
| 783 |
const patchLinkedContact = async (contactId, patch) => {
|
| 784 |
try {
|
| 785 |
+
const res = await apiFetch(`/api/contacts/${contactId}`, {
|
| 786 |
method: 'PATCH',
|
| 787 |
headers: { 'Content-Type': 'application/json' },
|
| 788 |
body: JSON.stringify(patch),
|
|
|
|
| 803 |
setCompanyFetchLoading(true);
|
| 804 |
setCompanyFetchError('');
|
| 805 |
try {
|
| 806 |
+
const res = await apiFetch(`/api/contacts/${dealDetail.contact_id}/enrich`, { method: 'POST' });
|
| 807 |
const data = await res.json().catch(() => ({}));
|
| 808 |
if (!res.ok) {
|
| 809 |
setCompanyFetchError(typeof data.detail === 'string' ? data.detail : 'Could not fetch enrichment');
|
|
|
|
| 825 |
setDealDetail(deal);
|
| 826 |
setDealPanelForm(null);
|
| 827 |
try {
|
| 828 |
+
const res = await apiFetch(`/api/deals/${deal.id}`);
|
| 829 |
if (res.ok) {
|
| 830 |
const d = await res.json();
|
| 831 |
setDealDetail(d);
|
frontend/src/pages/EmailSequenceGenerator.jsx
CHANGED
|
@@ -8,6 +8,7 @@ import ProductSelector from '@/components/products/ProductSelector';
|
|
| 8 |
import PromptEditor from '@/components/prompts/PromptEditor';
|
| 9 |
import SequenceViewer from '@/components/sequences/SequenceViewer';
|
| 10 |
import AppShell from '@/components/layout/AppShell';
|
|
|
|
| 11 |
|
| 12 |
export default function EmailSequenceGenerator() {
|
| 13 |
const [step, setStep] = useState(1);
|
|
@@ -39,7 +40,7 @@ export default function EmailSequenceGenerator() {
|
|
| 39 |
|
| 40 |
// Save prompts to backend first, then start generation (avoids "No prompts found" race)
|
| 41 |
try {
|
| 42 |
-
const res = await
|
| 43 |
method: 'POST',
|
| 44 |
headers: { 'Content-Type': 'application/json' },
|
| 45 |
body: JSON.stringify({
|
|
|
|
| 8 |
import PromptEditor from '@/components/prompts/PromptEditor';
|
| 9 |
import SequenceViewer from '@/components/sequences/SequenceViewer';
|
| 10 |
import AppShell from '@/components/layout/AppShell';
|
| 11 |
+
import { apiFetch } from '@/lib/api';
|
| 12 |
|
| 13 |
export default function EmailSequenceGenerator() {
|
| 14 |
const [step, setStep] = useState(1);
|
|
|
|
| 40 |
|
| 41 |
// Save prompts to backend first, then start generation (avoids "No prompts found" race)
|
| 42 |
try {
|
| 43 |
+
const res = await apiFetch('/api/save-prompts', {
|
| 44 |
method: 'POST',
|
| 45 |
headers: { 'Content-Type': 'application/json' },
|
| 46 |
body: JSON.stringify({
|
frontend/src/pages/Leads.jsx
CHANGED
|
@@ -21,6 +21,7 @@ import CompanyDetailsEditor from '@/components/workspace/CompanyDetailsEditor';
|
|
| 21 |
import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
|
| 22 |
import { EditableCell } from '@/components/workspace/EditableCell';
|
| 23 |
import { cn } from '@/lib/utils';
|
|
|
|
| 24 |
|
| 25 |
const CRM_STATUSES = [
|
| 26 |
{ value: 'none', label: '—', className: 'bg-slate-300 text-slate-800' },
|
|
@@ -94,7 +95,7 @@ export default function Leads() {
|
|
| 94 |
params.set('sort_dir', 'desc');
|
| 95 |
if (search.trim()) params.set('search', search.trim());
|
| 96 |
if (statusFilter && statusFilter !== 'all') params.set('status', statusFilter);
|
| 97 |
-
const res = await
|
| 98 |
if (res.ok) {
|
| 99 |
const data = await res.json();
|
| 100 |
setLeads(data.leads || []);
|
|
@@ -110,7 +111,7 @@ export default function Leads() {
|
|
| 110 |
const seedDemoLeads = async () => {
|
| 111 |
setSeedBusy(true);
|
| 112 |
try {
|
| 113 |
-
const res = await
|
| 114 |
const data = await res.json().catch(() => ({}));
|
| 115 |
if (!res.ok) {
|
| 116 |
throw new Error(
|
|
@@ -133,7 +134,7 @@ export default function Leads() {
|
|
| 133 |
|
| 134 |
const patchLead = async (leadId, patch) => {
|
| 135 |
try {
|
| 136 |
-
const res = await
|
| 137 |
method: 'PATCH',
|
| 138 |
headers: { 'Content-Type': 'application/json' },
|
| 139 |
body: JSON.stringify(patch),
|
|
@@ -156,13 +157,13 @@ export default function Leads() {
|
|
| 156 |
setCompanyFetchLoading(true);
|
| 157 |
setCompanyFetchError('');
|
| 158 |
try {
|
| 159 |
-
const res = await
|
| 160 |
const data = await res.json().catch(() => ({}));
|
| 161 |
if (!res.ok) {
|
| 162 |
setCompanyFetchError(typeof data.detail === 'string' ? data.detail : 'Could not fetch enrichment');
|
| 163 |
return;
|
| 164 |
}
|
| 165 |
-
const res2 = await
|
| 166 |
if (res2.ok) {
|
| 167 |
setSelected(await res2.json());
|
| 168 |
}
|
|
@@ -179,7 +180,7 @@ export default function Leads() {
|
|
| 179 |
setThreadLoading(true);
|
| 180 |
setThreadData(null);
|
| 181 |
try {
|
| 182 |
-
const res = await
|
| 183 |
const data = await res.json();
|
| 184 |
if (!res.ok) throw new Error(data.detail || 'Failed to load thread');
|
| 185 |
setThreadData(data.history);
|
|
@@ -196,7 +197,7 @@ export default function Leads() {
|
|
| 196 |
setSelected(lead);
|
| 197 |
setThreadData(null);
|
| 198 |
try {
|
| 199 |
-
const res = await
|
| 200 |
if (res.ok) {
|
| 201 |
const d = await res.json();
|
| 202 |
setSelected(d);
|
|
@@ -241,7 +242,7 @@ export default function Leads() {
|
|
| 241 |
setBulkBusy(action);
|
| 242 |
try {
|
| 243 |
if (action === 'move') {
|
| 244 |
-
const res = await
|
| 245 |
method: 'POST',
|
| 246 |
headers: { 'Content-Type': 'application/json' },
|
| 247 |
body: JSON.stringify({ lead_ids: selectedIds }),
|
|
@@ -258,7 +259,7 @@ export default function Leads() {
|
|
| 258 |
if (!window.confirm(`Delete ${selectedIds.length} lead(s)? This cannot be undone.`)) {
|
| 259 |
return;
|
| 260 |
}
|
| 261 |
-
const res = await
|
| 262 |
method: 'POST',
|
| 263 |
headers: { 'Content-Type': 'application/json' },
|
| 264 |
body: JSON.stringify({ lead_ids: selectedIds }),
|
|
@@ -268,7 +269,7 @@ export default function Leads() {
|
|
| 268 |
throw new Error(typeof data.detail === 'string' ? data.detail : 'Delete failed');
|
| 269 |
}
|
| 270 |
} else if (action === 'deals') {
|
| 271 |
-
const res = await
|
| 272 |
method: 'POST',
|
| 273 |
headers: { 'Content-Type': 'application/json' },
|
| 274 |
body: JSON.stringify({ lead_ids: selectedIds }),
|
|
|
|
| 21 |
import ContactIdentityEditor from '@/components/workspace/ContactIdentityEditor';
|
| 22 |
import { EditableCell } from '@/components/workspace/EditableCell';
|
| 23 |
import { cn } from '@/lib/utils';
|
| 24 |
+
import { apiFetch } from '@/lib/api';
|
| 25 |
|
| 26 |
const CRM_STATUSES = [
|
| 27 |
{ value: 'none', label: '—', className: 'bg-slate-300 text-slate-800' },
|
|
|
|
| 95 |
params.set('sort_dir', 'desc');
|
| 96 |
if (search.trim()) params.set('search', search.trim());
|
| 97 |
if (statusFilter && statusFilter !== 'all') params.set('status', statusFilter);
|
| 98 |
+
const res = await apiFetch(`/api/leads?${params.toString()}`);
|
| 99 |
if (res.ok) {
|
| 100 |
const data = await res.json();
|
| 101 |
setLeads(data.leads || []);
|
|
|
|
| 111 |
const seedDemoLeads = async () => {
|
| 112 |
setSeedBusy(true);
|
| 113 |
try {
|
| 114 |
+
const res = await apiFetch('/api/leads/seed-demo', { method: 'POST' });
|
| 115 |
const data = await res.json().catch(() => ({}));
|
| 116 |
if (!res.ok) {
|
| 117 |
throw new Error(
|
|
|
|
| 134 |
|
| 135 |
const patchLead = async (leadId, patch) => {
|
| 136 |
try {
|
| 137 |
+
const res = await apiFetch(`/api/leads/${leadId}`, {
|
| 138 |
method: 'PATCH',
|
| 139 |
headers: { 'Content-Type': 'application/json' },
|
| 140 |
body: JSON.stringify(patch),
|
|
|
|
| 157 |
setCompanyFetchLoading(true);
|
| 158 |
setCompanyFetchError('');
|
| 159 |
try {
|
| 160 |
+
const res = await apiFetch(`/api/contacts/${selected.contact_id}/enrich`, { method: 'POST' });
|
| 161 |
const data = await res.json().catch(() => ({}));
|
| 162 |
if (!res.ok) {
|
| 163 |
setCompanyFetchError(typeof data.detail === 'string' ? data.detail : 'Could not fetch enrichment');
|
| 164 |
return;
|
| 165 |
}
|
| 166 |
+
const res2 = await apiFetch(`/api/leads/${selected.id}`);
|
| 167 |
if (res2.ok) {
|
| 168 |
setSelected(await res2.json());
|
| 169 |
}
|
|
|
|
| 180 |
setThreadLoading(true);
|
| 181 |
setThreadData(null);
|
| 182 |
try {
|
| 183 |
+
const res = await apiFetch(`/api/leads/${leadId}/smartlead-thread`);
|
| 184 |
const data = await res.json();
|
| 185 |
if (!res.ok) throw new Error(data.detail || 'Failed to load thread');
|
| 186 |
setThreadData(data.history);
|
|
|
|
| 197 |
setSelected(lead);
|
| 198 |
setThreadData(null);
|
| 199 |
try {
|
| 200 |
+
const res = await apiFetch(`/api/leads/${lead.id}`);
|
| 201 |
if (res.ok) {
|
| 202 |
const d = await res.json();
|
| 203 |
setSelected(d);
|
|
|
|
| 242 |
setBulkBusy(action);
|
| 243 |
try {
|
| 244 |
if (action === 'move') {
|
| 245 |
+
const res = await apiFetch('/api/leads/bulk-move-to-contacts', {
|
| 246 |
method: 'POST',
|
| 247 |
headers: { 'Content-Type': 'application/json' },
|
| 248 |
body: JSON.stringify({ lead_ids: selectedIds }),
|
|
|
|
| 259 |
if (!window.confirm(`Delete ${selectedIds.length} lead(s)? This cannot be undone.`)) {
|
| 260 |
return;
|
| 261 |
}
|
| 262 |
+
const res = await apiFetch('/api/leads/bulk-delete', {
|
| 263 |
method: 'POST',
|
| 264 |
headers: { 'Content-Type': 'application/json' },
|
| 265 |
body: JSON.stringify({ lead_ids: selectedIds }),
|
|
|
|
| 269 |
throw new Error(typeof data.detail === 'string' ? data.detail : 'Delete failed');
|
| 270 |
}
|
| 271 |
} else if (action === 'deals') {
|
| 272 |
+
const res = await apiFetch('/api/deals/from-leads', {
|
| 273 |
method: 'POST',
|
| 274 |
headers: { 'Content-Type': 'application/json' },
|
| 275 |
body: JSON.stringify({ lead_ids: selectedIds }),
|
frontend/src/pages/RunHistory.jsx
CHANGED
|
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|
| 6 |
import { Badge } from "@/components/ui/badge";
|
| 7 |
import { motion } from 'framer-motion';
|
| 8 |
import AppShell from '@/components/layout/AppShell';
|
|
|
|
| 9 |
|
| 10 |
export default function RunHistory() {
|
| 11 |
const [runs, setRuns] = useState([]);
|
|
@@ -20,7 +21,7 @@ export default function RunHistory() {
|
|
| 20 |
const fetchRuns = async () => {
|
| 21 |
try {
|
| 22 |
setLoading(true);
|
| 23 |
-
const response = await
|
| 24 |
if (response.ok) {
|
| 25 |
const data = await response.json();
|
| 26 |
setRuns(data);
|
|
|
|
| 6 |
import { Badge } from "@/components/ui/badge";
|
| 7 |
import { motion } from 'framer-motion';
|
| 8 |
import AppShell from '@/components/layout/AppShell';
|
| 9 |
+
import { apiFetch } from '@/lib/api';
|
| 10 |
|
| 11 |
export default function RunHistory() {
|
| 12 |
const [runs, setRuns] = useState([]);
|
|
|
|
| 21 |
const fetchRuns = async () => {
|
| 22 |
try {
|
| 23 |
setLoading(true);
|
| 24 |
+
const response = await apiFetch('/api/smartlead-runs');
|
| 25 |
if (response.ok) {
|
| 26 |
const data = await response.json();
|
| 27 |
setRuns(data);
|