Seth commited on
Commit
698ffee
·
1 Parent(s): 168b3d6
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
- user = request.session.get("user")
89
- if not user:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  raise HTTPException(status_code=401, detail="Not signed in")
91
- return user
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- request.session["user"] = {
175
- "sub": id_info.get("sub"),
176
- "email": id_info.get("email"),
177
- "name": id_info.get("name"),
178
- "picture": id_info.get("picture"),
179
- "email_verified": id_info.get("email_verified"),
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 create_engine, Column, Integer, String, Text, DateTime, JSON
 
 
 
 
 
 
 
 
 
 
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
- # Create tables
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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(func.lower(Contact.email) == email.lower())
 
 
 
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(func.lower(CrmLead.email) == email.lower())
 
 
 
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(...), db: Session = Depends(get_db)):
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(db: Session = Depends(get_db)):
591
  """Return all available contact field names from uploaded Apollo rows."""
592
- contacts = db.query(Contact.raw_data).all()
 
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
- db: Session = Depends(get_db),
619
  ):
620
- query = db.query(Contact)
 
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, db: Session = Depends(get_db)):
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 = db.query(Contact).filter(func.lower(Contact.email) == email).first()
 
 
 
 
 
 
 
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, db: Session = Depends(get_db)):
 
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(db: Session = Depends(get_db)):
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(Contact.file_id == DEMO_CONTACTS_FILE_ID)
 
 
 
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, db: Session = Depends(get_db)):
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 = db.query(Contact).filter(Contact.id == int(cid)).first()
 
 
 
 
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
- db: Session = Depends(get_db),
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, db: Session = Depends(get_db)):
1032
- contact = db.query(Contact).filter(Contact.id == contact_id).first()
 
 
 
 
 
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 = db.query(Contact).filter(Contact.id == row.contact_id).first()
 
 
 
 
 
 
 
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, db: Session = Depends(get_db)):
1161
- contact = db.query(Contact).filter(Contact.id == contact_id).first()
 
 
 
 
 
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(Contact.id != contact_id, func.lower(Contact.email) == email)
 
 
 
 
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, db: Session = Depends(get_db)):
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 = db.query(Contact).filter(Contact.id == contact_id).first()
 
 
 
 
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, db: Session = Depends(get_db)):
1318
- contact = db.query(Contact).filter(Contact.id == contact_id).first()
 
 
 
 
 
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, db: Session = Depends(get_db)):
1355
  """Save prompt templates for products"""
 
1356
  try:
1357
  # Delete existing prompts for this file
1358
- db.query(Prompt).filter(Prompt.file_id == request.file_id).delete()
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(...), db: Session = Depends(get_db)):
1377
  """Return progress for sequence generation (so frontend can resume after sleep/reconnect)."""
1378
- db_file = db.query(UploadedFile).filter(UploadedFile.file_id == file_id).first()
 
 
 
 
 
 
 
 
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(GeneratedSequence.file_id == file_id)
 
 
 
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(...), db: Session = Depends(get_db)):
1398
  """Return all generated sequences for a file (for catch-up after reconnect)."""
 
1399
  sequences = (
1400
  db.query(GeneratedSequence)
1401
- .filter(GeneratedSequence.file_id == file_id)
 
 
 
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
- db: Session = Depends(get_db),
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 = db.query(UploadedFile).filter(UploadedFile.file_id == file_id).first()
 
 
 
 
 
 
 
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 = db.query(Prompt).filter(Prompt.file_id == file_id).all()
 
 
 
 
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(GeneratedSequence.file_id == file_id).delete()
 
 
 
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(...), db: Session = Depends(get_db)):
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 = db.query(GeneratedSequence).filter(
1551
- GeneratedSequence.file_id == file_id
1552
- ).order_by(GeneratedSequence.sequence_id, GeneratedSequence.email_number).all()
 
 
 
 
 
 
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, db: Session = Depends(get_db)):
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 = db.query(GeneratedSequence).filter(
1664
- GeneratedSequence.file_id == request.file_id
1665
- ).order_by(GeneratedSequence.sequence_id, GeneratedSequence.email_number).all()
 
 
 
 
 
 
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), db: Session = Depends(get_db)):
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(request: Request, db: Session = Depends(get_db)):
 
 
 
 
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(func.lower(Contact.email) == parsed["email"].lower())
 
 
 
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(db: Session = Depends(get_db)):
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 = db.query(CrmLead).filter(demo_email_filter).delete(synchronize_session=False)
 
 
 
 
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
- db: Session = Depends(get_db),
2129
  ):
