feat: add appointments, campaigns, admin, storage, tests and various updates

- Add new routers: admin, appointments, campaigns
- Add storage service and logging config
- Add migrations directory and test suite with pytest config
- Add supabase_migration_features.sql
- Update models, dependencies, config, and existing routers
- Remove whatsapp_service (deleted)
- Update pyproject.toml and uv.lock dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
belviskhoremk
2026-04-03 09:11:58 +00:00
parent 9dccc83293
commit 92d4c2fc5e
51 changed files with 7076 additions and 515 deletions

View File

@@ -55,13 +55,6 @@ class Settings(BaseSettings):
# Supabase Storage
supabase_storage_url: str = ""
# WhatsApp Cloud API
whatsapp_access_token: str = ""
whatsapp_phone_number_id: str = ""
whatsapp_verify_token: str = "contexta_whatsapp_verify"
whatsapp_app_secret: str = ""
whatsapp_display_number: str = "" # E.164 without '+', e.g. "15551234567"
@property
def allowed_origins_list(self) -> List[str]:
return [o.strip() for o in self.allowed_origins.split(",")]
@@ -188,7 +181,7 @@ DEFAULT_MODELS = {
# ═══════════════════════════════════════════════════════════════════════════════
# PLAN LIMITS — Pricing: Starter $12/mo, Business $29/mo, Agency $79/mo
# PLAN LIMITS — Pricing: Starter $19/mo, Business $49/mo, Agency $99/mo
# ═══════════════════════════════════════════════════════════════════════════════
#
# Cost analysis (per 1M tokens approx):
@@ -207,9 +200,9 @@ DEFAULT_MODELS = {
# Fireworks models: ~$0.001-$0.004 per conversation
# GPT-4o: ~$0.015 per conversation
#
# Starter $12/mo, 1500 convos: max cost ~$6/mo (fireworks mix) → margin OK
# Business $29/mo, 5000 convos: max cost ~$15/mo (mixed models) → margin OK
# Agency $79/mo, 20000 convos: max cost ~$30/mo (fireworks) → healthy margin
# Starter $19/mo, 1500 convos: max cost ~$6/mo (fireworks mix) → margin OK
# Business $49/mo, 5000 convos: max cost ~$15/mo (mixed models) → margin OK
# Agency $99/mo, 20000 convos: max cost ~$30/mo (fireworks) → healthy margin
# ═══════════════════════════════════════════════════════════════════════════════
_ALL_FIREWORKS = [
@@ -229,52 +222,76 @@ PLAN_LIMITS = {
# Build, test, and go live with one chatbot — no card needed.
"free": {
"max_chatbots": 999999,
"max_published": 1, # can publish 1 chatbot
"max_published": 1, # can publish 1 chatbot
"max_documents_per_chatbot": 3,
"max_document_size_mb": 5,
"models": ["accounts/fireworks/models/llama-v3p3-70b-instruct"],
"conversations_limit": 100, # 100 real conversations/month
"conversations_limit": 100, # 100 real conversations/month
"code_export": False,
"analytics": False,
"channels": [], # no messaging channels
"gap_suggestions": False,
"channels": [], # no messaging channels
"url_sources": 0,
"leads_per_month": 0,
"show_branding": True, # cannot remove badge
"inbox_replies": False, # read-only inbox
"leads_editing": False, # view-only leads
"show_branding": True, # cannot remove badge
"appointments": False,
"appointments_chatbots": 0,
"campaigns": False,
"campaigns_per_month": 0,
"max_campaign_recipients": 0,
},
# ── Starter $12/mo ───────────────────────────────────────────────────────
# For individuals and solo businesses going live.
# ── Starter $19/mo ───────────────────────────────────────────────────────
# For solo operators: live chat, leads, booking, and campaigns.
"starter": {
"max_chatbots": 999999,
"max_published": 1,
"max_published": 3,
"max_documents_per_chatbot": 10,
"max_document_size_mb": 10,
"models": _ALL_FIREWORKS,
"conversations_limit": 1500,
"code_export": False,
"analytics": True,
"gap_suggestions": False,
"channels": ["telegram"],
"url_sources": 5,
"leads_per_month": 500,
"show_branding": True, # badge stays
"inbox_replies": True,
"leads_editing": True,
"show_branding": True, # badge stays on Starter
"appointments": True,
"appointments_chatbots": 1, # booking on 1 chatbot
"campaigns": True,
"campaigns_per_month": 3,
"max_campaign_recipients": 500,
},
# ── Business $29/mo ──────────────────────────────────────────────────────
# For growing businesses that need more chatbots and WhatsApp reach.
# ── Business $49/mo ──────────────────────────────────────────────────────
# For growing businesses: premium AI, unlimited booking, full analytics.
"business": {
"max_chatbots": 999999,
"max_published": 3,
"max_published": 10,
"max_documents_per_chatbot": 50,
"max_document_size_mb": 50,
"models": _ALL_FIREWORKS + _ALL_PREMIUM,
"conversations_limit": 5000,
"code_export": False,
"analytics": True,
"channels": ["telegram", "whatsapp"],
"gap_suggestions": True,
"channels": ["telegram"],
"url_sources": 999999,
"leads_per_month": 999999,
"show_branding": False, # can remove badge
"inbox_replies": True,
"leads_editing": True,
"show_branding": False, # can remove badge
"appointments": True,
"appointments_chatbots": 999999,
"campaigns": True,
"campaigns_per_month": 999999,
"max_campaign_recipients": 5000,
},
# ── Agency $79/mo ────────────────────────────────────────────────────────
# For agencies and large businesses managing many chatbots.
# ── Agency $99/mo ────────────────────────────────────────────────────────
# For agencies: unlimited everything, unlimited campaign recipients.
"agency": {
"max_chatbots": 999999,
"max_published": 999999,
@@ -284,10 +301,18 @@ PLAN_LIMITS = {
"conversations_limit": 20000,
"code_export": True,
"analytics": True,
"channels": ["telegram", "whatsapp"],
"gap_suggestions": True,
"channels": ["telegram"],
"url_sources": 999999,
"leads_per_month": 999999,
"inbox_replies": True,
"leads_editing": True,
"show_branding": False,
"appointments": True,
"appointments_chatbots": 999999,
"campaigns": True,
"campaigns_per_month": 999999,
"max_campaign_recipients": 999999,
},
# ── Enterprise (custom) ───────────────────────────────────────────────────
"enterprise": {
@@ -299,9 +324,17 @@ PLAN_LIMITS = {
"conversations_limit": 999999,
"code_export": True,
"analytics": True,
"channels": ["telegram", "whatsapp"],
"gap_suggestions": True,
"channels": ["telegram"],
"url_sources": 999999,
"leads_per_month": 999999,
"inbox_replies": True,
"leads_editing": True,
"show_branding": False,
"appointments": True,
"appointments_chatbots": 999999,
"campaigns": True,
"campaigns_per_month": 999999,
"max_campaign_recipients": 999999,
},
}

View File

@@ -29,7 +29,24 @@ async def get_current_user(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
return response.user
user = response.user
# Check for suspension
try:
profile = supabase.table("user_profiles").select("suspended_at").eq("user_id", user.id).execute()
if profile.data and profile.data[0].get("suspended_at"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account suspended. Please contact support.",
)
except HTTPException:
raise
except Exception:
pass # Don't block login if profile lookup fails
return user
except HTTPException:
raise
except Exception as e:
logger.error(f"Auth error: {e}")
raise HTTPException(
@@ -38,6 +55,29 @@ async def get_current_user(
)
async def get_admin_user(
current_user=Depends(get_current_user),
):
"""Require the current user to be an admin."""
supabase = get_supabase()
try:
profile = supabase.table("user_profiles").select("is_admin").eq("user_id", current_user.id).execute()
if not profile.data or not profile.data[0].get("is_admin"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Admin check failed: {e}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required",
)
return current_user
async def get_optional_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
):

34
app/logging_config.py Normal file
View File

@@ -0,0 +1,34 @@
import logging
import os
def configure_logging():
"""Configure structured JSON logging for the application."""
log_level = logging.DEBUG if os.getenv("APP_ENV", "development") == "development" else logging.INFO
try:
from pythonjsonlogger import jsonlogger
handler = logging.StreamHandler()
formatter = jsonlogger.JsonFormatter(
fmt="%(asctime)s %(levelname)s %(name)s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
rename_fields={"asctime": "timestamp", "levelname": "level", "name": "logger"},
)
handler.setFormatter(formatter)
except ImportError:
# Fallback to plain text if pythonjsonlogger not installed yet
handler = logging.StreamHandler()
handler.setFormatter(
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
)
root_logger = logging.getLogger()
root_logger.handlers.clear()
root_logger.addHandler(handler)
root_logger.setLevel(log_level)
# Silence noisy third-party loggers
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)

View File

@@ -4,17 +4,18 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response
import logging
from app.logging_config import configure_logging
configure_logging() # Must be called before any logger is created
from app.config import settings
from app.routers import auth, chatbots, documents, chat, marketplace, billing, models, analytics, inbox, leads, upload
from app.routers.documents import router_url_sources
from app.routers.leads import leads_public_router
from app.routers.channels import router as channels_router, webhook_router as channels_webhook_router
from app.routers import admin as admin_router
from app.routers.appointments import router as appointments_router, public_router as appointments_public_router
from app.routers.campaigns import router as campaigns_router
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger(__name__)
@@ -62,13 +63,28 @@ app.include_router(router_url_sources, prefix="/api/v1")
app.include_router(leads_public_router, prefix="/api/v1")
app.include_router(channels_router, prefix="/api/v1")
app.include_router(channels_webhook_router, prefix="/api/v1")
app.include_router(appointments_router, prefix="/api/v1")
app.include_router(appointments_public_router, prefix="/api/v1")
app.include_router(campaigns_router, prefix="/api/v1")
app.include_router(admin_router.router, prefix="/api/v1")
# ── Widget ─────────────────────────────────────────────────────────────────────
@app.get("/widget.js")
@app.get("/widget.js", include_in_schema=False)
async def serve_widget():
from app.services.widget import generate_widget_js
return Response(generate_widget_js(settings.app_url), media_type="application/javascript")
return Response(
content=generate_widget_js(settings.app_url),
media_type="application/javascript",
headers={
# Allow any site to load this script tag cross-origin
"Access-Control-Allow-Origin": "*",
# Cache for 1 hour in browsers / CDN; revalidate when stale
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
# Prevent MIME sniffing
"X-Content-Type-Options": "nosniff",
},
)
# ── Health & Info ──────────────────────────────────────────────────────────────
@@ -87,6 +103,15 @@ async def health():
return {"status": "healthy", "environment": settings.app_env}
# ── Prometheus Metrics ──────────────────────────────────────────────────────────
try:
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator().instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
logger.info("Prometheus metrics enabled at /metrics")
except ImportError:
logger.info("prometheus-fastapi-instrumentator not installed, metrics endpoint disabled")
# ── Sentry ─────────────────────────────────────────────────────────────────────
if settings.sentry_dsn:
import sentry_sdk

View File

@@ -1,8 +1,9 @@
from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, EmailStr, Field, field_validator
from typing import Optional, List, Dict, Any
from datetime import datetime
from enum import Enum
import uuid
import re
# ─── Enums ────────────────────────────────────────────────────────────────────
@@ -59,6 +60,7 @@ class UserResponse(BaseModel):
email: str
company_name: Optional[str] = None
plan: str = "free"
is_admin: bool = False
created_at: Optional[datetime] = None
@@ -100,6 +102,39 @@ class ChatbotCreate(BaseModel):
description: Optional[str] = None
system_prompt: Optional[str] = None
model: str = "accounts/fireworks/models/kimi-k2-instruct-0905"
@field_validator("name", mode="before")
@classmethod
def sanitize_name(cls, v: Any) -> Any:
if v:
v = str(v).strip()
if len(v) > 100:
raise ValueError("Name must be 100 characters or less")
return v
@field_validator("system_prompt", mode="before")
@classmethod
def sanitize_system_prompt(cls, v: Any) -> Any:
if v:
v = re.sub(r"<script[^>]*>.*?</script>", "", str(v), flags=re.DOTALL | re.IGNORECASE)
v = re.sub(r"javascript:", "", v, flags=re.IGNORECASE)
if len(v) > 10000:
raise ValueError("System prompt must be 10000 characters or less")
return v
@field_validator("description", mode="before")
@classmethod
def sanitize_description(cls, v: Any) -> Any:
if v and len(str(v)) > 2000:
raise ValueError("Description must be 2000 characters or less")
return v
@field_validator("welcome_message", mode="before")
@classmethod
def sanitize_welcome_message(cls, v: Any) -> Any:
if v and len(str(v)) > 500:
raise ValueError("Welcome message must be 500 characters or less")
return v
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
max_tokens: int = Field(default=1000, ge=100, le=8000)
primary_color: str = "#6366f1"
@@ -116,12 +151,46 @@ class ChatbotCreate(BaseModel):
handoff_message: str = "I'll connect you with our team. Please wait."
handoff_email: Optional[str] = None
handoff_keywords: List[str] = ["human", "agent", "speak to someone", "talk to a person", "real person"]
booking_enabled: bool = False
class ChatbotUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
system_prompt: Optional[str] = None
@field_validator("name", mode="before")
@classmethod
def sanitize_name(cls, v: Any) -> Any:
if v:
v = str(v).strip()
if len(v) > 100:
raise ValueError("Name must be 100 characters or less")
return v
@field_validator("system_prompt", mode="before")
@classmethod
def sanitize_system_prompt(cls, v: Any) -> Any:
if v:
v = re.sub(r"<script[^>]*>.*?</script>", "", str(v), flags=re.DOTALL | re.IGNORECASE)
v = re.sub(r"javascript:", "", v, flags=re.IGNORECASE)
if len(v) > 10000:
raise ValueError("System prompt must be 10000 characters or less")
return v
@field_validator("description", mode="before")
@classmethod
def sanitize_description(cls, v: Any) -> Any:
if v and len(str(v)) > 2000:
raise ValueError("Description must be 2000 characters or less")
return v
@field_validator("welcome_message", mode="before")
@classmethod
def sanitize_welcome_message(cls, v: Any) -> Any:
if v and len(str(v)) > 500:
raise ValueError("Welcome message must be 500 characters or less")
return v
model: Optional[str] = None
temperature: Optional[float] = None
max_tokens: Optional[int] = None
@@ -139,6 +208,7 @@ class ChatbotUpdate(BaseModel):
handoff_message: Optional[str] = None
handoff_email: Optional[str] = None
handoff_keywords: Optional[List[str]] = None
booking_enabled: Optional[bool] = None
class ChatbotResponse(BaseModel):
@@ -172,6 +242,7 @@ class ChatbotResponse(BaseModel):
handoff_message: str = "I'll connect you with our team. Please wait."
handoff_email: Optional[str] = None
handoff_keywords: List[str] = ["human", "agent", "speak to someone", "talk to a person", "real person"]
booking_enabled: bool = False
class ChatbotPublicResponse(BaseModel):
@@ -355,9 +426,16 @@ class LeadResponse(BaseModel):
name: Optional[str] = None
phone: Optional[str] = None
company: Optional[str] = None
status: str = "new"
notes: Optional[str] = None
created_at: Optional[datetime] = None
class LeadUpdate(BaseModel):
status: Optional[str] = None # new, contacted, qualified, closed, lost
notes: Optional[str] = None
# ─── URL Source Models ─────────────────────────────────────────────────────────
class UrlSourceCreate(BaseModel):
@@ -392,6 +470,8 @@ class InboxConversation(BaseModel):
language: str
message_count: int
first_message: Optional[str] = None
status: str = "open"
last_agent_reply_at: Optional[datetime] = None
created_at: Optional[datetime] = None
@@ -405,13 +485,148 @@ class InboxMessage(BaseModel):
created_at: Optional[datetime] = None
class ConversationStatusUpdate(BaseModel):
status: str # open, agent_handling, resolved
class AgentReplyCreate(BaseModel):
message: str = Field(min_length=1, max_length=4000)
# ─── Channel Models ────────────────────────────────────────────────────────────
class ChannelConnectionResponse(BaseModel):
id: str
channel: str
bot_username: Optional[str] = None
wa_keyword: Optional[str] = None
wa_link: Optional[str] = None
is_active: bool
created_at: Optional[datetime] = None
created_at: Optional[datetime] = None
# ─── Admin Models ──────────────────────────────────────────────────────────────
class AdminUserListItem(BaseModel):
id: str
email: str
company_name: Optional[str] = None
plan: str = "free"
subscription_status: str = "active"
chatbot_count: int = 0
conversations_count: int = 0
is_suspended: bool = False
is_admin: bool = False
created_at: Optional[datetime] = None
class AdminUserDetail(AdminUserListItem):
website: Optional[str] = None
industry: Optional[str] = None
chatbots: List[Dict[str, Any]] = []
class AdminChangePlanRequest(BaseModel):
plan: str
reason: Optional[str] = None
class AdminSuspendRequest(BaseModel):
suspend: bool
reason: Optional[str] = None
class AdminStatsResponse(BaseModel):
total_users: int
total_chatbots: int
total_published_chatbots: int
total_conversations: int
total_messages: int
active_subscriptions: Dict[str, int]
class AdminChatbotListItem(BaseModel):
id: str
name: str
owner_email: Optional[str] = None
company_name: Optional[str] = None
is_published: bool = False
document_count: int = 0
conversation_count: int = 0
created_at: Optional[datetime] = None
class AdminSystemHealth(BaseModel):
db: str
qdrant: str
llm_providers: Dict[str, bool]
timestamp: datetime
class AdminConversationListItem(BaseModel):
id: str
chatbot_name: Optional[str] = None
session_id: Optional[str] = None
language: Optional[str] = None
message_count: int = 0
created_at: Optional[datetime] = None
first_message: Optional[str] = None
# ─── Appointment Models ────────────────────────────────────────────────────────
class BusinessHoursEntry(BaseModel):
day_of_week: int = Field(ge=0, le=6) # 0=Mon, 6=Sun
is_open: bool = True
open_time: str = "09:00" # HH:MM
close_time: str = "17:00"
slot_duration_minutes: int = Field(default=60, ge=15, le=480)
class BusinessHoursSave(BaseModel):
hours: List[BusinessHoursEntry]
class AppointmentCreate(BaseModel):
customer_name: str = Field(min_length=1, max_length=200)
customer_contact: str = Field(min_length=1, max_length=200)
service: Optional[str] = None
slot_start: datetime
notes: Optional[str] = None
conversation_id: Optional[str] = None
class AppointmentResponse(BaseModel):
id: str
chatbot_id: str
conversation_id: Optional[str] = None
customer_name: str
customer_contact: str
service: Optional[str] = None
slot_start: datetime
slot_end: datetime
status: str
notes: Optional[str] = None
created_at: Optional[datetime] = None
class AppointmentStatusUpdate(BaseModel):
status: str # pending, confirmed, cancelled, completed
# ─── Campaign Models ───────────────────────────────────────────────────────────
class CampaignCreate(BaseModel):
chatbot_id: str
title: str = Field(min_length=1, max_length=200)
message: str = Field(min_length=1, max_length=4000)
class CampaignResponse(BaseModel):
id: str
chatbot_id: str
title: str
message: str
status: str
recipients_count: int
sent_count: int
created_at: Optional[datetime] = None
sent_at: Optional[datetime] = None

555
app/routers/admin.py Normal file
View File

@@ -0,0 +1,555 @@
"""
Admin router — all endpoints require is_admin = TRUE in user_profiles.
Bootstrap: after running migration 001, set your admin user in Supabase:
UPDATE user_profiles SET is_admin = TRUE WHERE user_id = '<your-uuid>';
"""
import logging
import time
from collections import defaultdict
from datetime import datetime
from typing import Optional, List, Dict
from fastapi import APIRouter, Depends, HTTPException, Query
from app.dependencies import get_admin_user
from app.database import get_supabase
from app.models import (
AdminStatsResponse, AdminUserListItem, AdminUserDetail,
AdminChangePlanRequest, AdminSuspendRequest, AdminChatbotListItem,
AdminSystemHealth, AdminConversationListItem, SuccessResponse,
)
from app.services.vector_store import vector_store
from app.services.storage import delete_from_storage
from app.config import settings
router = APIRouter(prefix="/admin", tags=["Admin"])
logger = logging.getLogger(__name__)
_app_start_time = time.time()
# ── Stats ──────────────────────────────────────────────────────────────────────
@router.get("/stats", response_model=AdminStatsResponse)
async def get_stats(admin=Depends(get_admin_user)):
"""Platform-wide statistics."""
supabase = get_supabase()
# Total users
try:
users_resp = supabase.table("user_profiles").select("user_id", count="exact").execute()
total_users = users_resp.count or 0
except Exception:
total_users = 0
# Total chatbots
try:
cb_resp = supabase.table("chatbots").select("id", count="exact").execute()
total_chatbots = cb_resp.count or 0
pub_resp = supabase.table("chatbots").select("id", count="exact").eq("is_published", True).execute()
total_published = pub_resp.count or 0
except Exception:
total_chatbots = 0
total_published = 0
# Total conversations
try:
conv_resp = supabase.table("conversations").select("id", count="exact").execute()
total_convos = conv_resp.count or 0
except Exception:
total_convos = 0
# Total messages
try:
msg_resp = supabase.table("messages").select("id", count="exact").execute()
total_messages = msg_resp.count or 0
except Exception:
total_messages = 0
# Active subscriptions by plan
active_subs: Dict[str, int] = defaultdict(int)
try:
subs_resp = supabase.table("subscriptions").select("plan, status").eq("status", "active").execute()
for s in (subs_resp.data or []):
active_subs[s["plan"]] += 1
except Exception:
pass
return AdminStatsResponse(
total_users=total_users,
total_chatbots=total_chatbots,
total_published_chatbots=total_published,
total_conversations=total_convos,
total_messages=total_messages,
active_subscriptions=dict(active_subs),
)
# ── Users ──────────────────────────────────────────────────────────────────────
@router.get("/users")
async def list_users(
admin=Depends(get_admin_user),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
search: Optional[str] = None,
plan: Optional[str] = None,
):
"""Paginated list of all users with company and subscription info."""
supabase = get_supabase()
offset = (page - 1) * limit
try:
# Fetch companies (contains owner_id and name)
companies_resp = supabase.table("companies").select("id, owner_id, name, website, industry").execute()
companies = {c["owner_id"]: c for c in (companies_resp.data or [])}
# Fetch subscriptions
subs_resp = supabase.table("subscriptions").select("user_id, plan, status").execute()
subs = {s["user_id"]: s for s in (subs_resp.data or [])}
# Fetch user profiles (suspension, admin flag)
profiles_resp = supabase.table("user_profiles").select("user_id, is_admin, suspended_at").execute()
profiles = {p["user_id"]: p for p in (profiles_resp.data or [])}
# Fetch auth users via admin API
try:
auth_users_resp = supabase.auth.admin.list_users()
auth_users = auth_users_resp if isinstance(auth_users_resp, list) else getattr(auth_users_resp, 'users', [])
except Exception as e:
logger.warning(f"Could not list auth users: {e}")
auth_users = []
# Fetch chatbot counts per company
cb_resp = supabase.table("chatbots").select("company_id").execute()
cb_by_company: Dict[str, int] = defaultdict(int)
for cb in (cb_resp.data or []):
cb_by_company[cb["company_id"]] += 1
# Fetch conversation counts per chatbot (to get per-user conv count)
chatbots_resp = supabase.table("chatbots").select("id, company_id").execute()
chatbot_company_map = {cb["id"]: cb["company_id"] for cb in (chatbots_resp.data or [])}
conv_resp = supabase.table("conversations").select("chatbot_id", count="exact").execute()
conv_by_chatbot: Dict[str, int] = defaultdict(int)
for conv in (conv_resp.data or []):
conv_by_chatbot[conv["chatbot_id"]] += 1
conv_by_company: Dict[str, int] = defaultdict(int)
for cb_id, count in conv_by_chatbot.items():
company_id = chatbot_company_map.get(cb_id)
if company_id:
conv_by_company[company_id] += count
# Build user list
users_list = []
for auth_user in auth_users:
uid = getattr(auth_user, "id", None) or auth_user.get("id", "")
email = getattr(auth_user, "email", None) or auth_user.get("email", "")
created_at = getattr(auth_user, "created_at", None) or auth_user.get("created_at")
# Apply filters
if search and search.lower() not in email.lower():
company_info = companies.get(uid, {})
if search.lower() not in (company_info.get("name") or "").lower():
continue
sub_info = subs.get(uid, {})
user_plan = sub_info.get("plan", "free")
if plan and user_plan != plan:
continue
company_info = companies.get(uid, {})
profile_info = profiles.get(uid, {})
users_list.append(AdminUserListItem(
id=uid,
email=email,
company_name=company_info.get("name"),
plan=user_plan,
subscription_status=sub_info.get("status", "active"),
chatbot_count=cb_by_company.get(company_info.get("id", ""), 0),
conversations_count=conv_by_company.get(company_info.get("id", ""), 0),
is_suspended=bool(profile_info.get("suspended_at")),
is_admin=bool(profile_info.get("is_admin", False)),
created_at=created_at,
))
total = len(users_list)
paginated = users_list[offset:offset + limit]
return {
"users": [u.model_dump() for u in paginated],
"total": total,
"page": page,
"pages": max(1, (total + limit - 1) // limit),
}
except Exception as e:
logger.error(f"Admin list_users error: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch users")
@router.get("/users/{user_id}", response_model=AdminUserDetail)
async def get_user(user_id: str, admin=Depends(get_admin_user)):
"""Detailed info about a specific user."""
supabase = get_supabase()
try:
auth_user = supabase.auth.admin.get_user_by_id(user_id)
auth_u = getattr(auth_user, "user", auth_user)
email = getattr(auth_u, "email", "") or auth_u.get("email", "")
created_at = getattr(auth_u, "created_at", None) or auth_u.get("created_at")
except Exception:
email = ""
created_at = None
company = supabase.table("companies").select("*").eq("owner_id", user_id).execute()
company_info = company.data[0] if company.data else {}
sub = supabase.table("subscriptions").select("plan, status").eq("user_id", user_id).execute()
sub_info = sub.data[0] if sub.data else {}
profile = supabase.table("user_profiles").select("is_admin, suspended_at").eq("user_id", user_id).execute()
profile_info = profile.data[0] if profile.data else {}
chatbots = []
if company_info.get("id"):
cb_resp = supabase.table("chatbots").select("id, name, is_published, created_at") \
.eq("company_id", company_info["id"]).execute()
chatbots = cb_resp.data or []
return AdminUserDetail(
id=user_id,
email=email,
company_name=company_info.get("name"),
website=company_info.get("website"),
industry=company_info.get("industry"),
plan=sub_info.get("plan", "free"),
subscription_status=sub_info.get("status", "active"),
chatbot_count=len(chatbots),
conversations_count=0,
is_suspended=bool(profile_info.get("suspended_at")),
is_admin=bool(profile_info.get("is_admin", False)),
created_at=created_at,
chatbots=chatbots,
)
@router.patch("/users/{user_id}/plan")
async def change_user_plan(user_id: str, data: AdminChangePlanRequest, admin=Depends(get_admin_user)):
"""Manually grant or change a user's subscription plan."""
valid_plans = ["free", "starter", "business", "agency", "enterprise"]
if data.plan not in valid_plans:
raise HTTPException(status_code=400, detail=f"Invalid plan. Must be one of: {valid_plans}")
supabase = get_supabase()
try:
supabase.table("subscriptions").upsert({
"user_id": user_id,
"plan": data.plan,
"status": "active",
}, on_conflict="user_id").execute()
except Exception as e:
logger.error(f"Failed to change plan for {user_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to update plan")
logger.info(f"Admin {admin.id} changed plan for user {user_id} to {data.plan}. Reason: {data.reason}")
return {"message": f"Plan updated to {data.plan}", "user_id": user_id, "plan": data.plan}
@router.post("/users/{user_id}/suspend")
async def suspend_user(user_id: str, data: AdminSuspendRequest, admin=Depends(get_admin_user)):
"""Suspend or unsuspend a user account."""
supabase = get_supabase()
update_data: dict = {"updated_at": datetime.utcnow().isoformat()}
if data.suspend:
update_data["suspended_at"] = datetime.utcnow().isoformat()
if data.reason:
update_data["suspended_reason"] = data.reason
action = "suspended"
else:
update_data["suspended_at"] = None
update_data["suspended_reason"] = None
action = "unsuspended"
try:
supabase.table("user_profiles").upsert(
{"user_id": user_id, **update_data},
on_conflict="user_id"
).execute()
except Exception as e:
logger.error(f"Failed to {action} user {user_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to {action} user")
logger.info(f"Admin {admin.id} {action} user {user_id}")
return {"message": f"User {action}", "user_id": user_id}
@router.delete("/users/{user_id}", response_model=SuccessResponse)
async def delete_user(user_id: str, admin=Depends(get_admin_user)):
"""Permanently delete a user and all their data."""
supabase = get_supabase()
# 1. Get company
company = supabase.table("companies").select("id").eq("owner_id", user_id).execute()
company_id = company.data[0]["id"] if company.data else None
if company_id:
# 2. Get all chatbots and clean up Qdrant + storage
chatbots = supabase.table("chatbots").select("id, qdrant_collection_name, logo_url") \
.eq("company_id", company_id).execute()
for cb in (chatbots.data or []):
if cb.get("qdrant_collection_name"):
try:
vector_store.delete_collection(cb["qdrant_collection_name"])
except Exception as e:
logger.warning(f"Failed to delete Qdrant collection for chatbot {cb['id']}: {e}")
if cb.get("logo_url"):
delete_from_storage(supabase, "logos", cb["logo_url"])
# 3. Delete documents from storage
docs = supabase.table("documents").select("file_url") \
.in_("chatbot_id", [cb["id"] for cb in (chatbots.data or [])]).execute()
for doc in (docs.data or []):
if doc.get("file_url"):
delete_from_storage(supabase, "documents", doc["file_url"])
# 4. Delete company (cascades to chatbots, documents, conversations)
supabase.table("companies").delete().eq("id", company_id).execute()
# 5. Delete subscription
supabase.table("subscriptions").delete().eq("user_id", user_id).execute()
# 6. Delete user profile
supabase.table("user_profiles").delete().eq("user_id", user_id).execute()
# 7. Delete auth user
try:
supabase.auth.admin.delete_user(user_id)
except Exception as e:
logger.error(f"Failed to delete auth user {user_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to delete auth user")
logger.info(f"Admin {admin.id} deleted user {user_id}")
return SuccessResponse(success=True, message="User deleted successfully")
# ── Chatbots ───────────────────────────────────────────────────────────────────
@router.get("/chatbots")
async def list_all_chatbots(
admin=Depends(get_admin_user),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
search: Optional[str] = None,
):
"""Paginated list of ALL chatbots across all users."""
supabase = get_supabase()
offset = (page - 1) * limit
try:
# Get chatbots with company info
q = supabase.table("chatbots").select("*, companies(name, owner_id)") \
.order("created_at", desc=True)
result = q.execute()
all_chatbots = result.data or []
# Apply search filter
if search:
s = search.lower()
all_chatbots = [
cb for cb in all_chatbots
if s in (cb.get("name") or "").lower()
or s in (cb.get("companies", {}) or {}).get("name", "").lower()
]
total = len(all_chatbots)
paginated = all_chatbots[offset:offset + limit]
# Get owner emails for paginated set
owner_ids = list({(cb.get("companies") or {}).get("owner_id") for cb in paginated if cb.get("companies")})
owner_emails: Dict[str, str] = {}
if owner_ids:
try:
for oid in owner_ids:
try:
u = supabase.auth.admin.get_user_by_id(oid)
u_obj = getattr(u, "user", u)
owner_emails[oid] = getattr(u_obj, "email", "") or u_obj.get("email", "")
except Exception:
pass
except Exception:
pass
# Get doc and conv counts for paginated chatbots
cb_ids = [cb["id"] for cb in paginated]
doc_counts: Dict[str, int] = defaultdict(int)
conv_counts: Dict[str, int] = defaultdict(int)
if cb_ids:
docs_resp = supabase.table("documents").select("chatbot_id").in_("chatbot_id", cb_ids).execute()
for d in (docs_resp.data or []):
doc_counts[d["chatbot_id"]] += 1
convs_resp = supabase.table("conversations").select("chatbot_id").in_("chatbot_id", cb_ids).execute()
for c in (convs_resp.data or []):
conv_counts[c["chatbot_id"]] += 1
items = []
for cb in paginated:
company_info = cb.get("companies") or {}
owner_id = company_info.get("owner_id")
items.append(AdminChatbotListItem(
id=cb["id"],
name=cb.get("name", ""),
owner_email=owner_emails.get(owner_id),
company_name=company_info.get("name"),
is_published=cb.get("is_published", False),
document_count=doc_counts[cb["id"]],
conversation_count=conv_counts[cb["id"]],
created_at=cb.get("created_at"),
))
return {
"chatbots": [i.model_dump() for i in items],
"total": total,
"page": page,
"pages": max(1, (total + limit - 1) // limit),
}
except Exception as e:
logger.error(f"Admin list_chatbots error: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch chatbots")
@router.delete("/chatbots/{chatbot_id}", response_model=SuccessResponse)
async def delete_chatbot_admin(chatbot_id: str, admin=Depends(get_admin_user)):
"""Force-delete any chatbot regardless of ownership."""
supabase = get_supabase()
cb = supabase.table("chatbots").select("*").eq("id", chatbot_id).execute()
if not cb.data:
raise HTTPException(status_code=404, detail="Chatbot not found")
chatbot = cb.data[0]
# Delete Qdrant collection
if chatbot.get("qdrant_collection_name"):
try:
vector_store.delete_collection(chatbot["qdrant_collection_name"])
except Exception as e:
logger.warning(f"Failed to delete Qdrant collection: {e}")
# Delete logo from storage
if chatbot.get("logo_url"):
delete_from_storage(supabase, "logos", chatbot["logo_url"])
supabase.table("chatbots").delete().eq("id", chatbot_id).execute()
logger.info(f"Admin {admin.id} deleted chatbot {chatbot_id}")
return SuccessResponse(success=True, message="Chatbot deleted")
# ── Conversations ──────────────────────────────────────────────────────────────
@router.get("/conversations")
async def list_conversations(
admin=Depends(get_admin_user),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
):
"""Recent conversations across all chatbots."""
supabase = get_supabase()
offset = (page - 1) * limit
try:
result = supabase.table("conversations") \
.select("*, chatbots(name)") \
.order("created_at", desc=True) \
.range(offset, offset + limit - 1) \
.execute()
convos = result.data or []
# Get first message for each conversation
conv_ids = [c["id"] for c in convos]
first_msgs: Dict[str, str] = {}
if conv_ids:
msgs_resp = supabase.table("messages") \
.select("conversation_id, content, role") \
.in_("conversation_id", conv_ids) \
.eq("role", "user") \
.order("created_at", desc=False) \
.execute()
seen = set()
for m in (msgs_resp.data or []):
cid = m["conversation_id"]
if cid not in seen:
seen.add(cid)
first_msgs[cid] = (m.get("content") or "")[:120]
# Total count
count_resp = supabase.table("conversations").select("id", count="exact").execute()
total = count_resp.count or 0
items = [
AdminConversationListItem(
id=c["id"],
chatbot_name=(c.get("chatbots") or {}).get("name"),
session_id=c.get("session_id"),
language=c.get("language"),
message_count=c.get("message_count", 0),
created_at=c.get("created_at"),
first_message=first_msgs.get(c["id"]),
)
for c in convos
]
return {
"conversations": [i.model_dump() for i in items],
"total": total,
"page": page,
"pages": max(1, (total + limit - 1) // limit),
}
except Exception as e:
logger.error(f"Admin list_conversations error: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch conversations")
# ── System Health ──────────────────────────────────────────────────────────────
@router.get("/system/health", response_model=AdminSystemHealth)
async def system_health(admin=Depends(get_admin_user)):
"""Check health of all system components."""
# Check database
db_status = "unhealthy"
try:
supabase = get_supabase()
supabase.table("subscriptions").select("id").limit(1).execute()
db_status = "healthy"
except Exception as e:
logger.warning(f"DB health check failed: {e}")
# Check Qdrant
qdrant_status = "unhealthy"
try:
vector_store.client.get_collections()
qdrant_status = "healthy"
except Exception as e:
logger.warning(f"Qdrant health check failed: {e}")
# Check LLM provider API key availability
llm_providers = {
"openai": bool(getattr(settings, "openai_api_key", None)),
"anthropic": bool(getattr(settings, "anthropic_api_key", None)),
"google": bool(getattr(settings, "google_api_key", None)),
"fireworks": bool(getattr(settings, "fireworks_api_key", None)),
}
return AdminSystemHealth(
db=db_status,
qdrant=qdrant_status,
llm_providers=llm_providers,
timestamp=datetime.utcnow(),
)

View File

@@ -9,6 +9,7 @@ from app.database import get_supabase
from app.dependencies import get_current_user
from app.config import PLAN_LIMITS
from typing import List, Optional, Dict
from collections import defaultdict
from pydantic import BaseModel
from datetime import datetime, timedelta
import logging
@@ -127,14 +128,7 @@ async def get_analytics_overview(user=Depends(get_current_user)):
conversations_used=0,
)
# Gather per-chatbot analytics
chatbot_analytics = []
total_convos = 0
total_msgs = 0
total_sessions = 0
month_convos = 0
all_ratings = []
# ── Batch queries (fixes N+1) ────────────────────────────────────────────────
now = datetime.utcnow()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
week_start = now - timedelta(days=now.weekday())
@@ -142,14 +136,60 @@ async def get_analytics_overview(user=Depends(get_current_user)):
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
thirty_days_ago = now - timedelta(days=30)
# Batch query 1: ALL conversations for all chatbots (single query)
all_convos_resp = supabase.table("conversations") \
.select("id, chatbot_id, session_id, language, created_at") \
.in_("chatbot_id", chatbot_ids) \
.execute()
all_convos = all_convos_resp.data or []
all_conv_ids = [c["id"] for c in all_convos]
# Batch query 2: ALL messages for all conversations (single query)
all_msgs: List[Dict] = []
if all_conv_ids:
# Split into chunks of 500 to avoid URL length limits
for i in range(0, len(all_conv_ids), 500):
chunk = all_conv_ids[i:i + 500]
msgs_resp = supabase.table("messages") \
.select("id, conversation_id, role, content, created_at") \
.in_("conversation_id", chunk) \
.execute()
all_msgs.extend(msgs_resp.data or [])
# Batch query 3: ALL feedback for all chatbots (single query)
all_feedback: List[Dict] = []
if chatbot_ids:
fb_resp = supabase.table("message_feedback") \
.select("chatbot_id, feedback") \
.in_("chatbot_id", chatbot_ids) \
.execute()
all_feedback = fb_resp.data or []
# Index data by chatbot_id for O(1) lookups
convos_by_chatbot: Dict[str, List[Dict]] = defaultdict(list)
for c in all_convos:
convos_by_chatbot[c["chatbot_id"]].append(c)
msgs_by_conv: Dict[str, List[Dict]] = defaultdict(list)
for m in all_msgs:
msgs_by_conv[m["conversation_id"]].append(m)
fb_by_chatbot: Dict[str, List[Dict]] = defaultdict(list)
for f in all_feedback:
fb_by_chatbot[f["chatbot_id"]].append(f)
# ── Aggregate per chatbot ────────────────────────────────────────────────────
chatbot_analytics = []
total_convos = 0
total_msgs = 0
total_sessions = 0
month_convos = 0
all_ratings = []
for chatbot in chatbot_list:
cid = chatbot["id"]
# Total conversations
convos = supabase.table("conversations").select("id, session_id, language, created_at", count="exact") \
.eq("chatbot_id", cid).execute()
conv_count = convos.count or 0
conv_data = convos.data or []
conv_data = convos_by_chatbot[cid]
conv_count = len(conv_data)
total_convos += conv_count
# Unique sessions
@@ -157,34 +197,35 @@ async def get_analytics_overview(user=Depends(get_current_user)):
unique_sess = len(sessions)
total_sessions += unique_sess
# Total messages
msgs = supabase.table("messages").select("id", count="exact") \
.in_("conversation_id", [c["id"] for c in conv_data] if conv_data else [""]).execute()
msg_count = msgs.count or 0
# Messages for this chatbot
chatbot_msgs = []
for c in conv_data:
chatbot_msgs.extend(msgs_by_conv[c["id"]])
msg_count = len(chatbot_msgs)
total_msgs += msg_count
# Time-based conversation counts
today_count = sum(1 for c in conv_data if c.get("created_at") and c["created_at"][:10] == today_start.strftime("%Y-%m-%d"))
today_str = today_start.strftime("%Y-%m-%d")
today_count = sum(1 for c in conv_data if c.get("created_at") and c["created_at"][:10] == today_str)
week_count = sum(1 for c in conv_data if c.get("created_at") and c["created_at"] >= week_start.isoformat())
month_count = sum(1 for c in conv_data if c.get("created_at") and c["created_at"] >= month_start.isoformat())
month_convos += month_count
# Daily conversations (last 30 days)
daily = {}
daily: Dict[str, int] = {}
for c in conv_data:
if c.get("created_at") and c["created_at"] >= thirty_days_ago.isoformat():
day = c["created_at"][:10]
daily[day] = daily.get(day, 0) + 1
daily_list = [DailyConversations(date=d, count=n) for d, n in sorted(daily.items())]
# Languages used
# Languages
lang_counts: Dict[str, int] = {}
for c in conv_data:
lang = c.get("language", "en")
lang_counts[lang] = lang_counts.get(lang, 0) + 1
# Peak hour (approximate from created_at)
# Peak hour
hour_counts: Dict[int, int] = {}
for c in conv_data:
if c.get("created_at") and len(c["created_at"]) > 13:
@@ -195,35 +236,25 @@ async def get_analytics_overview(user=Depends(get_current_user)):
pass
peak = max(hour_counts, key=hour_counts.get) if hour_counts else None
# Top queries (from user messages, get first message per conversation)
top_queries: List[TopQuery] = []
if conv_data:
conv_ids = [c["id"] for c in conv_data[:100]] # limit to recent 100
user_msgs = supabase.table("messages").select("content") \
.in_("conversation_id", conv_ids) \
.eq("role", "user") \
.limit(200).execute()
query_counts: Dict[str, int] = {}
for m in (user_msgs.data or []):
# Top queries from user messages
query_counts: Dict[str, int] = {}
for m in chatbot_msgs:
if m.get("role") == "user":
content = (m.get("content") or "")[:100].strip()
if content:
query_counts[content] = query_counts.get(content, 0) + 1
top_sorted = sorted(query_counts.items(), key=lambda x: -x[1])[:5]
top_queries = [TopQuery(query=q, count=n) for q, n in top_sorted]
top_queries = [TopQuery(query=q, count=n) for q, n in sorted(query_counts.items(), key=lambda x: -x[1])[:5]]
# Rating
rating = chatbot.get("average_rating")
if rating:
all_ratings.append(rating)
# Feedback counts
fb_result = supabase.table("message_feedback").select("feedback", count="exact") \
.eq("chatbot_id", cid).execute()
total_fb = fb_result.count or 0
fb_pos = sum(1 for f in (fb_result.data or []) if f.get("feedback") == "positive")
fb_neg = total_fb - fb_pos
# Feedback
chatbot_fb = fb_by_chatbot[cid]
fb_pos = sum(1 for f in chatbot_fb if f.get("feedback") == "positive")
fb_neg = len(chatbot_fb) - fb_pos
# Average messages per conversation
avg_msgs = round(msg_count / conv_count, 1) if conv_count > 0 else 0.0
chatbot_analytics.append(ChatbotAnalyticsResponse(
@@ -234,7 +265,7 @@ async def get_analytics_overview(user=Depends(get_current_user)):
total_messages=msg_count,
average_messages_per_conversation=avg_msgs,
average_rating=rating,
total_ratings=total_fb,
total_ratings=len(chatbot_fb),
conversations_today=today_count,
conversations_this_week=week_count,
conversations_this_month=month_count,

287
app/routers/appointments.py Normal file
View File

@@ -0,0 +1,287 @@
from fastapi import APIRouter, HTTPException, Depends, Query
from app.database import get_supabase
from app.dependencies import get_current_user
from app.config import PLAN_LIMITS
from app.models import (
AppointmentCreate, AppointmentResponse, AppointmentStatusUpdate,
BusinessHoursEntry, BusinessHoursSave,
)
from typing import List, Optional
from datetime import datetime, timedelta, date
import uuid
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/appointments", tags=["Appointments"])
public_router = APIRouter(tags=["Appointments"])
# ── Helpers ───────────────────────────────────────────────────────────────────
def _check_booking_access(user_id: str, supabase):
sub = supabase.table("subscriptions").select("plan").eq("user_id", user_id).eq("status", "active").execute()
plan = sub.data[0]["plan"] if sub.data else "free"
if plan not in ("starter", "business", "agency", "enterprise"):
raise HTTPException(status_code=402, detail="Appointment booking requires Starter plan or higher")
return plan
def _get_user_company_id(user_id: str, supabase) -> str:
result = supabase.table("companies").select("id").eq("owner_id", user_id).execute()
if not result.data:
raise HTTPException(status_code=404, detail="Company not found")
return result.data[0]["id"]
def _verify_chatbot_ownership(chatbot_id: str, company_id: str, supabase):
chatbot = supabase.table("chatbots").select("id").eq("id", chatbot_id).eq("company_id", company_id).execute()
if not chatbot.data:
raise HTTPException(status_code=404, detail="Chatbot not found")
def _parse_time(t: str) -> tuple[int, int]:
"""Parse HH:MM into (hour, minute)."""
h, m = t.split(":")
return int(h), int(m)
def _get_available_slots(chatbot_id: str, target_date: date, supabase) -> List[dict]:
"""Return list of available {slot_start, slot_end} dicts for the given date."""
day_of_week = target_date.weekday() # 0=Mon
hours = supabase.table("business_hours") \
.select("*").eq("chatbot_id", chatbot_id).eq("day_of_week", day_of_week).execute()
if not hours.data or not hours.data[0].get("is_open"):
return []
h = hours.data[0]
open_h, open_m = _parse_time(h["open_time"])
close_h, close_m = _parse_time(h["close_time"])
duration = h.get("slot_duration_minutes", 60)
slot_start = datetime(target_date.year, target_date.month, target_date.day, open_h, open_m)
slot_end_limit = datetime(target_date.year, target_date.month, target_date.day, close_h, close_m)
# Fetch already-booked slots for that day
day_start = datetime(target_date.year, target_date.month, target_date.day, 0, 0)
day_end = day_start + timedelta(days=1)
booked = supabase.table("appointments") \
.select("slot_start, slot_end") \
.eq("chatbot_id", chatbot_id) \
.neq("status", "cancelled") \
.gte("slot_start", day_start.isoformat()) \
.lt("slot_start", day_end.isoformat()) \
.execute()
booked_starts = {b["slot_start"] for b in (booked.data or [])}
slots = []
now = datetime.utcnow()
while slot_start + timedelta(minutes=duration) <= slot_end_limit:
slot_end = slot_start + timedelta(minutes=duration)
# Skip past slots
if slot_start > now:
iso_start = slot_start.isoformat()
if iso_start not in booked_starts:
slots.append({"slot_start": iso_start, "slot_end": slot_end.isoformat()})
slot_start = slot_end
return slots
# ── Protected endpoints (business owner) ─────────────────────────────────────
@router.get("", response_model=List[AppointmentResponse])
async def list_appointments(
chatbot_id: Optional[str] = Query(None),
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=200),
user=Depends(get_current_user),
):
supabase = get_supabase()
_check_booking_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
chatbots_q = supabase.table("chatbots").select("id").eq("company_id", company_id)
if chatbot_id:
chatbots_q = chatbots_q.eq("id", chatbot_id)
chatbots = chatbots_q.execute()
chatbot_ids = [c["id"] for c in (chatbots.data or [])]
if not chatbot_ids:
return []
offset = (page - 1) * limit
q = supabase.table("appointments").select("*").in_("chatbot_id", chatbot_ids)
if status:
q = q.eq("status", status)
result = q.order("slot_start", desc=False).range(offset, offset + limit - 1).execute()
return [AppointmentResponse(**a) for a in (result.data or [])]
@router.patch("/{appointment_id}", response_model=AppointmentResponse)
async def update_appointment_status(
appointment_id: str,
data: AppointmentStatusUpdate,
user=Depends(get_current_user),
):
valid = ("pending", "confirmed", "cancelled", "completed")
if data.status not in valid:
raise HTTPException(status_code=400, detail=f"Status must be one of {valid}")
supabase = get_supabase()
_check_booking_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
appt = supabase.table("appointments").select("*, chatbots(company_id)") \
.eq("id", appointment_id).execute()
if not appt.data:
raise HTTPException(status_code=404, detail="Appointment not found")
if appt.data[0].get("chatbots", {}).get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
result = supabase.table("appointments").update({"status": data.status}) \
.eq("id", appointment_id).execute()
return AppointmentResponse(**result.data[0])
@router.get("/chatbot/{chatbot_id}/hours")
async def get_business_hours(chatbot_id: str, user=Depends(get_current_user)):
supabase = get_supabase()
_check_booking_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
_verify_chatbot_ownership(chatbot_id, company_id, supabase)
result = supabase.table("business_hours").select("*") \
.eq("chatbot_id", chatbot_id).order("day_of_week").execute()
return result.data or []
@router.put("/chatbot/{chatbot_id}/hours")
async def save_business_hours(
chatbot_id: str,
data: BusinessHoursSave,
user=Depends(get_current_user),
):
supabase = get_supabase()
_check_booking_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
_verify_chatbot_ownership(chatbot_id, company_id, supabase)
# Upsert each day
for entry in data.hours:
existing = supabase.table("business_hours").select("id") \
.eq("chatbot_id", chatbot_id).eq("day_of_week", entry.day_of_week).execute()
row = {
"chatbot_id": chatbot_id,
"day_of_week": entry.day_of_week,
"is_open": entry.is_open,
"open_time": entry.open_time,
"close_time": entry.close_time,
"slot_duration_minutes": entry.slot_duration_minutes,
}
if existing.data:
supabase.table("business_hours").update(row).eq("id", existing.data[0]["id"]).execute()
else:
row["id"] = str(uuid.uuid4())
supabase.table("business_hours").insert(row).execute()
return {"success": True}
# ── Public endpoints (customers booking) ─────────────────────────────────────
@public_router.get("/chatbots/{chatbot_id}/booking-info")
async def get_booking_info(chatbot_id: str):
"""Return public booking info for the booking page (no auth required)."""
supabase = get_supabase()
result = supabase.table("chatbots") \
.select("id, name, booking_enabled, companies(name)") \
.eq("id", chatbot_id).execute()
if not result.data:
raise HTTPException(status_code=404, detail="Chatbot not found")
chatbot = result.data[0]
if not chatbot.get("booking_enabled"):
raise HTTPException(status_code=400, detail="Booking is not enabled for this chatbot")
return {
"chatbot_id": chatbot["id"],
"chatbot_name": chatbot.get("name", ""),
"company_name": (chatbot.get("companies") or {}).get("name", ""),
}
@public_router.get("/chatbots/{chatbot_id}/available-slots")
async def get_available_slots(
chatbot_id: str,
date: str = Query(..., description="YYYY-MM-DD"),
):
"""Return available time slots for a given date (public)."""
supabase = get_supabase()
chatbot = supabase.table("chatbots").select("id, booking_enabled, is_published") \
.eq("id", chatbot_id).execute()
if not chatbot.data:
raise HTTPException(status_code=404, detail="Chatbot not found")
if not chatbot.data[0].get("booking_enabled"):
raise HTTPException(status_code=400, detail="Booking not enabled for this chatbot")
try:
target = datetime.strptime(date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format, use YYYY-MM-DD")
slots = _get_available_slots(chatbot_id, target, supabase)
return {"date": date, "slots": slots}
@public_router.post("/chatbots/{chatbot_id}/appointments", response_model=AppointmentResponse, status_code=201)
async def create_appointment(chatbot_id: str, data: AppointmentCreate):
"""Create an appointment (public endpoint, no auth required)."""
supabase = get_supabase()
chatbot = supabase.table("chatbots").select("id, booking_enabled") \
.eq("id", chatbot_id).execute()
if not chatbot.data:
raise HTTPException(status_code=404, detail="Chatbot not found")
if not chatbot.data[0].get("booking_enabled"):
raise HTTPException(status_code=400, detail="Booking not enabled for this chatbot")
# Verify the slot is still available
slot_start_dt = data.slot_start
target_date = slot_start_dt.date()
available = _get_available_slots(chatbot_id, target_date, supabase)
available_starts = {s["slot_start"] for s in available}
slot_iso = slot_start_dt.isoformat()
# Try a few normalizations (with/without timezone suffix)
if slot_iso not in available_starts and slot_iso + "Z" not in available_starts:
# Check without microseconds
slot_iso_no_ms = slot_start_dt.replace(microsecond=0).isoformat()
if slot_iso_no_ms not in available_starts:
raise HTTPException(status_code=409, detail="This slot is no longer available")
# Calculate slot_end based on business hours duration
hours = supabase.table("business_hours").select("slot_duration_minutes") \
.eq("chatbot_id", chatbot_id).eq("day_of_week", target_date.weekday()).execute()
duration = hours.data[0]["slot_duration_minutes"] if hours.data else 60
slot_end_dt = slot_start_dt + timedelta(minutes=duration)
appt_data = {
"id": str(uuid.uuid4()),
"chatbot_id": chatbot_id,
"conversation_id": data.conversation_id,
"customer_name": data.customer_name,
"customer_contact": data.customer_contact,
"service": data.service,
"slot_start": data.slot_start.isoformat(),
"slot_end": slot_end_dt.isoformat(),
"status": "pending",
"notes": data.notes,
}
result = supabase.table("appointments").insert(appt_data).execute()
if not result.data:
raise HTTPException(status_code=500, detail="Failed to create appointment")
return AppointmentResponse(**result.data[0])

View File

@@ -58,6 +58,12 @@ async def signup(data: UserSignup):
}
).execute()
# Safety-net: ensure user_profiles row exists (trigger should handle it, but just in case)
try:
supabase.table("user_profiles").insert({"user_id": user.id}).execute()
except Exception:
pass # Row may already exist from trigger
token = auth_resp.session.access_token if auth_resp.session else ""
return TokenResponse(
access_token=token,
@@ -66,6 +72,7 @@ async def signup(data: UserSignup):
email=user.email,
company_name=data.company_name,
plan="free",
is_admin=False,
),
)
except HTTPException:
@@ -109,6 +116,13 @@ async def login(data: UserLogin):
)
plan = sub.data[0]["plan"] if sub.data else "free"
# Get is_admin flag
try:
profile = supabase.table("user_profiles").select("is_admin").eq("user_id", user.id).execute()
is_admin = profile.data[0].get("is_admin", False) if profile.data else False
except Exception:
is_admin = False
return TokenResponse(
access_token=auth_resp.session.access_token,
user=UserResponse(
@@ -116,6 +130,7 @@ async def login(data: UserLogin):
email=user.email,
company_name=company_name,
plan=plan,
is_admin=is_admin,
),
)
except HTTPException:
@@ -230,9 +245,16 @@ async def get_me(user=Depends(get_current_user)):
)
plan = sub.data[0]["plan"] if sub.data else "free"
try:
profile = supabase.table("user_profiles").select("is_admin").eq("user_id", user.id).execute()
is_admin = profile.data[0].get("is_admin", False) if profile.data else False
except Exception:
is_admin = False
return UserResponse(
id=user.id,
email=user.email,
company_name=company_name,
plan=plan,
is_admin=is_admin,
)

View File

@@ -89,6 +89,17 @@ async def stripe_webhook(
supabase = get_supabase()
event_type = event.get("type", "")
event_id = event.get("id", "")
# Idempotency check: skip already-processed events
if event_id:
existing = supabase.table("stripe_webhook_events") \
.select("stripe_event_id") \
.eq("stripe_event_id", event_id) \
.execute()
if existing.data:
logger.info(f"Stripe event {event_id} already processed, skipping")
return {"received": True}
if event_type == "checkout.session.completed":
session = event["data"]["object"]
@@ -140,6 +151,16 @@ async def stripe_webhook(
except Exception as e:
logger.warning(f"Failed to send cancellation notification: {e}")
# Record event as processed
if event_id:
try:
supabase.table("stripe_webhook_events").insert({
"stripe_event_id": event_id,
"event_type": event_type,
}).execute()
except Exception as e:
logger.warning(f"Failed to record stripe event {event_id}: {e}")
return {"received": True}
except HTTPException:

167
app/routers/campaigns.py Normal file
View File

@@ -0,0 +1,167 @@
from fastapi import APIRouter, HTTPException, Depends, Query
from app.database import get_supabase
from app.dependencies import get_current_user
from app.models import CampaignCreate, CampaignResponse
from typing import List, Optional
import uuid
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/campaigns", tags=["Campaigns"])
# ── Helpers ───────────────────────────────────────────────────────────────────
def _check_campaigns_access(user_id: str, supabase):
sub = supabase.table("subscriptions").select("plan").eq("user_id", user_id).eq("status", "active").execute()
plan = sub.data[0]["plan"] if sub.data else "free"
if plan not in ("starter", "business", "agency", "enterprise"):
raise HTTPException(status_code=402, detail="Campaigns require Starter plan or higher")
return plan
def _get_user_company_id(user_id: str, supabase) -> str:
result = supabase.table("companies").select("id").eq("owner_id", user_id).execute()
if not result.data:
raise HTTPException(status_code=404, detail="Company not found")
return result.data[0]["id"]
def _verify_chatbot_ownership(chatbot_id: str, company_id: str, supabase):
chatbot = supabase.table("chatbots").select("id").eq("id", chatbot_id).eq("company_id", company_id).execute()
if not chatbot.data:
raise HTTPException(status_code=404, detail="Chatbot not found")
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("", response_model=List[CampaignResponse])
async def list_campaigns(
chatbot_id: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
user=Depends(get_current_user),
):
supabase = get_supabase()
_check_campaigns_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
chatbots_q = supabase.table("chatbots").select("id").eq("company_id", company_id)
if chatbot_id:
chatbots_q = chatbots_q.eq("id", chatbot_id)
chatbots = chatbots_q.execute()
chatbot_ids = [c["id"] for c in (chatbots.data or [])]
if not chatbot_ids:
return []
offset = (page - 1) * limit
result = supabase.table("campaigns").select("*").in_("chatbot_id", chatbot_ids) \
.order("created_at", desc=True).range(offset, offset + limit - 1).execute()
return [CampaignResponse(**c) for c in (result.data or [])]
@router.post("", response_model=CampaignResponse, status_code=201)
async def create_campaign(data: CampaignCreate, user=Depends(get_current_user)):
supabase = get_supabase()
_check_campaigns_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
_verify_chatbot_ownership(data.chatbot_id, company_id, supabase)
# Count Telegram subscribers for this chatbot
subscribers = supabase.table("channel_sessions").select("id", count="exact") \
.eq("chatbot_id", data.chatbot_id).eq("channel", "telegram").execute()
recipients_count = subscribers.count or 0
campaign_data = {
"id": str(uuid.uuid4()),
"chatbot_id": data.chatbot_id,
"title": data.title,
"message": data.message,
"status": "draft",
"recipients_count": recipients_count,
"sent_count": 0,
}
result = supabase.table("campaigns").insert(campaign_data).execute()
if not result.data:
raise HTTPException(status_code=500, detail="Failed to create campaign")
return CampaignResponse(**result.data[0])
@router.post("/{campaign_id}/send", response_model=CampaignResponse)
async def send_campaign(campaign_id: str, user=Depends(get_current_user)):
"""Broadcast the campaign message to all Telegram subscribers of the chatbot."""
supabase = get_supabase()
_check_campaigns_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
campaign = supabase.table("campaigns").select("*, chatbots(company_id)") \
.eq("id", campaign_id).execute()
if not campaign.data:
raise HTTPException(status_code=404, detail="Campaign not found")
c = campaign.data[0]
if c.get("chatbots", {}).get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
if c["status"] == "sent":
raise HTTPException(status_code=400, detail="Campaign already sent")
chatbot_id = c["chatbot_id"]
# Get the Telegram bot token for this chatbot
conn = supabase.table("channel_connections").select("bot_token") \
.eq("chatbot_id", chatbot_id).eq("channel", "telegram").eq("is_active", True).execute()
if not conn.data or not conn.data[0].get("bot_token"):
raise HTTPException(status_code=400, detail="No active Telegram connection for this chatbot")
bot_token = conn.data[0]["bot_token"]
# Get all Telegram subscribers (channel_sessions)
sessions = supabase.table("channel_sessions").select("external_id") \
.eq("chatbot_id", chatbot_id).eq("channel", "telegram").execute()
subscribers = sessions.data or []
# Mark as sending
supabase.table("campaigns").update({"status": "sending"}).eq("id", campaign_id).execute()
# Broadcast
from app.services.telegram_service import send_message as tg_send
sent = 0
for sub in subscribers:
try:
# external_id format is "tg:{token_prefix}:{chat_id}"
parts = sub["external_id"].split(":")
if len(parts) >= 3:
chat_id = int(parts[2])
await tg_send(bot_token, chat_id, c["message"])
sent += 1
except Exception as e:
logger.warning(f"Failed to send campaign to {sub['external_id']}: {e}")
# Mark as sent
from datetime import datetime, timezone
supabase.table("campaigns").update({
"status": "sent",
"sent_count": sent,
"recipients_count": len(subscribers),
"sent_at": datetime.now(timezone.utc).isoformat(),
}).eq("id", campaign_id).execute()
updated = supabase.table("campaigns").select("*").eq("id", campaign_id).execute()
return CampaignResponse(**updated.data[0])
@router.delete("/{campaign_id}")
async def delete_campaign(campaign_id: str, user=Depends(get_current_user)):
supabase = get_supabase()
_check_campaigns_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
campaign = supabase.table("campaigns").select("*, chatbots(company_id)") \
.eq("id", campaign_id).execute()
if not campaign.data:
raise HTTPException(status_code=404, detail="Campaign not found")
if campaign.data[0].get("chatbots", {}).get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
if campaign.data[0]["status"] == "sending":
raise HTTPException(status_code=400, detail="Cannot delete a campaign that is currently sending")
supabase.table("campaigns").delete().eq("id", campaign_id).execute()
return {"success": True}

View File

@@ -1,11 +1,9 @@
import json
import re
import uuid
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel
from typing import List, Optional
from typing import Optional
from app.config import settings, PLAN_LIMITS
from app.database import get_supabase
@@ -14,7 +12,6 @@ from app.services.rag import rag_engine
from app.services.telegram_service import (
delete_webhook, get_bot_info, send_message as tg_send, set_webhook,
)
from app.services.whatsapp_service import send_message as wa_send, verify_signature
from app.routers.chat import (
_get_conversation_history, _get_or_create_conversation, _save_message,
)
@@ -32,11 +29,6 @@ class TelegramConnectRequest(BaseModel):
bot_token: str
class WhatsAppConnectRequest(BaseModel):
chatbot_id: str
wa_keyword: Optional[str] = None
# ── Helpers ───────────────────────────────────────────────────────────────────
def _verify_chatbot_ownership(chatbot_id: str, user_id: str, supabase):
@@ -53,18 +45,6 @@ def _verify_chatbot_ownership(chatbot_id: str, user_id: str, supabase):
raise HTTPException(status_code=404, detail="Chatbot not found")
def _generate_keyword(chatbot_name: str, chatbot_id: str, supabase) -> str:
base = re.sub(r"[^A-Z0-9]", "", chatbot_name.upper())[:10] or "BOT"
existing = (
supabase.table("channel_connections")
.select("id").eq("channel", "whatsapp").eq("wa_keyword", base).execute()
)
if not existing.data:
return base
suffix = chatbot_id.replace("-", "")[:4].upper()
return f"{base[:6]}{suffix}"
def _get_or_create_channel_session(
chatbot_id: str, channel: str, external_id: str, supabase
) -> dict:
@@ -85,32 +65,13 @@ def _get_or_create_channel_session(
return result.data[0]
def _upsert_whatsapp_session(chatbot_id: str, phone: str, session_id: str, supabase):
existing = (
supabase.table("channel_sessions")
.select("id").eq("channel", "whatsapp").eq("external_id", phone).execute()
)
if existing.data:
supabase.table("channel_sessions").update(
{"chatbot_id": chatbot_id, "session_id": session_id}
).eq("id", existing.data[0]["id"]).execute()
else:
supabase.table("channel_sessions").insert({
"id": str(uuid.uuid4()),
"chatbot_id": chatbot_id,
"channel": "whatsapp",
"external_id": phone,
"session_id": session_id,
}).execute()
def _check_channel_plan(user_id: str, channel: str, supabase):
"""Raise 402 if the user's plan doesn't include the requested channel."""
sub = supabase.table("subscriptions").select("plan").eq("user_id", user_id).eq("status", "active").execute()
plan = sub.data[0]["plan"] if sub.data else "free"
allowed = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"]).get("channels", [])
if channel not in allowed:
label = {"telegram": "Starter", "whatsapp": "Business"}.get(channel, "a higher")
label = {"telegram": "Starter"}.get(channel, "a higher")
raise HTTPException(status_code=402, detail=f"{channel.title()} channel requires {label} plan or higher")
@@ -151,12 +112,6 @@ def _detect_language(text: str) -> str:
return best if scores[best] > 0.25 else "en"
def _wa_link(keyword: str) -> str:
if settings.whatsapp_display_number:
return f"https://wa.me/{settings.whatsapp_display_number}?text=START+{keyword}"
return ""
# ── CRUD endpoints ────────────────────────────────────────────────────────────
@router.get("")
@@ -165,17 +120,11 @@ async def list_channels(chatbot_id: str = Query(...), user=Depends(get_current_u
_verify_chatbot_ownership(chatbot_id, user.id, supabase)
result = (
supabase.table("channel_connections")
.select("id,channel,bot_username,wa_keyword,is_active,created_at")
.select("id,channel,bot_username,is_active,created_at")
.eq("chatbot_id", chatbot_id)
.execute()
)
rows = result.data or []
for row in rows:
if row["channel"] == "whatsapp" and row.get("wa_keyword"):
row["wa_link"] = _wa_link(row["wa_keyword"])
else:
row["wa_link"] = None
return rows
return result.data or []
@router.post("/telegram")
@@ -224,47 +173,6 @@ async def connect_telegram(data: TelegramConnectRequest, user=Depends(get_curren
}
@router.post("/whatsapp")
async def connect_whatsapp(data: WhatsAppConnectRequest, user=Depends(get_current_user)):
supabase = get_supabase()
_verify_chatbot_ownership(data.chatbot_id, user.id, supabase)
_check_channel_plan(user.id, "whatsapp", supabase)
chatbot = supabase.table("chatbots").select("name").eq("id", data.chatbot_id).execute()
chatbot_name = chatbot.data[0]["name"] if chatbot.data else "BOT"
keyword = (data.wa_keyword or _generate_keyword(chatbot_name, data.chatbot_id, supabase)).upper()
keyword = re.sub(r"[^A-Z0-9]", "", keyword)[:12]
if not keyword:
raise HTTPException(status_code=400, detail="Keyword must contain letters or numbers")
taken = (
supabase.table("channel_connections")
.select("id").eq("channel", "whatsapp").eq("wa_keyword", keyword)
.neq("chatbot_id", data.chatbot_id).execute()
)
if taken.data:
raise HTTPException(status_code=400, detail=f"Keyword '{keyword}' is already taken. Choose a different one.")
existing = (
supabase.table("channel_connections")
.select("id").eq("chatbot_id", data.chatbot_id).eq("channel", "whatsapp").execute()
)
conn_data = {
"chatbot_id": data.chatbot_id,
"channel": "whatsapp",
"wa_keyword": keyword,
"is_active": True,
}
if existing.data:
supabase.table("channel_connections").update(conn_data).eq("id", existing.data[0]["id"]).execute()
else:
conn_data["id"] = str(uuid.uuid4())
supabase.table("channel_connections").insert(conn_data).execute()
return {"success": True, "keyword": keyword, "wa_link": _wa_link(keyword)}
@router.delete("/{connection_id}")
async def disconnect_channel(connection_id: str, user=Depends(get_current_user)):
supabase = get_supabase()
@@ -383,136 +291,3 @@ async def telegram_webhook(bot_token: str, request: Request):
return {"ok": True}
# ── WhatsApp webhooks ─────────────────────────────────────────────────────────
@webhook_router.get("/whatsapp")
async def whatsapp_verify(request: Request):
"""Meta webhook verification challenge."""
params = dict(request.query_params)
if (
params.get("hub.mode") == "subscribe"
and settings.whatsapp_verify_token
and params.get("hub.verify_token") == settings.whatsapp_verify_token
):
return Response(content=params["hub.challenge"], media_type="text/plain")
raise HTTPException(status_code=403, detail="Forbidden")
@webhook_router.post("/whatsapp")
async def whatsapp_webhook(request: Request):
"""Receive messages from WhatsApp Cloud API."""
raw_body = await request.body()
if settings.whatsapp_app_secret:
sig = request.headers.get("X-Hub-Signature-256", "")
if not verify_signature(raw_body, sig, settings.whatsapp_app_secret):
raise HTTPException(status_code=403, detail="Invalid signature")
try:
body = json.loads(raw_body)
value = body["entry"][0]["changes"][0]["value"]
if "messages" not in value:
return {"ok": True}
msg = value["messages"][0]
if msg.get("type") != "text":
return {"ok": True}
from_number = msg["from"]
text = msg["text"]["body"].strip()
except (KeyError, IndexError, json.JSONDecodeError):
return {"ok": True}
supabase = get_supabase()
async def _wa_reply(to: str, message: str):
if settings.whatsapp_access_token and settings.whatsapp_phone_number_id:
await wa_send(settings.whatsapp_phone_number_id, to, message, settings.whatsapp_access_token)
# Handle START <keyword>
if text.upper().startswith("START "):
keyword = re.sub(r"[^A-Z0-9]", "", text[6:].strip().upper())
conn = (
supabase.table("channel_connections")
.select("*").eq("channel", "whatsapp").eq("wa_keyword", keyword).eq("is_active", True).execute()
)
if not conn.data:
await _wa_reply(from_number, f"Sorry, chatbot '{keyword}' not found. Use the link from the business you're contacting.")
return {"ok": True}
chatbot_id = conn.data[0]["chatbot_id"]
chatbot_result = supabase.table("chatbots").select("name,welcome_message").eq("id", chatbot_id).execute()
chatbot = chatbot_result.data[0] if chatbot_result.data else {}
_upsert_whatsapp_session(chatbot_id, from_number, str(uuid.uuid4()), supabase)
welcome = chatbot.get("welcome_message") or f"Hello! I'm {chatbot.get('name', 'your assistant')}. How can I help you?"
await _wa_reply(from_number, welcome)
return {"ok": True}
# Regular message — find active session
session_result = (
supabase.table("channel_sessions")
.select("*").eq("channel", "whatsapp").eq("external_id", from_number).execute()
)
if not session_result.data:
await _wa_reply(from_number, "To start chatting, use the WhatsApp link from the business you're trying to contact.")
return {"ok": True}
session = session_result.data[0]
chatbot_id = session["chatbot_id"]
# Check subscription still allows WhatsApp
if not _check_chatbot_channel_subscription(chatbot_id, "whatsapp", supabase):
await _wa_reply(from_number, "This service is currently unavailable. Please contact the business directly.")
return {"ok": True}
chatbot_result = (
supabase.table("chatbots").select("*, companies(name, logo_url)").eq("id", chatbot_id).execute()
)
if not chatbot_result.data:
return {"ok": True}
chatbot = chatbot_result.data[0]
collection_name = chatbot.get("qdrant_collection_name")
if not collection_name:
await _wa_reply(from_number, "This chatbot isn't ready yet. Please try again later.")
return {"ok": True}
detected_lang = _detect_language(text)
company_data = chatbot.get("companies", {}) or {}
conversation = _get_or_create_conversation(
chatbot_id=chatbot_id,
session_id=session["session_id"],
user_id=None,
language=detected_lang,
supabase=supabase,
)
history = _get_conversation_history(conversation["id"], supabase)
chatbot_config = {**chatbot, "company_name": company_data.get("name", "")}
try:
result = await rag_engine.process_query(
query=text,
collection_name=collection_name,
chatbot_config=chatbot_config,
conversation_history=history,
language=detected_lang,
)
except Exception as e:
logger.error(f"WhatsApp RAG error for chatbot {chatbot_id}: {e}")
await _wa_reply(from_number, "Sorry, I encountered an error. Please try again.")
return {"ok": True}
confidence_score = max((s.score for s in result.get("sources", [])), default=0.0)
_save_message(conversation["id"], "user", text, supabase)
_save_message(
conversation["id"], "assistant", result["response"], supabase,
sources=[s.model_dump() for s in result.get("sources", [])],
model=result.get("model", ""),
confidence_score=confidence_score,
)
supabase.table("conversations").update(
{"message_count": len(history) + 2}
).eq("id", conversation["id"]).execute()
supabase.table("channel_sessions").update({"last_active": "now()"}).eq("id", session["id"]).execute()
await _wa_reply(from_number, result["response"])
return {"ok": True}

View File

@@ -124,6 +124,19 @@ async def chat(
# Get conversation history
history = _get_conversation_history(conversation["id"], supabase)
# If an agent has taken over this conversation, stop the bot from responding
conv_status = conversation.get("status", "open")
if conv_status == "agent_handling":
return ChatResponse(
response="",
session_id=session_id,
sources=[],
model_used="",
tokens_used=0,
needs_lead_capture=False,
handoff=False,
)
# Get company info for context
company_data = chatbot.get("companies", {}) or {}
chatbot_config = {
@@ -131,6 +144,19 @@ async def chat(
"company_name": company_data.get("name", ""),
}
# If booking is enabled, inject a note into the system prompt so the bot
# can guide users to the booking page
if chatbot.get("booking_enabled"):
from app.config import settings as _cfg
booking_url = f"{_cfg.app_url}/book/{chatbot_id}"
booking_note = (
f"\n\nAppointment booking: This business accepts appointments online. "
f"If the user wants to book an appointment, meeting, or consultation, "
f"provide them this booking link: {booking_url}"
)
existing_prompt = chatbot_config.get("system_prompt") or ""
chatbot_config["system_prompt"] = existing_prompt + booking_note
# Run RAG
result = await rag_engine.process_query(
query=message.message,

View File

@@ -5,6 +5,7 @@ from app.models import (
from app.database import get_supabase
from app.dependencies import get_current_user, get_user_subscription
from app.services.vector_store import vector_store
from app.services.storage import delete_from_storage
from app.config import PLAN_LIMITS
from typing import List
import uuid
@@ -83,8 +84,20 @@ async def create_chatbot(data: ChatbotCreate, user=Depends(get_current_user)):
"handoff_keywords": data.handoff_keywords,
}
result = supabase.table("chatbots").insert(chatbot_data).execute()
if not result.data:
try:
result = supabase.table("chatbots").insert(chatbot_data).execute()
if not result.data:
raise HTTPException(status_code=500, detail="Failed to create chatbot")
except HTTPException:
raise
except Exception as e:
# Cleanup orphaned Qdrant collection if DB insert failed
if collection_name:
try:
vector_store.delete_collection(collection_name)
logger.warning(f"Cleaned up orphaned Qdrant collection {collection_name} after DB failure")
except Exception:
pass
raise HTTPException(status_code=500, detail="Failed to create chatbot")
return _format_chatbot(result.data[0], supabase)
@@ -139,7 +152,11 @@ async def delete_chatbot(chatbot_id: str, user=Depends(get_current_user)):
try:
vector_store.delete_collection(chatbot["qdrant_collection_name"])
except Exception as e:
logger.warning(f"Failed to delete collection: {e}")
logger.warning(f"Failed to delete Qdrant collection: {e}")
# Delete logo from Supabase Storage
if chatbot.get("logo_url"):
delete_from_storage(supabase, "logos", chatbot["logo_url"])
supabase.table("chatbots").delete().eq("id", chatbot_id).execute()
return SuccessResponse(success=True, message="Chatbot deleted")
@@ -303,4 +320,5 @@ def _format_chatbot(chatbot: dict, supabase) -> ChatbotResponse:
handoff_message=chatbot.get("handoff_message", "I'll connect you with our team. Please wait."),
handoff_email=chatbot.get("handoff_email"),
handoff_keywords=chatbot.get("handoff_keywords") or ["human", "agent", "speak to someone", "talk to a person", "real person"],
booking_enabled=bool(chatbot.get("booking_enabled")),
)

View File

@@ -5,6 +5,7 @@ from app.dependencies import get_current_user
from app.services.document_processor import process_document
from app.services.embeddings import embedding_service
from app.services.vector_store import vector_store
from app.services.storage import delete_from_storage, extract_storage_path
from app.config import settings
from typing import List
import uuid
@@ -205,10 +206,72 @@ async def delete_document(chatbot_id: str, document_id: str, user=Depends(get_cu
except Exception as e:
logger.warning(f"Failed to delete vectors: {e}")
# Delete file from Supabase Storage
if doc.data[0].get("file_url"):
delete_from_storage(supabase, "documents", doc.data[0]["file_url"])
supabase.table("documents").delete().eq("id", document_id).execute()
return SuccessResponse(success=True, message="Document deleted")
@router.post("/{document_id}/retry", response_model=DocumentResponse)
async def retry_document_processing(
chatbot_id: str,
document_id: str,
background_tasks: BackgroundTasks,
user=Depends(get_current_user),
):
"""Retry processing a failed document."""
supabase = get_supabase()
chatbot = _get_user_chatbot(chatbot_id, user.id, supabase)
doc = supabase.table("documents").select("*").eq("id", document_id).eq("chatbot_id", chatbot_id).execute()
if not doc.data:
raise HTTPException(status_code=404, detail="Document not found")
document = doc.data[0]
if document.get("status") != "failed":
raise HTTPException(status_code=400, detail="Only failed documents can be retried")
file_url = document.get("file_url")
if not file_url:
raise HTTPException(
status_code=400,
detail="No file URL stored. Please re-upload this document."
)
# Download file from storage
try:
path = extract_storage_path(file_url, "documents")
if not path:
raise HTTPException(status_code=400, detail="Cannot locate file in storage. Please re-upload.")
file_bytes = supabase.storage.from_("documents").download(path)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to download document {document_id} for retry: {e}")
raise HTTPException(status_code=400, detail="Cannot retrieve file from storage. Please re-upload.")
# Reset status to processing
result = supabase.table("documents").update({
"status": "processing",
"error_message": None,
"chunk_count": 0,
}).eq("id", document_id).execute()
# Re-enqueue background processing
background_tasks.add_task(
_process_document_bg,
file_bytes=file_bytes,
file_name=document["file_name"],
doc_id=document_id,
chatbot=chatbot,
supabase=supabase,
)
return DocumentResponse(**result.data[0])
# ── URL Sources ───────────────────────────────────────────────────────────────
@url_router.get("", response_model=List[UrlSourceResponse])

View File

@@ -2,8 +2,9 @@ from fastapi import APIRouter, HTTPException, Depends, Query
from app.database import get_supabase
from app.dependencies import get_current_user
from app.config import PLAN_LIMITS
from app.models import InboxConversation, InboxMessage
from app.models import InboxConversation, InboxMessage, ConversationStatusUpdate, AgentReplyCreate
from typing import List, Optional
import uuid
import logging
logger = logging.getLogger(__name__)
@@ -79,6 +80,8 @@ async def list_inbox_conversations(
language=conv.get("language", "en"),
message_count=conv.get("message_count", 0),
first_message=first_message_text,
status=conv.get("status", "open"),
last_agent_reply_at=conv.get("last_agent_reply_at"),
created_at=conv.get("created_at"),
))
@@ -137,6 +140,67 @@ async def get_inbox_conversation(
}
@router.patch("/conversations/{conversation_id}/status")
async def update_conversation_status(
conversation_id: str,
data: ConversationStatusUpdate,
user=Depends(get_current_user),
):
"""Update conversation status (open, agent_handling, resolved)."""
if data.status not in ("open", "agent_handling", "resolved"):
raise HTTPException(status_code=400, detail="Invalid status")
supabase = get_supabase()
_check_inbox_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
conv = supabase.table("conversations").select("*, chatbots(company_id)") \
.eq("id", conversation_id).execute()
if not conv.data:
raise HTTPException(status_code=404, detail="Conversation not found")
if conv.data[0].get("chatbots", {}).get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
supabase.table("conversations").update({"status": data.status}).eq("id", conversation_id).execute()
return {"success": True, "status": data.status}
@router.post("/conversations/{conversation_id}/reply")
async def agent_reply(
conversation_id: str,
data: AgentReplyCreate,
user=Depends(get_current_user),
):
"""Send an agent reply to a conversation."""
supabase = get_supabase()
_check_inbox_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
conv = supabase.table("conversations").select("*, chatbots(company_id)") \
.eq("id", conversation_id).execute()
if not conv.data:
raise HTTPException(status_code=404, detail="Conversation not found")
if conv.data[0].get("chatbots", {}).get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
msg_id = str(uuid.uuid4())
supabase.table("messages").insert({
"id": msg_id,
"conversation_id": conversation_id,
"role": "agent",
"content": data.message,
}).execute()
# Mark as agent_handling if not already, and record reply time
current_status = conv.data[0].get("status", "open")
update_data: dict = {"last_agent_reply_at": "now()"}
if current_status == "open":
update_data["status"] = "agent_handling"
supabase.table("conversations").update(update_data).eq("id", conversation_id).execute()
return {"success": True, "message_id": msg_id}
@router.delete("/conversations/{conversation_id}")
async def delete_inbox_conversation(
conversation_id: str,

View File

@@ -2,7 +2,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query
from fastapi.responses import StreamingResponse
from app.database import get_supabase
from app.dependencies import get_current_user
from app.models import LeadCreate, LeadResponse
from app.models import LeadCreate, LeadResponse, LeadUpdate
from typing import List, Optional
import uuid
import csv
@@ -60,6 +60,40 @@ async def list_leads(
return [LeadResponse(**lead) for lead in (result.data or [])]
@router.patch("/{lead_id}", response_model=LeadResponse)
async def update_lead(
lead_id: str,
data: LeadUpdate,
user=Depends(get_current_user),
):
"""Update lead status or notes."""
supabase = get_supabase()
_check_leads_access(user.id, supabase)
company_id = _get_user_company_id(user.id, supabase)
lead = supabase.table("leads").select("*, chatbots(company_id)") \
.eq("id", lead_id).execute()
if not lead.data:
raise HTTPException(status_code=404, detail="Lead not found")
if lead.data[0].get("chatbots", {}).get("company_id") != company_id:
raise HTTPException(status_code=403, detail="Not authorized")
update_fields: dict = {}
if data.status is not None:
valid_statuses = ("new", "contacted", "qualified", "closed", "lost")
if data.status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Status must be one of {valid_statuses}")
update_fields["status"] = data.status
if data.notes is not None:
update_fields["notes"] = data.notes
if not update_fields:
return LeadResponse(**lead.data[0])
result = supabase.table("leads").update(update_fields).eq("id", lead_id).execute()
return LeadResponse(**result.data[0])
@router.get("/export")
async def export_leads_csv(
chatbot_id: Optional[str] = Query(None),

View File

@@ -96,7 +96,7 @@ async def get_available_models(user=Depends(get_current_user)):
if plan == "free":
upgrade_label = "Upgrade to Starter for more AI models and messaging channels"
elif plan == "starter":
upgrade_label = "Upgrade to Business for GPT-4o, Claude, Gemini and WhatsApp"
upgrade_label = "Upgrade to Business for GPT-4o, Claude, and Gemini"
return ModelsResponse(
models=models,

View File

@@ -7,7 +7,7 @@ import logging
logger = logging.getLogger(__name__)
RAG_SYSTEM_PROMPT = """You are a helpful AI assistant for {company_name}.
RAG_SYSTEM_PROMPT = """You are a helpful AI assistant for {company_name}.
Your role is to answer questions based on the provided context from company documents.
IMPORTANT RULES:
@@ -16,13 +16,20 @@ IMPORTANT RULES:
3. Be concise and helpful
4. Always maintain a professional, friendly tone
5. If asked about topics completely outside the context, politely redirect to relevant topics
{language_instruction}
{custom_instructions}
Context from knowledge base:
{context}
"""
LANGUAGE_NAMES = {
"en": "English", "fr": "French", "es": "Spanish", "de": "German",
"it": "Italian", "pt": "Portuguese", "ar": "Arabic", "zh": "Chinese",
"ja": "Japanese", "ko": "Korean", "ru": "Russian", "nl": "Dutch",
"tr": "Turkish", "pl": "Polish", "vi": "Vietnamese", "th": "Thai",
}
class RAGEngine:
def __init__(self):
@@ -102,8 +109,15 @@ class RAGEngine:
logger.warning(f"[RAG] No context found for query: '{query}' in collection '{collection_name}'")
# Step 4: Build messages
lang_name = LANGUAGE_NAMES.get(language, "English") if language and language != "en" else ""
language_instruction = (
f"\n6. Respond in {lang_name}. Match the language of the user's message."
if lang_name else ""
)
system_prompt = RAG_SYSTEM_PROMPT.format(
company_name=chatbot_config.get("company_name", ""),
language_instruction=language_instruction,
custom_instructions=chatbot_config.get("system_prompt") or "",
context=context,
)

46
app/services/storage.py Normal file
View File

@@ -0,0 +1,46 @@
"""
Supabase Storage helper utilities.
Used to delete files from storage buckets when deleting documents or chatbots.
"""
import logging
from typing import Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
def extract_storage_path(url: str, bucket: str) -> Optional[str]:
"""
Extract the file path from a Supabase Storage public URL.
URL format: {supabase_url}/storage/v1/object/public/{bucket}/{path}
Returns the path portion after the bucket name, or None if not parseable.
"""
if not url:
return None
try:
parsed = urlparse(url)
prefix = f"/storage/v1/object/public/{bucket}/"
if prefix in parsed.path:
idx = parsed.path.index(prefix) + len(prefix)
return parsed.path[idx:]
except Exception as e:
logger.warning(f"Failed to extract storage path from URL '{url}': {e}")
return None
def delete_from_storage(supabase, bucket: str, url: str) -> bool:
"""
Delete a file from a Supabase Storage bucket given its public URL.
Returns True on success, False if the URL couldn't be parsed or deletion failed.
"""
path = extract_storage_path(url, bucket)
if not path:
return False
try:
supabase.storage.from_(bucket).remove([path])
logger.info(f"Deleted storage file: {bucket}/{path}")
return True
except Exception as e:
logger.warning(f"Failed to delete storage file {bucket}/{path}: {e}")
return False

View File

@@ -1,36 +0,0 @@
import httpx
import hashlib
import hmac
import logging
logger = logging.getLogger(__name__)
_META_API = "https://graph.facebook.com/v19.0"
async def send_message(phone_number_id: str, to: str, text: str, access_token: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{_META_API}/{phone_number_id}/messages",
headers={"Authorization": f"Bearer {access_token}"},
json={
"messaging_product": "whatsapp",
"to": to,
"type": "text",
"text": {"body": text},
},
)
return r.status_code == 200
except Exception as e:
logger.error(f"WhatsApp send error: {e}")
return False
def verify_signature(payload: bytes, signature: str, app_secret: str) -> bool:
expected = "sha256=" + hmac.new(
app_secret.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)

View File

@@ -1,140 +1,182 @@
def generate_widget_js(app_url: str) -> str:
"""Generate the embeddable widget JavaScript with app_url baked in."""
return f"""
(function() {{
var APP_URL = "{app_url}";
// Find script tag to get chatbot ID
var scripts = document.querySelectorAll('script[data-chatbot]');
var chatbotId = null;
if (scripts.length > 0) {{
chatbotId = scripts[scripts.length - 1].getAttribute('data-chatbot');
}}
if (!chatbotId) {{
console.warn('[Contexta] No data-chatbot attribute found on script tag');
return;
}}
// Styles
var style = document.createElement('style');
style.textContent = `
.contexta-btn {{
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
background: #6366f1;
border: none;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
z-index: 999998;
transition: transform 0.2s, box-shadow 0.2s;
}}
.contexta-btn:hover {{
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(0,0,0,0.25);
}}
.contexta-btn svg {{
width: 26px;
height: 26px;
fill: white;
}}
.contexta-container {{
position: fixed;
bottom: 92px;
right: 24px;
width: 380px;
height: 580px;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
z-index: 999999;
overflow: hidden;
display: none;
flex-direction: column;
border: 1px solid #e5e7eb;
}}
.contexta-container.open {{
display: flex;
}}
.contexta-iframe {{
width: 100%;
height: 100%;
border: none;
flex: 1;
}}
.contexta-close {{
position: absolute;
top: 8px;
right: 8px;
background: rgba(0,0,0,0.3);
border: none;
color: white;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}}
@media (max-width: 480px) {{
.contexta-container {{
bottom: 0;
right: 0;
width: 100vw;
height: 100vh;
border-radius: 0;
}}
}}
`;
document.head.appendChild(style);
// Button
var btn = document.createElement('button');
btn.className = 'contexta-btn';
btn.setAttribute('aria-label', 'Open chat');
btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>';
document.body.appendChild(btn);
// Container with iframe
var container = document.createElement('div');
container.className = 'contexta-container';
var closeBtn = document.createElement('button');
closeBtn.className = 'contexta-close';
closeBtn.innerHTML = '&times;';
closeBtn.setAttribute('aria-label', 'Close chat');
container.appendChild(closeBtn);
var iframe = document.createElement('iframe');
iframe.className = 'contexta-iframe';
iframe.src = APP_URL + '/chat/' + chatbotId;
iframe.setAttribute('allow', 'microphone');
container.appendChild(iframe);
document.body.appendChild(container);
// Toggle logic
var isOpen = false;
function toggle() {{
isOpen = !isOpen;
if (isOpen) {{
container.classList.add('open');
btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>';
}} else {{
container.classList.remove('open');
btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>';
}}
}}
btn.addEventListener('click', toggle);
closeBtn.addEventListener('click', toggle);
}})();
"""
Widget JS generator.
Produces a self-contained, framework-agnostic JavaScript bundle served at
GET /widget.js. Embed on any page with:
<script
src="https://api.yoursite.com/widget.js"
data-chatbot="<chatbot-id>">
</script>
Works on vanilla HTML, WordPress, Webflow, Shopify, Next.js (_document),
and any framework where you control the HTML shell.
For React/Vue projects that want a native component, host-side devs can call
window.Contexta.open() / .close() / .toggle()
from their own button, or await the dedicated npm package (@contexta/widget).
Design decisions
----------------
- All CSS is ID-scoped (#ctxa-*) to avoid colliding with host-page styles.
- The iframe src is set lazily on first open — zero network cost until use.
- document.currentScript is captured synchronously (before any async code)
so it works even when the host page has many script tags.
- z-index 2147483647 is the highest a browser will honour.
- sandbox attribute restricts the iframe while still allowing forms/popups.
"""
_TEMPLATE = r"""(function () {
'use strict';
/* ── Double-init guard ─────────────────────────────────────────────── */
if (window.__ctxa) return;
/* ── Read chatbot ID from <script data-chatbot="..."> ──────────────── */
var _cur = document.currentScript;
var _id = _cur && _cur.getAttribute('data-chatbot');
if (!_id) {
var _all = document.querySelectorAll('script[data-chatbot]');
if (_all.length) _id = _all[_all.length - 1].getAttribute('data-chatbot');
}
if (!_id) {
console.warn('[Contexta] widget loaded but no data-chatbot attribute found.');
return;
}
var _base = '__APP_URL__';
var _url = _base + '/chat/' + _id;
/* ── Styles ─────────────────────────────────────────────────────────── */
var _css = [
'#ctxa-btn{',
'position:fixed;bottom:24px;right:24px;',
'width:56px;height:56px;border-radius:50%;',
'background:linear-gradient(135deg,#6366f1,#4f46e5);',
'border:none;cursor:pointer;padding:0;',
'box-shadow:0 4px 20px rgba(99,102,241,.45);',
'display:flex;align-items:center;justify-content:center;',
'z-index:2147483646;',
'transition:transform .2s ease,box-shadow .2s ease;',
'outline:none;',
'}',
'#ctxa-btn:hover{transform:scale(1.1);box-shadow:0 6px 28px rgba(99,102,241,.55)}',
'#ctxa-btn:focus-visible{outline:2px solid #6366f1;outline-offset:3px}',
/* Icon animations */
'#ctxa-ico-chat,#ctxa-ico-x{position:absolute;transition:opacity .15s ease,transform .2s ease}',
'#ctxa-ico-x{opacity:0;transform:rotate(-90deg)}',
'#ctxa-btn.open #ctxa-ico-chat{opacity:0;transform:rotate(90deg)}',
'#ctxa-btn.open #ctxa-ico-x{opacity:1;transform:rotate(0)}',
/* Panel */
'#ctxa-panel{',
'position:fixed;bottom:92px;right:24px;',
'width:380px;height:600px;',
'max-height:calc(100dvh - 120px);',
'border-radius:20px;overflow:hidden;background:#fff;',
'box-shadow:0 20px 60px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.07);',
'z-index:2147483647;',
'opacity:0;transform:translateY(12px) scale(.97);pointer-events:none;',
'transition:opacity .22s cubic-bezier(.4,0,.2,1),transform .22s cubic-bezier(.4,0,.2,1);',
'}',
'#ctxa-panel.open{opacity:1;transform:translateY(0) scale(1);pointer-events:all}',
'#ctxa-frame{width:100%;height:100%;border:none;display:block;background:#fff}',
/* Mobile: full-screen */
'@media(max-width:480px){',
'#ctxa-panel{bottom:0;right:0;left:0;width:100%;height:100%;',
'max-height:100dvh;border-radius:0;box-shadow:none}',
'#ctxa-btn{bottom:16px;right:16px}',
'}',
].join('');
var _style = document.createElement('style');
_style.textContent = _css;
document.head.appendChild(_style);
/* ── Button ─────────────────────────────────────────────────────────── */
var _btn = document.createElement('button');
_btn.id = 'ctxa-btn';
_btn.setAttribute('aria-label', 'Open chat');
_btn.setAttribute('aria-expanded', 'false');
_btn.innerHTML = (
'<svg id="ctxa-ico-chat" width="24" height="24" viewBox="0 0 24 24" fill="none">' +
'<path d="M12 2C6.477 2 2 6.2 2 11.4c0 2.8 1.26 5.3 3.26 7.04L4 22l4.2-1.75' +
'A11.1 11.1 0 0 0 12 20.8c5.523 0 10-4.2 10-9.4S17.523 2 12 2z" fill="white"/>' +
'</svg>' +
'<svg id="ctxa-ico-x" width="20" height="20" viewBox="0 0 24 24" fill="none">' +
'<path d="M18 6L6 18M6 6l12 12" stroke="white" stroke-width="2.5"' +
' stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>'
);
/* ── Panel + lazy iframe ─────────────────────────────────────────────── */
var _panel = document.createElement('div');
_panel.id = 'ctxa-panel';
_panel.setAttribute('role', 'dialog');
_panel.setAttribute('aria-label', 'Chat');
var _frame = document.createElement('iframe');
_frame.id = 'ctxa-frame';
_frame.title = 'Contexta chat';
_frame.setAttribute('allow', 'clipboard-write');
/* sandbox: scripts + same-origin needed for the React app to run */
_frame.setAttribute('sandbox',
'allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation-by-user-activation'
);
_panel.appendChild(_frame);
/* ── Mount after DOM ready ───────────────────────────────────────────── */
function _mount() {
document.body.appendChild(_btn);
document.body.appendChild(_panel);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _mount);
} else {
_mount();
}
/* ── Open / Close ────────────────────────────────────────────────────── */
var _isOpen = false;
var _loaded = false;
function _open() {
if (!_loaded) { _frame.src = _url; _loaded = true; }
_isOpen = true;
_panel.classList.add('open');
_btn.classList.add('open');
_btn.setAttribute('aria-expanded', 'true');
_btn.setAttribute('aria-label', 'Close chat');
}
function _close() {
_isOpen = false;
_panel.classList.remove('open');
_btn.classList.remove('open');
_btn.setAttribute('aria-expanded', 'false');
_btn.setAttribute('aria-label', 'Open chat');
}
_btn.addEventListener('click', function () { _isOpen ? _close() : _open(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && _isOpen) _close();
});
/* ── Public API ──────────────────────────────────────────────────────── */
window.__ctxa = true;
window.Contexta = {
open: _open,
close: _close,
toggle: function () { _isOpen ? _close() : _open(); },
};
}());
"""
def generate_widget_js(app_url: str) -> str:
"""Return the widget bundle with the frontend app URL baked in."""
return _TEMPLATE.replace('__APP_URL__', app_url.rstrip('/'))