Improve backend

This commit is contained in:
Urtzi Alfaro
2025-11-18 07:17:17 +01:00
parent d36f2ab9af
commit 5c45164c8e
61 changed files with 9846 additions and 495 deletions

View File

@@ -0,0 +1,308 @@
# services/tenant/app/api/whatsapp_admin.py
"""
WhatsApp Admin API Endpoints
Admin-only endpoints for managing WhatsApp phone number assignments
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from uuid import UUID
from typing import List, Optional
from pydantic import BaseModel, Field
import httpx
import os
from app.core.database import get_db
from app.models.tenant_settings import TenantSettings
from app.models.tenants import Tenant
router = APIRouter()
# ================================================================
# SCHEMAS
# ================================================================
class WhatsAppPhoneNumberInfo(BaseModel):
"""Information about a WhatsApp phone number from Meta API"""
id: str = Field(..., description="Phone Number ID")
display_phone_number: str = Field(..., description="Display phone number (e.g., +34 612 345 678)")
verified_name: str = Field(..., description="Verified business name")
quality_rating: str = Field(..., description="Quality rating (GREEN, YELLOW, RED)")
class TenantWhatsAppStatus(BaseModel):
"""WhatsApp status for a tenant"""
tenant_id: UUID
tenant_name: str
whatsapp_enabled: bool
phone_number_id: Optional[str] = None
display_phone_number: Optional[str] = None
class AssignPhoneNumberRequest(BaseModel):
"""Request to assign phone number to tenant"""
phone_number_id: str = Field(..., description="Meta WhatsApp Phone Number ID")
display_phone_number: str = Field(..., description="Display format (e.g., '+34 612 345 678')")
class AssignPhoneNumberResponse(BaseModel):
"""Response after assigning phone number"""
success: bool
message: str
tenant_id: UUID
phone_number_id: str
display_phone_number: str
# ================================================================
# ENDPOINTS
# ================================================================
@router.get(
"/admin/whatsapp/phone-numbers",
response_model=List[WhatsAppPhoneNumberInfo],
summary="List available WhatsApp phone numbers",
description="Get all phone numbers available in the master WhatsApp Business Account"
)
async def list_available_phone_numbers():
"""
List all phone numbers from the master WhatsApp Business Account
Requires:
- WHATSAPP_BUSINESS_ACCOUNT_ID environment variable
- WHATSAPP_ACCESS_TOKEN environment variable
Returns list of available phone numbers with their status
"""
business_account_id = os.getenv("WHATSAPP_BUSINESS_ACCOUNT_ID")
access_token = os.getenv("WHATSAPP_ACCESS_TOKEN")
api_version = os.getenv("WHATSAPP_API_VERSION", "v18.0")
if not business_account_id or not access_token:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="WhatsApp master account not configured. Set WHATSAPP_BUSINESS_ACCOUNT_ID and WHATSAPP_ACCESS_TOKEN environment variables."
)
try:
# Fetch phone numbers from Meta Graph API
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
f"https://graph.facebook.com/{api_version}/{business_account_id}/phone_numbers",
headers={"Authorization": f"Bearer {access_token}"},
params={
"fields": "id,display_phone_number,verified_name,quality_rating"
}
)
if response.status_code != 200:
error_data = response.json()
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Meta API error: {error_data.get('error', {}).get('message', 'Unknown error')}"
)
data = response.json()
phone_numbers = data.get("data", [])
return [
WhatsAppPhoneNumberInfo(
id=phone.get("id"),
display_phone_number=phone.get("display_phone_number"),
verified_name=phone.get("verified_name", ""),
quality_rating=phone.get("quality_rating", "UNKNOWN")
)
for phone in phone_numbers
]
except httpx.HTTPError as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to fetch phone numbers from Meta: {str(e)}"
)
@router.get(
"/admin/whatsapp/tenants",
response_model=List[TenantWhatsAppStatus],
summary="List all tenants with WhatsApp status",
description="Get WhatsApp configuration status for all tenants"
)
async def list_tenant_whatsapp_status(
db: AsyncSession = Depends(get_db)
):
"""
List all tenants with their WhatsApp configuration status
Returns:
- tenant_id: Tenant UUID
- tenant_name: Tenant name
- whatsapp_enabled: Whether WhatsApp is enabled
- phone_number_id: Assigned phone number ID (if any)
- display_phone_number: Display format (if any)
"""
# Query all tenants with their settings
query = select(Tenant, TenantSettings).outerjoin(
TenantSettings,
Tenant.id == TenantSettings.tenant_id
)
result = await db.execute(query)
rows = result.all()
tenant_statuses = []
for tenant, settings in rows:
notification_settings = settings.notification_settings if settings else {}
tenant_statuses.append(
TenantWhatsAppStatus(
tenant_id=tenant.id,
tenant_name=tenant.name,
whatsapp_enabled=notification_settings.get("whatsapp_enabled", False),
phone_number_id=notification_settings.get("whatsapp_phone_number_id", ""),
display_phone_number=notification_settings.get("whatsapp_display_phone_number", "")
)
)
return tenant_statuses
@router.post(
"/admin/whatsapp/tenants/{tenant_id}/assign-phone",
response_model=AssignPhoneNumberResponse,
summary="Assign phone number to tenant",
description="Assign a WhatsApp phone number from the master account to a tenant"
)
async def assign_phone_number_to_tenant(
tenant_id: UUID,
request: AssignPhoneNumberRequest,
db: AsyncSession = Depends(get_db)
):
"""
Assign a WhatsApp phone number to a tenant
- **tenant_id**: UUID of the tenant
- **phone_number_id**: Meta Phone Number ID from master account
- **display_phone_number**: Human-readable format (e.g., "+34 612 345 678")
This will:
1. Validate the tenant exists
2. Check if phone number is already assigned to another tenant
3. Update tenant's notification settings
4. Enable WhatsApp for the tenant
"""
# Verify tenant exists
tenant_query = select(Tenant).where(Tenant.id == tenant_id)
tenant_result = await db.execute(tenant_query)
tenant = tenant_result.scalar_one_or_none()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tenant {tenant_id} not found"
)
# Check if phone number is already assigned to another tenant
settings_query = select(TenantSettings).where(TenantSettings.tenant_id != tenant_id)
settings_result = await db.execute(settings_query)
all_settings = settings_result.scalars().all()
for settings in all_settings:
notification_settings = settings.notification_settings or {}
if notification_settings.get("whatsapp_phone_number_id") == request.phone_number_id:
# Get the other tenant's name
other_tenant_query = select(Tenant).where(Tenant.id == settings.tenant_id)
other_tenant_result = await db.execute(other_tenant_query)
other_tenant = other_tenant_result.scalar_one_or_none()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Phone number {request.display_phone_number} is already assigned to tenant '{other_tenant.name if other_tenant else 'Unknown'}'"
)
# Get or create tenant settings
settings_query = select(TenantSettings).where(TenantSettings.tenant_id == tenant_id)
settings_result = await db.execute(settings_query)
settings = settings_result.scalar_one_or_none()
if not settings:
# Create default settings
settings = TenantSettings(
tenant_id=tenant_id,
**TenantSettings.get_default_settings()
)
db.add(settings)
# Update notification settings
notification_settings = settings.notification_settings or {}
notification_settings["whatsapp_enabled"] = True
notification_settings["whatsapp_phone_number_id"] = request.phone_number_id
notification_settings["whatsapp_display_phone_number"] = request.display_phone_number
settings.notification_settings = notification_settings
await db.commit()
await db.refresh(settings)
return AssignPhoneNumberResponse(
success=True,
message=f"Phone number {request.display_phone_number} assigned to tenant '{tenant.name}'",
tenant_id=tenant_id,
phone_number_id=request.phone_number_id,
display_phone_number=request.display_phone_number
)
@router.delete(
"/admin/whatsapp/tenants/{tenant_id}/unassign-phone",
response_model=AssignPhoneNumberResponse,
summary="Unassign phone number from tenant",
description="Remove WhatsApp phone number assignment from a tenant"
)
async def unassign_phone_number_from_tenant(
tenant_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""
Unassign WhatsApp phone number from a tenant
- **tenant_id**: UUID of the tenant
This will:
1. Clear the phone number assignment
2. Disable WhatsApp for the tenant
"""
# Get tenant settings
settings_query = select(TenantSettings).where(TenantSettings.tenant_id == tenant_id)
settings_result = await db.execute(settings_query)
settings = settings_result.scalar_one_or_none()
if not settings:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Settings not found for tenant {tenant_id}"
)
# Get current values for response
notification_settings = settings.notification_settings or {}
old_phone_id = notification_settings.get("whatsapp_phone_number_id", "")
old_display_phone = notification_settings.get("whatsapp_display_phone_number", "")
# Update notification settings
notification_settings["whatsapp_enabled"] = False
notification_settings["whatsapp_phone_number_id"] = ""
notification_settings["whatsapp_display_phone_number"] = ""
settings.notification_settings = notification_settings
await db.commit()
return AssignPhoneNumberResponse(
success=True,
message=f"Phone number unassigned from tenant",
tenant_id=tenant_id,
phone_number_id=old_phone_id,
display_phone_number=old_display_phone
)