2130
- q = db.query(CrmLead)
 
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, db: Session = Depends(get_db)):
2167
- row = db.query(CrmLead).filter(CrmLead.id == lead_id).first()
 
 
 
 
 
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 = db.query(Contact).filter(Contact.id == row.contact_id).first()
 
 
 
 
 
 
 
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, db: Session = Depends(get_db)):
2186
- row = db.query(CrmLead).filter(CrmLead.id == lead_id).first()
 
 
 
 
 
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, db: Session = Depends(get_db)):
2244
- lead = db.query(CrmLead).filter(CrmLead.id == lead_id).first()
 
 
 
 
 
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, db: Session = Depends(get_db)):
 
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 = db.query(CrmLead).filter(CrmLead.id == lid).first()
 
 
 
 
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, db: Session = Depends(get_db)):
 
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, db: Session = Depends(get_db)):
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 = db.query(CrmLead).filter(CrmLead.id == lid).first()
 
 
 
 
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
- db: Session = Depends(get_db),
2331
  ):
2332
- q = db.query(CrmDeal)
 
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, db: Session = Depends(get_db)):
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, db: Session = Depends(get_db)):
2392
- row = db.query(CrmDeal).filter(CrmDeal.id == deal_id).first()
 
 
 
 
 
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, db: Session = Depends(get_db)):
2400
- row = db.query(CrmDeal).filter(CrmDeal.id == deal_id).first()
 
 
 
 
 
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 = db.query(Contact).filter(Contact.id == int(cid)).first()
 
 
 
 
 
 
 
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 = db.query(Contact).filter(Contact.id == row.contact_id).first()
 
 
 
 
 
 
 
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(db: Session = Depends(get_db)):
2470
- removed = db.query(CrmDeal).filter(CrmDeal.name.like("DEMO: %")).delete(synchronize_session=False)
 
 
 
 
 
 
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, db: Session = Depends(get_db)):
2541
  """Fetch full thread from Smartlead API (Admin API key required)."""
2542
- row = db.query(CrmLead).filter(CrmLead.id == lead_id).first()
 
 
 
 
 
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=&lt;your workspace id&gt;** 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
- * Header sign-in: session cookie set by GET /api/auth/google Google /api/auth/google/callback.
9
  */
10
  export default function GoogleAuthBar() {
11
  const [searchParams, setSearchParams] = useSearchParams();
12
- const [phase, setPhase] = useState('loading'); // loading | ready | error
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
- fetch('/api/auth/status').then((r) => r.json()),
20
- fetch('/api/auth/me', { credentials: 'include' }).then((r) =>
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 fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
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-8rem,20rem)] items-center gap-2">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 sm:inline">
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="/api/auth/google" className="inline-flex items-center gap-2">
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, { withCredentials: false });
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
- fetch(`/api/generation-status?file_id=${encodeURIComponent(uploadedFile.fileId)}`),
119
- fetch(`/api/sequences?file_id=${encodeURIComponent(uploadedFile.fileId)}`)
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 fetch(`/api/download-sequences?file_id=${uploadedFile.fileId}`);
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 fetch('/api/smartlead-campaigns');
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 fetch('/api/push-to-smartlead', {
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 fetch(`/api/download-sequences?file_id=${uploadedFile.fileId}`);
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 fetch('/api/upload-csv', {
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 fetch(
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 fetch(`/api/company-names?q=${encodeURIComponent(q.trim())}&limit=20`);
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 fetch('/api/contact-fields');
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 fetch(`/api/contacts/${contactId}`, {
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 fetch(`/api/contacts?${params.toString()}`);
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 fetch('/api/contacts/seed-demo', { method: 'POST' });
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
- fetch(`/api/contacts/${contact.id}`),
250
- fetch(`/api/contacts/${contact.id}/sequences`),
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 fetch('/api/contacts/bulk-delete', {
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 fetch('/api/contacts/bulk-convert-to-leads', {
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 fetch(`/api/contacts/${selectedContact.id}/enrich`, {
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 fetch('/api/contacts', {
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 fetch(`/api/deals?${params.toString()}`);
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 fetch('/api/deals/seed-demo', { method: 'POST' });
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 fetch('/api/deals', {
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 fetch(`/api/deals/${dealId}`, {
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 fetch(`/api/deals/${dealId}`);
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 fetch(`/api/contacts/${contactId}`, {
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 fetch(`/api/contacts/${dealDetail.contact_id}/enrich`, { method: 'POST' });
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 fetch(`/api/deals/${deal.id}`);
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 fetch('/api/save-prompts', {
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 fetch(`/api/leads?${params.toString()}`);
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 fetch('/api/leads/seed-demo', { method: 'POST' });
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 fetch(`/api/leads/${leadId}`, {
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 fetch(`/api/contacts/${selected.contact_id}/enrich`, { method: 'POST' });
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 fetch(`/api/leads/${selected.id}`);
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 fetch(`/api/leads/${leadId}/smartlead-thread`);
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 fetch(`/api/leads/${lead.id}`);
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 fetch('/api/leads/bulk-move-to-contacts', {
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 fetch('/api/leads/bulk-delete', {
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 fetch('/api/deals/from-leads', {
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 fetch('/api/smartlead-runs');
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);