View File

@@ -7,7 +7,7 @@ from fastapi import FastAPI
from sqlalchemy import text
from app.core.config import settings
from app.core.database import database_manager
from app.api import tenants, tenant_members, tenant_operations, webhooks, internal_demo, plans, subscription, tenant_settings
from app.api import tenants, tenant_members, tenant_operations, webhooks, internal_demo, plans, subscription, tenant_settings, whatsapp_admin
from shared.service_base import StandardFastAPIService
@@ -116,6 +116,7 @@ service.add_router(plans.router, tags=["subscription-plans"]) # Public endpoint
service.add_router(subscription.router, tags=["subscription"])
# Register settings router BEFORE tenants router to ensure proper route matching
service.add_router(tenant_settings.router, prefix="/api/v1/tenants", tags=["tenant-settings"])
service.add_router(whatsapp_admin.router, prefix="/api/v1", tags=["whatsapp-admin"]) # Admin WhatsApp management
service.add_router(tenants.router, tags=["tenants"])
service.add_router(tenant_members.router, tags=["tenant-members"])
service.add_router(tenant_operations.router, tags=["tenant-operations"])

View File

@@ -182,12 +182,10 @@ class TenantSettings(Base):
# Notification Settings (Notification Service)
notification_settings = Column(JSON, nullable=False, default=lambda: {
# WhatsApp Configuration
# WhatsApp Configuration (Shared Account Model)
"whatsapp_enabled": False,
"whatsapp_phone_number_id": "", # Meta WhatsApp Phone Number ID
"whatsapp_access_token": "", # Meta access token (should be encrypted)
"whatsapp_business_account_id": "", # Meta Business Account ID
"whatsapp_api_version": "v18.0",
"whatsapp_phone_number_id": "", # Meta WhatsApp Phone Number ID (from shared master account)
"whatsapp_display_phone_number": "", # Display format for UI (e.g., "+34 612 345 678")
"whatsapp_default_language": "es",
# Email Configuration
@@ -354,9 +352,7 @@ class TenantSettings(Base):
"notification_settings": {
"whatsapp_enabled": False,
"whatsapp_phone_number_id": "",
"whatsapp_access_token": "",
"whatsapp_business_account_id": "",
"whatsapp_api_version": "v18.0",
"whatsapp_display_phone_number": "",
"whatsapp_default_language": "es",
"email_enabled": True,
"email_from_address": "",

View File

@@ -220,12 +220,10 @@ class MLInsightsSettings(BaseModel):
class NotificationSettings(BaseModel):
"""Notification and communication settings"""
# WhatsApp Configuration
# WhatsApp Configuration (Shared Account Model)
whatsapp_enabled: bool = Field(False, description="Enable WhatsApp notifications for this tenant")
whatsapp_phone_number_id: str = Field("", description="Meta WhatsApp Phone Number ID")
whatsapp_access_token: str = Field("", description="Meta WhatsApp Access Token (encrypted)")
whatsapp_business_account_id: str = Field("", description="Meta WhatsApp Business Account ID")
whatsapp_api_version: str = Field("v18.0", description="WhatsApp Cloud API version")
whatsapp_phone_number_id: str = Field("", description="Meta WhatsApp Phone Number ID (from shared master account)")
whatsapp_display_phone_number: str = Field("", description="Display format for UI (e.g., '+34 612 345 678')")
whatsapp_default_language: str = Field("es", description="Default language for WhatsApp templates")
# Email Configuration
@@ -262,13 +260,6 @@ class NotificationSettings(BaseModel):
raise ValueError("whatsapp_phone_number_id is required when WhatsApp is enabled")
return v
@validator('whatsapp_access_token')
def validate_access_token(cls, v, values):
"""Validate access token is provided if WhatsApp is enabled"""
if values.get('whatsapp_enabled') and not v:
raise ValueError("whatsapp_access_token is required when WhatsApp is enabled")
return v
# ================================================================
# REQUEST/RESPONSE SCHEMAS