diff --git a/frontend/src/api/types/demo.ts b/frontend/src/api/types/demo.ts index 97360e85..7ddccd02 100644 --- a/frontend/src/api/types/demo.ts +++ b/frontend/src/api/types/demo.ts @@ -20,7 +20,7 @@ * Backend: services/demo_session/app/api/schemas.py:10-15 (DemoSessionCreate) */ export interface DemoSessionCreate { - demo_account_type: string; // individual_bakery or central_baker + demo_account_type: string; // professional or enterprise user_id?: string | null; // Optional authenticated user ID ip_address?: string | null; user_agent?: string | null; diff --git a/frontend/src/pages/public/DemoPage.tsx b/frontend/src/pages/public/DemoPage.tsx index 7c34667f..2d1045ba 100644 --- a/frontend/src/pages/public/DemoPage.tsx +++ b/frontend/src/pages/public/DemoPage.tsx @@ -1,482 +1,924 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { Link } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import { PublicLayout } from '../../components/layout'; -import { Button } from '../../components/ui'; -import { getDemoAccounts, createDemoSession, DemoAccount, demoSessionAPI } from '../../api/services/demo'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '../../components/ui/Button'; import { apiClient } from '../../api/client'; -import { Check, Clock, Shield, Play, Zap, ArrowRight, Store, Factory, Loader2 } from 'lucide-react'; -import { markTourAsStartPending } from '../../features/demo-onboarding'; +import { useAuthStore } from '../../stores'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '../../components/ui/Card'; +import { Badge } from '../../components/ui/Badge'; +import { Alert, AlertDescription } from '../../components/ui/Alert'; +import Modal, { ModalHeader, ModalBody, ModalFooter } from '../../components/ui/Modal/Modal'; +import { PublicLayout } from '../../components/layout'; +import { useTranslation } from 'react-i18next'; +import { + Store, + Network, + CheckCircle, + Users, + Building, + Package, + BarChart3, + ForkKnife, + ChefHat, + CreditCard, + Bell, + ShoppingBag, + ShoppingBasket, + Warehouse, + Truck, + TrendingUp, + DollarSign, + Clock, + MapPin, + Layers, + Activity, + ShoppingCart, + Factory, + Zap, + Star, + PlusCircle, + Map as MapIcon, + ShoppingBasket as ShoppingBasketIcon, + TrendingUp as ChartIcon, + DollarSign as MoneyIcon, + ArrowRight +} from 'lucide-react'; -const POLL_INTERVAL_MS = 1500; // Poll every 1.5 seconds +const DemoPage = () => { + const navigate = useNavigate(); + const { t } = useTranslation('demo'); + const setDemoAuth = useAuthStore((state) => state.setDemoAuth); + const [selectedTier, setSelectedTier] = useState(null); + const [creatingTier, setCreatingTier] = useState(null); + const [cloneProgress, setCloneProgress] = useState({ + parent: 0, + children: [0, 0, 0], + distribution: 0, + overall: 0 + }); + const [creationError, setCreationError] = useState(''); + const [estimatedProgress, setEstimatedProgress] = useState(0); + const [progressStartTime, setProgressStartTime] = useState(null); -export const DemoPage: React.FC = () => { - const { t } = useTranslation(); - const [demoAccounts, setDemoAccounts] = useState([]); - const [loading, setLoading] = useState(true); - const [creatingSession, setCreatingSession] = useState(false); - const [error, setError] = useState(null); - const [progressPercentage, setProgressPercentage] = useState(0); - const [estimatedTime, setEstimatedTime] = useState(5); + // BUG-010 FIX: State for partial status warning + const [partialWarning, setPartialWarning] = useState<{ + show: boolean; + message: string; + failedServices: string[]; + sessionData: any; + tier: string; + } | null>(null); - useEffect(() => { - const fetchDemoAccounts = async () => { - try { - const accounts = await getDemoAccounts(); - setDemoAccounts(accounts); - } catch (err) { - setError(t('demo:errors.loading_accounts', 'Error al cargar las cuentas demo')); - console.error('Error fetching demo accounts:', err); - } finally { - setLoading(false); - } - }; + // BUG-011 FIX: State for timeout modal + const [timeoutModal, setTimeoutModal] = useState<{ + show: boolean; + sessionId: string; + sessionData: any; + tier: string; + } | null>(null); - fetchDemoAccounts(); - }, []); + // BUG-009 FIX: Ref to track polling abort controller + const abortControllerRef = React.useRef(null); - const pollStatus = useCallback(async (sessionId: string) => { - try { - const statusData = await demoSessionAPI.getSessionStatus(sessionId); + // Helper function to calculate estimated progress based on elapsed time + const calculateEstimatedProgress = (tier: string, startTime: number): number => { + const elapsed = Date.now() - startTime; + const duration = tier === 'enterprise' ? 75000 : 30000; // ms + const linearProgress = Math.min(95, (elapsed / duration) * 100); + // Logarithmic curve for natural feel - starts fast, slows down + return Math.min(95, Math.round(linearProgress * (1 - Math.exp(-elapsed / 10000)))); + }; - // Calculate progress - ALWAYS update, even if no progress data yet - if (statusData.progress && Object.keys(statusData.progress).length > 0) { - const services = Object.values(statusData.progress); - const totalServices = services.length; - - if (totalServices > 0) { - const completedServices = services.filter( - (s) => s.status === 'completed' || s.status === 'failed' - ).length; - const percentage = Math.round((completedServices / totalServices) * 100); - setProgressPercentage(percentage); - - // Estimate remaining time - const remainingServices = totalServices - completedServices; - setEstimatedTime(Math.max(remainingServices * 2, 1)); - } else { - // No services yet, show minimal progress - setProgressPercentage(5); - } - } else { - // No progress data yet, show initial state - setProgressPercentage(10); - } - - // Check if ready to redirect - // CRITICAL: Wait for inventory, recipes, AND suppliers to complete - // to prevent dashboard from showing SetupWizardBlocker - const progress = statusData.progress || {}; - const inventoryReady = progress.inventory?.status === 'completed'; - const recipesReady = progress.recipes?.status === 'completed'; - const suppliersReady = progress.suppliers?.status === 'completed'; - - const criticalServicesReady = inventoryReady && recipesReady && suppliersReady; - - // Additionally verify that we have minimum required data to bypass SetupWizardBlocker - // The SetupWizardBlocker requires: 3+ ingredients, 1+ suppliers, 1+ recipes - - // Ensure progress data exists for all required services - const hasInventoryProgress = !!progress.inventory; - const hasSuppliersProgress = !!progress.suppliers; - const hasRecipesProgress = !!progress.recipes; - - // Extract counts with defensive checks - const ingredientsCount = hasInventoryProgress ? (progress.inventory.details?.ingredients || 0) : 0; - const suppliersCount = hasSuppliersProgress ? (progress.suppliers.details?.suppliers || 0) : 0; - const recipesCount = hasRecipesProgress ? (progress.recipes.details?.recipes || 0) : 0; - - // Verify we have the minimum required counts - const hasMinimumIngredients = (typeof ingredientsCount === 'number' && ingredientsCount >= 3); - const hasMinimumSuppliers = (typeof suppliersCount === 'number' && suppliersCount >= 1); - const hasMinimumRecipes = (typeof recipesCount === 'number' && recipesCount >= 1); - - // Ensure all required services have completed AND we have minimum data - const hasMinimumRequiredData = - hasInventoryProgress && - hasSuppliersProgress && - hasRecipesProgress && - hasMinimumIngredients && - hasMinimumSuppliers && - hasMinimumRecipes; - - const shouldRedirect = - (statusData.status === 'ready' && hasMinimumRequiredData) || // Ready status AND minimum required data - (criticalServicesReady && hasMinimumRequiredData); // Critical services done + minimum required data - - if (shouldRedirect) { - // Show 100% before redirect - setProgressPercentage(100); - // Small delay for smooth transition - setTimeout(() => { - window.location.href = `/app/dashboard?session=${sessionId}`; - }, 300); - return true; // Stop polling - } - - return false; // Continue polling - } catch (err) { - console.error('Error polling session status:', err); - return false; + const demoOptions = [ + { + id: 'professional', + tier: 'professional', + icon: Store, + title: 'Panadería Artesana', + subtitle: 'Tier Profesional', + description: 'Panadería profesional moderna que combina calidad artesanal con eficiencia operativa', + features: [ + 'Gestión de inventario y stock', + 'Planificación de producción', + 'Gestión de pedidos y proveedores', + 'Forecasting con IA (92% precisión)', + 'Dashboard de métricas', + 'Alertas inteligentes', + 'Integración con POS', + 'Recetas profesionales estandarizadas', + 'Ventas omnicanal (retail + online + catering)', + 'Reportes de negocio' + ], + characteristics: { + locations: '1', + employees: '12', + productionModel: 'Local Artesanal', + salesChannels: 'Retail + Online + Catering' + }, + accountType: 'professional', + baseTenantId: 'a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6', + color: 'blue' + }, + { + id: 'enterprise', + tier: 'enterprise', + icon: Network, + title: 'Cadena Enterprise', + subtitle: 'Tier Enterprise', + description: 'Producción centralizada con red de distribución en Madrid, Barcelona y Valencia', + features: [ + 'Todas las funciones Professional +', + 'Gestión multi-ubicación ilimitada', + 'Obrador central (Madrid) + 3 outlets', + 'Distribución y logística VRP-optimizada', + 'Forecasting agregado de red', + 'Dashboard enterprise consolidado', + 'Transferencias internas automatizadas', + 'Optimización de rutas de entrega', + 'Solicitudes de procurement entre ubicaciones', + 'Reportes consolidados nivel corporativo' + ], + characteristics: { + locations: '1 obrador + 3 tiendas', + employees: '45', + productionModel: 'Centralizado (Madrid)', + salesChannels: 'Madrid / Barcelona / Valencia' + }, + accountType: 'enterprise', + baseTenantId: 'c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8', + color: 'purple' } - }, []); + ]; - const handleStartDemo = async (accountType: string) => { - setCreatingSession(true); - setError(null); - setProgressPercentage(0); - setEstimatedTime(6); + const getLoadingMessage = (tier, progress) => { + if (tier === 'enterprise') { + if (progress < 25) return 'Creando obrador central...'; + if (progress < 50) return 'Configurando puntos de venta...'; + if (progress < 75) return 'Generando rutas de distribución...'; + return 'Finalizando configuración enterprise...'; + } else { + if (progress < 50) return 'Configurando tu panadería...'; + return 'Cargando datos de demostración...'; + } + }; + + const handleStartDemo = async (accountType, tier) => { + setCreatingTier(tier); + setCreationError(''); + setProgressStartTime(Date.now()); + setEstimatedProgress(0); try { - const session = await createDemoSession({ - demo_account_type: accountType as 'individual_bakery' | 'central_baker', + // Start the demo session first + const response = await fetch('/api/v1/demo/sessions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('access_token') || ''}` + }, + body: JSON.stringify({ + demo_account_type: accountType, + subscription_tier: tier + }) }); - console.log('✅ Demo session created:', session); - - // Store session ID in API client - apiClient.setDemoSessionId(session.session_id); - - // Set the virtual tenant ID in API client - apiClient.setTenantId(session.virtual_tenant_id); - console.log('✅ Set API client tenant ID:', session.virtual_tenant_id); - - // Store session info in localStorage for UI - localStorage.setItem('demo_mode', 'true'); - localStorage.setItem('demo_session_id', session.session_id); - localStorage.setItem('demo_account_type', accountType); - localStorage.setItem('demo_expires_at', session.expires_at); - localStorage.setItem('demo_tenant_id', session.virtual_tenant_id); - - // Start polling IMMEDIATELY in parallel with other setup - const pollInterval = setInterval(async () => { - const shouldStop = await pollStatus(session.session_id); - if (shouldStop) { - clearInterval(pollInterval); - } - }, POLL_INTERVAL_MS); - - // Initialize tenant store and other setup in parallel (non-blocking) - Promise.all([ - import('../../stores/tenant.store').then(({ useTenantStore }) => { - const demoTenant = { - id: session.virtual_tenant_id, - name: session.demo_config?.name || `Demo ${accountType}`, - business_type: accountType === 'individual_bakery' ? 'bakery' : 'central_baker', - business_model: accountType, - address: session.demo_config?.address || 'Demo Address', - city: session.demo_config?.city || 'Madrid', - postal_code: '28001', - phone: null, - is_active: true, - subscription_tier: 'demo', - ml_model_trained: false, - last_training_date: null, - owner_id: 'demo-user', - created_at: new Date().toISOString(), - }; - useTenantStore.getState().setCurrentTenant(demoTenant); - console.log('✅ Initialized tenant store with demo tenant:', demoTenant); - }), - // Mark tour to start automatically - Promise.resolve(markTourAsStartPending()), - ]).catch(err => console.error('Error initializing tenant store:', err)); - - // Initial poll (don't wait for tenant store) - const shouldStop = await pollStatus(session.session_id); - if (shouldStop) { - clearInterval(pollInterval); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Failed to create demo session'); } - } catch (err: any) { - setError(err?.message || t('demo:errors.creating_session', 'Error al crear sesión demo')); - console.error('Error creating demo session:', err); - setCreatingSession(false); + + const sessionData = await response.json(); + + // Store demo context immediately after session creation + localStorage.setItem('demo_mode', 'true'); + localStorage.setItem('demo_account_type', accountType); + localStorage.setItem('subscription_tier', tier); // Store tier for reference + localStorage.setItem('demo_session_id', sessionData.session_id); + localStorage.setItem('virtual_tenant_id', sessionData.virtual_tenant_id); + localStorage.setItem('demo_expires_at', sessionData.expires_at); + + // BUG FIX: Store the session token as access_token for authentication + if (sessionData.session_token) { + console.log('🔐 [DemoPage] Storing session token:', sessionData.session_token.substring(0, 20) + '...'); + localStorage.setItem('access_token', sessionData.session_token); + + // CRITICAL FIX: Update Zustand auth store with demo auth + // This ensures the token persists across navigation and page reloads + setDemoAuth(sessionData.session_token, { + id: 'demo-user', + email: 'demo@bakery.com', + full_name: 'Demo User', + is_active: true, + is_verified: true, + created_at: new Date().toISOString(), + tenant_id: sessionData.virtual_tenant_id, + }); + + console.log('✅ [DemoPage] Demo auth set in store'); + } else { + console.error('❌ [DemoPage] No session_token in response!', sessionData); + } + + // Now poll for status until ready + await pollForSessionStatus(sessionData.session_id, tier, sessionData); + + } catch (error) { + console.error('Error creating demo:', error); + setCreationError('Error al iniciar la demo. Por favor, inténtalo de nuevo.'); + } finally { + setCreatingTier(null); + setProgressStartTime(null); + setEstimatedProgress(0); + // Reset progress + setCloneProgress({ + parent: 0, + children: [0, 0, 0], + distribution: 0, + overall: 0 + }); } }; - const getAccountIcon = (accountType: string) => { - return accountType === 'individual_bakery' ? Store : Factory; + const pollForSessionStatus = async (sessionId, tier, sessionData) => { + const maxAttempts = 120; // 120 * 1s = 2 minutes max wait time + let attempts = 0; + + // BUG-009 FIX: Create AbortController for cancellation + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + // Initially show a higher progress to indicate activity + setCloneProgress({ + parent: 10, + children: [0, 0, 0], + distribution: 0, + overall: 10 + }); + + // Set up progress estimation interval for smooth UI updates + const progressInterval = setInterval(() => { + if (progressStartTime) { + const estimated = calculateEstimatedProgress(tier, progressStartTime); + setEstimatedProgress(estimated); + + // Update displayed progress with combination of estimated and backend + setCloneProgress(prev => ({ + ...prev, + overall: Math.max(estimated, prev.overall) + })); + } + }, 500); // Update every 500ms for smooth animation + + try { + while (attempts < maxAttempts) { + try { + // BUG-009 FIX: Pass abort signal to fetch + const statusResponse = await fetch( + `/api/v1/demo/sessions/${sessionId}/status`, + { signal: abortController.signal } + ); + + if (!statusResponse.ok) { + throw new Error('Failed to get session status'); + } + + const statusData = await statusResponse.json(); + + // Update progress based on actual backend status + updateProgressFromBackendStatus(statusData, tier); + + // BUG-010 FIX: Handle ready status separately from partial + if (statusData.status === 'ready') { + // Full success - navigate immediately + clearInterval(progressInterval); + setTimeout(() => { + const targetUrl = tier === 'enterprise' + ? `/app/tenants/${sessionData.virtual_tenant_id}/enterprise` + : `/app/tenants/${sessionData.virtual_tenant_id}/dashboard`; + navigate(targetUrl); + }, 1000); + return; + } else if (statusData.status === 'PARTIAL' || statusData.status === 'partial') { + // BUG-010 FIX: Show warning modal for partial status + clearInterval(progressInterval); + const failedServices = getFailedServices(statusData); + setPartialWarning({ + show: true, + message: 'La demo está parcialmente lista. Algunos servicios no se cargaron completamente.', + failedServices: failedServices, + sessionData: sessionData, + tier: tier + }); + return; + } else if (statusData.status === 'FAILED' || statusData.status === 'failed') { + clearInterval(progressInterval); + setCreationError('Error al clonar los datos de demo. Por favor, inténtalo de nuevo.'); + return; + } + + // Wait before next poll - reduced from 2s to 1s + await new Promise(resolve => setTimeout(resolve, 1000)); + attempts++; + } catch (error: any) { + // BUG-009 FIX: Handle abort gracefully + if (error?.name === 'AbortError') { + console.log('Polling cancelled due to component unmount'); + clearInterval(progressInterval); + return; + } + throw error; + } + } + + // BUG-011 FIX: Show timeout modal instead of auto-navigating + clearInterval(progressInterval); + setTimeoutModal({ + show: true, + sessionId: sessionId, + sessionData: sessionData, + tier: tier + }); + + } catch (error) { + clearInterval(progressInterval); + console.error('Error polling for status:', error); + setCreationError('Error verificando el estado de la demo. Por favor, inténtalo de nuevo.'); + } finally { + // Clean up abort controller reference + abortControllerRef.current = null; + } }; - if (loading) { - return ( - -
-
-
-

{t('demo:loading.initial', 'Cargando cuentas demo...')}

-
-
-
- ); - } + // BUG-009 FIX: Cleanup on unmount + React.useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + // BUG-010 FIX: Helper to extract failed services + const getFailedServices = (statusData: any): string[] => { + const failed: string[] = []; + if (statusData.progress) { + if (statusData.progress.parent?.services) { + Object.entries(statusData.progress.parent.services).forEach(([name, service]: [string, any]) => { + if (service?.status === 'failed') { + failed.push(name); + } + }); + } + if (statusData.progress.services) { + Object.entries(statusData.progress.services).forEach(([name, service]: [string, any]) => { + if (service?.status === 'failed') { + failed.push(name); + } + }); + } + } + return failed; + }; + + const updateProgressFromBackendStatus = (statusData, tier) => { + // Calculate progress based on the actual status from backend + if (statusData.progress) { + if (tier === 'enterprise') { + // Handle enterprise progress structure which may be different + // Enterprise demos may have a different progress structure with parent, children, distribution + handleEnterpriseProgress(statusData.progress); + } else { + // Handle individual bakery progress structure + handleIndividualProgress(statusData.progress); + } + } else { + // If no detailed progress available, use estimated progress or increment gradually + setCloneProgress(prev => { + const newProgress = Math.max( + estimatedProgress, + Math.min(prev.overall + 2, 95) // Increment by 2% instead of 1% + ); + return { + ...prev, + overall: newProgress + }; + }); + } + }; + + const handleIndividualProgress = (progress) => { + // Count services that are completed (look for 'completed', 'ready', or 'success' status) + const services = progress || {}; + const completedServices = Object.values(services).filter((service: any) => + service?.status === 'completed' || + service?.status === 'ready' || + service?.status === 'success' || + service?.success === true // Some services might use success flag instead of status + ).length; + + // Estimate overall progress based on services completed + const totalServices = Object.keys(services).length || 11; // Default to 11 known services + const backendProgress = Math.min(95, Math.round((completedServices / totalServices) * 100)); + + // Use the maximum of backend progress and estimated progress to prevent backtracking + const overallProgress = Math.max(backendProgress, estimatedProgress); + + setCloneProgress({ + parent: overallProgress, + children: [0, 0, 0], + distribution: 0, + overall: overallProgress + }); + }; + + const handleEnterpriseProgress = (progress) => { + // For enterprise demos, the progress might have a more complex structure + // It may have parent, children, distribution components + let backendProgress = 0; + let parentProgress = 0; + let childrenProgressArray = [0, 0, 0]; + let distributionProgress = 0; + + // Check if this is the enterprise results structure (with parent/children/distribution) + if (progress.parent && progress.children && progress.distribution !== undefined) { + // This looks like an enterprise results structure from the end of cloning + // Calculate progress based on parent, children, and distribution status + if (progress.parent.overall_status === 'ready' || progress.parent.overall_status === 'partial') { + parentProgress = 100; + } else if (progress.parent.overall_status === 'pending') { + parentProgress = 50; // Increased from 25 for better perceived progress + } else { + // Count parent services for more granular progress + const parentServices = progress.parent.services || {}; + const completedParent = Object.values(parentServices).filter((s: any) => + s?.status === 'completed' || s?.status === 'ready' || s?.status === 'success' + ).length; + const totalParent = Object.keys(parentServices).length || 1; + parentProgress = Math.round((completedParent / totalParent) * 100); + } + + if (progress.children && progress.children.length > 0) { + childrenProgressArray = progress.children.map((child: any) => { + if (child.status === 'ready' || child.status === 'completed') return 100; + if (child.status === 'partial') return 75; + if (child.status === 'pending') return 30; + return 0; + }); + const avgChildrenProgress = childrenProgressArray.reduce((a, b) => a + b, 0) / childrenProgressArray.length; + + // Ensure we have exactly 3 children values for display + while (childrenProgressArray.length < 3) childrenProgressArray.push(0); + childrenProgressArray = childrenProgressArray.slice(0, 3); + + backendProgress = Math.round((parentProgress * 0.4) + (avgChildrenProgress * 0.4)); + } else { + backendProgress = Math.round(parentProgress * 0.8); + } + + if (progress.distribution) { + if (progress.distribution.status === 'ready' || progress.distribution.status === 'completed') { + distributionProgress = 100; + } else if (progress.distribution.status === 'pending') { + distributionProgress = 50; + } else { + distributionProgress = progress.distribution.status === 'failed' ? 100 : 75; + } + backendProgress = Math.round(backendProgress * 0.8 + distributionProgress * 0.2); + } + } else { + // If it's not the enterprise result structure, fall back to service-based calculation + const services = progress || {}; + const completedServices = Object.values(services).filter((service: any) => + service?.status === 'completed' || + service?.status === 'ready' || + service?.status === 'success' || + service?.success === true + ).length; + + const totalServices = Object.keys(services).length || 11; + backendProgress = Math.min(95, Math.round((completedServices / totalServices) * 100)); + parentProgress = backendProgress; + childrenProgressArray = [backendProgress * 0.7, backendProgress * 0.7, backendProgress * 0.7]; + distributionProgress = backendProgress * 0.8; + } + + // Use the maximum of backend progress and estimated progress to prevent backtracking + const overallProgress = Math.max(Math.min(95, backendProgress), estimatedProgress); + + setCloneProgress({ + parent: Math.max(parentProgress, estimatedProgress * 0.9), + children: childrenProgressArray, + distribution: Math.max(distributionProgress, estimatedProgress * 0.7), + overall: overallProgress + }); + }; + + const getIconColor = (color) => { + const colors = { + blue: 'text-blue-600', + purple: 'text-purple-600', + green: 'text-green-600', + orange: 'text-orange-600' + }; + return colors[color] || 'text-blue-600'; + }; return ( {/* Hero Section */} -
+
-
-
- - - {t('demo:hero.badge', 'Demo Interactiva')} - -
- -

- {t('demo:hero.title', 'Prueba El Panadero Digital')} - {t('demo:hero.subtitle', 'sin compromiso')} +
+

+ Prueba Nuestra Plataforma

- -

- {t('demo:hero.description', 'Elige el tipo de panadería que se ajuste a tu negocio')} +

+ Elige tu experiencia de demostración ideal y explora cómo nuestra + plataforma puede transformar tu negocio de panadería

- -
-
- - {t('demo:hero.benefits.no_credit_card', 'Sin tarjeta de crédito')} -
-
- - {t('demo:hero.benefits.access_time', '30 minutos de acceso')} -
-
- - {t('demo:hero.benefits.real_data', 'Datos reales en español')} -
-
-
- - {error && ( -
- {error} -
- )} - - {/* Demo Account Cards */} -
- {demoAccounts.map((account) => { - const Icon = getAccountIcon(account.account_type); - - return ( -
- {/* Gradient overlay */} -
- -
- {/* Header */} -
-
-
- -
-
-

- {account.account_type === 'individual_bakery' - ? t('demo:accounts.individual_bakery.title', 'Panadería Individual con Producción local') - : t('demo:accounts.central_baker.title', 'Panadería Franquiciada con Obrador Central')} -

-

- {account.account_type === 'individual_bakery' - ? t('demo:accounts.individual_bakery.subtitle', account.business_model) - : t('demo:accounts.central_baker.subtitle', 'Punto de Venta + Obrador Central')} -

-
-
- - {t('demo:accounts.demo_badge', 'DEMO')} - -
- - {/* Description */} -

- {account.description} -

- - {/* Key Characteristics */} -
-

- {account.account_type === 'individual_bakery' - ? t('demo:accounts.individual_bakery.characteristics.title', 'Características del negocio') - : t('demo:accounts.central_baker.characteristics.title', 'Características del negocio')} -

-
- {account.account_type === 'individual_bakery' ? ( - <> -
- {t('demo:accounts.individual_bakery.characteristics.employees', 'Empleados')}: - {t('demo:accounts.individual_bakery.characteristics.employees_value', '~8')} -
-
- {t('demo:accounts.individual_bakery.characteristics.shifts', 'Turnos')}: - {t('demo:accounts.individual_bakery.characteristics.shifts_value', '1/día')} -
-
- {t('demo:accounts.individual_bakery.characteristics.sales', 'Ventas')}: - {t('demo:accounts.individual_bakery.characteristics.sales_value', 'Directas')} -
-
- {t('demo:accounts.individual_bakery.characteristics.products', 'Productos')}: - {t('demo:accounts.individual_bakery.characteristics.products_value', 'Local')} -
- - ) : ( - <> -
- {t('demo:accounts.central_baker.characteristics.employees', 'Empleados')}: - {t('demo:accounts.central_baker.characteristics.employees_value', '~5-6')} -
-
- {t('demo:accounts.central_baker.characteristics.shifts', 'Turnos')}: - {t('demo:accounts.central_baker.characteristics.shifts_value', '2/día')} -
-
- {t('demo:accounts.central_baker.characteristics.model', 'Modelo')}: - {t('demo:accounts.central_baker.characteristics.model_value', 'Franquicia')} -
-
- {t('demo:accounts.central_baker.characteristics.products', 'Productos')}: - {t('demo:accounts.central_baker.characteristics.products_value', 'De obrador')} -
- - )} -
-
- - {/* Features */} - {account.features && account.features.length > 0 && ( -
-

- {t('demo:accounts.features_title', 'Funcionalidades incluidas:')} -

- {account.features.map((feature, idx) => ( -
- - {feature} -
- ))} -
- )} - - {/* CTA Button */} - -
-
- ); - })} -
- - {/* Footer CTA */} -
-

- {t('demo:footer.have_account', '¿Ya tienes una cuenta?')} -

- - {t('demo:footer.login_link', 'Inicia sesión aquí')} - -

- {/* Loading Modal Overlay */} - {creatingSession && ( -
-
-
- {/* Animated loader */} -
-
- -
- - {Math.min(progressPercentage, 100)}% - + {/* Main Content */} +
+
+ {/* Demo Options */} +
+ {demoOptions.map((option) => ( + setSelectedTier(option.id)} + > + +
+
+ +
+ {option.title} + + {option.subtitle} + +
+
-
-
+

{option.description}

+ -

- {progressPercentage >= 100 - ? t('demo:loading.ready_title', '¡Listo! Redirigiendo...') - : t('demo:loading.preparing_title', 'Preparando tu Demo')} -

-

- {progressPercentage >= 100 - ? t('demo:loading.ready_description', 'Tu entorno está listo. Accediendo al dashboard...') - : t('demo:loading.preparing_description', 'Configurando tu entorno personalizado con datos de muestra...')} -

+ + {/* Features List */} +
+ {option.features.slice(0, 6).map((feature, index) => ( +
+ + {feature} +
+ ))} + {option.features.length > 6 && ( +
+ + + {option.features.length - 6} funciones más +
+ )} +
- {/* Progress bar */} -
-
-
+ {/* Characteristics Grid */} +
+
+ +
+ Ubicaciones: +

{option.characteristics.locations}

+
+
+
+ +
+ Empleados: +

{option.characteristics.employees}

+
+
+
+ +
+ Producción: +

{option.characteristics.productionModel}

+
+
+
+ +
+ Canales: +

{option.characteristics.salesChannels}

+
+
+
+ - {/* Estimated time - Only show if not complete */} - {progressPercentage < 100 && ( -
- - {t('demo:loading.estimated_time', 'Tiempo estimado: ~{{seconds}}s', { seconds: estimatedTime })} -
- )} + + + + + ))} +
- {/* Tips while loading */} - {progressPercentage < 100 && ( -
-

- {t('demo:loading.tip', '💡 Tip: La demo incluye datos reales de panaderías españolas para que puedas explorar todas las funcionalidades')} + {/* Loading Progress */} + {creatingTier !== null && ( +

+ + + Configurando Tu Demo + + +
+
+ Progreso total + {cloneProgress.overall}% +
+
+
+
+ + {creatingTier === 'enterprise' && ( +
+
+ Obrador Central + {cloneProgress.parent}% +
+
+ {cloneProgress.children.map((progress, index) => ( +
+
Outlet {index + 1}
+
+
+
+
{progress}%
+
+ ))} +
+
+ Distribución + {cloneProgress.distribution}% +
+
+
+
+
+ )} +
+
+
+
+ )} + + {/* Error Alert */} + {creationError && ( + + {creationError} + + )} + + {/* Partial Status Warning Modal */} + {partialWarning?.show && ( + setPartialWarning(null)} + size="md" + > + setPartialWarning(null)} + /> + +
+ + + {partialWarning.message} + + + + {partialWarning.failedServices.length > 0 && ( +
+

+ Servicios con problemas: +

+
    + {partialWarning.failedServices.map((service, index) => ( +
  • {service}
  • + ))} +
+
+ )} + +

+ Puedes continuar con la demo, pero algunas funcionalidades pueden no estar disponibles. + También puedes intentar crear una nueva sesión.

- )} - - {/* Error message if any */} - {error && ( -
- {error} + + +
+ +
- )} +
+ + )} + + {/* Timeout Modal */} + {timeoutModal?.show && ( + setTimeoutModal(null)} + size="md" + > + setTimeoutModal(null)} + /> + +
+ + + La creación de tu sesión demo está tardando más de lo habitual. + Esto puede deberse a alta carga en el sistema. + + + +

+ Tienes las siguientes opciones: +

+ +
+
+

+ 1. Seguir Esperando +

+

+ La sesión puede completarse en cualquier momento. Mantén esta página abierta. +

+
+ +
+

+ 2. Iniciar con Datos Parciales +

+

+ Accede a la demo ahora con los servicios que ya estén listos. +

+
+ +
+

+ 3. Cancelar e Intentar de Nuevo +

+

+ Cancela esta sesión y crea una nueva desde cero. +

+
+
+
+
+ +
+ + + +
+
+
+ )} + + {/* Comparison Section */} +
+

Comparación de Funcionalidades

+
+
+
+

Función

+
+
+
Professional
+
Individual Bakery
+
+
+
Enterprise
+
Chain of Bakeries
+
+
+ + {[ + { feature: 'Número Máximo de Ubicaciones', professional: '1', enterprise: 'Ilimitado' }, + { feature: 'Gestión de Inventario', professional: '✓', enterprise: '✓ Agregado' }, + { feature: 'Forecasting con IA', professional: 'Personalizado', enterprise: 'Agregado + Individual' }, + { feature: 'Planificación de Producción', professional: '✓', enterprise: '✓ Centralizada' }, + { feature: 'Transferencias Internas', professional: '×', enterprise: '✓ Optimizadas' }, + { feature: 'Logística y Rutas', professional: '×', enterprise: '✓ Optimización VRP' }, + { feature: 'Dashboard Multi-ubicación', professional: '×', enterprise: '✓ Visión de Red' }, + { feature: 'Reportes Consolidados', professional: '×', enterprise: '✓ Nivel de Red' } + ].map((row, index) => ( +
+
{row.feature}
+
{row.professional}
+
{row.enterprise}
+
+ ))}
- )} +
); }; -export default DemoPage; +export default DemoPage; \ No newline at end of file diff --git a/frontend/src/stores/useTenantInitializer.ts b/frontend/src/stores/useTenantInitializer.ts index 3ff9ae62..1be6cb88 100644 --- a/frontend/src/stores/useTenantInitializer.ts +++ b/frontend/src/stores/useTenantInitializer.ts @@ -2,6 +2,43 @@ import { useEffect } from 'react'; import { useIsAuthenticated } from './auth.store'; import { useTenantActions, useAvailableTenants, useCurrentTenant } from './tenant.store'; import { useIsDemoMode, useDemoSessionId, useDemoAccountType } from '../hooks/useAccessControl'; +import { SUBSCRIPTION_TIERS, SubscriptionTier } from '../api/types/subscription'; + +/** + * Defines the appropriate subscription tier based on demo account type + */ +const getDemoTierForAccountType = (accountType: string | null): SubscriptionTier => { + switch (accountType) { + case 'enterprise': + return SUBSCRIPTION_TIERS.ENTERPRISE; + case 'professional': + default: + return SUBSCRIPTION_TIERS.PROFESSIONAL; + } +}; + +/** + * Defines appropriate tenant characteristics based on account type + */ +const getTenantDetailsForAccountType = (accountType: string | null) => { + const details = { + professional: { + name: 'Panadería Profesional - Demo', + business_type: 'bakery', + business_model: 'professional', + description: 'Demostración de panadería profesional' + }, + enterprise: { + name: 'Red de Panaderías - Demo', + business_type: 'bakery', + business_model: 'enterprise', + description: 'Demostración de cadena de panaderías enterprise' + } + }; + + const defaultDetails = details.professional; + return details[accountType as keyof typeof details] || defaultDetails; +}; /** * Hook to automatically initialize tenant data when user is authenticated or in demo mode @@ -23,60 +60,74 @@ export const useTenantInitializer = () => { } }, [isAuthenticated, availableTenants, loadUserTenants]); - // Set up mock tenant for demo mode + // Set up mock tenant for demo mode with appropriate subscription tier useEffect(() => { if (isDemoMode && demoSessionId) { - const demoTenantId = localStorage.getItem('demo_tenant_id') || 'demo-tenant-id'; + const virtualTenantId = localStorage.getItem('virtual_tenant_id'); + const storedTier = localStorage.getItem('subscription_tier'); console.log('🔍 [TenantInitializer] Demo mode detected:', { isDemoMode, demoSessionId, - demoTenantId, + virtualTenantId, demoAccountType, + storedTier, currentTenant: currentTenant?.id }); + // Guard: If no virtual_tenant_id is available, skip tenant setup + if (!virtualTenantId) { + console.warn('⚠️ [TenantInitializer] No virtual_tenant_id found in localStorage'); + return; + } + // Check if current tenant is the demo tenant and is properly set const isValidDemoTenant = currentTenant && typeof currentTenant === 'object' && - currentTenant.id === demoTenantId; + currentTenant.id === virtualTenantId; if (!isValidDemoTenant) { console.log('🔧 [TenantInitializer] Setting up demo tenant...'); - const accountTypeName = demoAccountType === 'individual_bakery' - ? 'Panadería San Pablo - Demo' - : 'Panadería La Espiga - Demo'; + // Determine the appropriate subscription tier based on stored value or account type + const subscriptionTier = storedTier as SubscriptionTier || getDemoTierForAccountType(demoAccountType); + + // Get appropriate tenant details based on account type + const tenantDetails = getTenantDetailsForAccountType(demoAccountType); // Create a complete tenant object matching TenantResponse structure const mockTenant = { - id: demoTenantId, - name: accountTypeName, + id: virtualTenantId, + name: tenantDetails.name, subdomain: `demo-${demoSessionId.slice(0, 8)}`, - business_type: demoAccountType === 'individual_bakery' ? 'bakery' : 'central_baker', - business_model: demoAccountType, + business_type: tenantDetails.business_type, + business_model: tenantDetails.business_model, + description: tenantDetails.description, address: 'Demo Address', city: 'Madrid', postal_code: '28001', phone: null, is_active: true, - subscription_tier: 'demo', + subscription_plan: subscriptionTier, // New field name + subscription_tier: subscriptionTier, // Deprecated but kept for backward compatibility ml_model_trained: false, last_training_date: null, owner_id: 'demo-user', created_at: new Date().toISOString(), }; + console.log(`✅ [TenantInitializer] Setting up tenant with tier: ${subscriptionTier}`); + // Set the demo tenant as current setCurrentTenant(mockTenant); // **CRITICAL: Also set tenant ID in API client** // This ensures API requests include the tenant ID header import('../api/client').then(({ apiClient }) => { - apiClient.setTenantId(demoTenantId); - console.log('✅ [TenantInitializer] Set API client tenant ID:', demoTenantId); + apiClient.setTenantId(virtualTenantId); + console.log('✅ [TenantInitializer] Set API client tenant ID:', virtualTenantId); }); } } }, [isDemoMode, demoSessionId, demoAccountType, currentTenant, setCurrentTenant]); -}; \ No newline at end of file +};; \ No newline at end of file diff --git a/gateway/app/middleware/demo_middleware.py b/gateway/app/middleware/demo_middleware.py index e2df6226..b8fa9b49 100644 --- a/gateway/app/middleware/demo_middleware.py +++ b/gateway/app/middleware/demo_middleware.py @@ -8,15 +8,29 @@ from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import Response from typing import Optional +import uuid import httpx import structlog logger = structlog.get_logger() +# Fixed Demo Tenant IDs (these are the template tenants that will be cloned) +# Professional demo (merged from San Pablo + La Espiga) +DEMO_TENANT_PROFESSIONAL = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6") + +# Enterprise chain demo (parent + 3 children) +DEMO_TENANT_ENTERPRISE_CHAIN = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8") +DEMO_TENANT_CHILD_1 = uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9") +DEMO_TENANT_CHILD_2 = uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0") +DEMO_TENANT_CHILD_3 = uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1") + # Demo tenant IDs (base templates) DEMO_TENANT_IDS = { - "a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6", # Panadería San Pablo - "b2c3d4e5-f6g7-h8i9-j0k1-l2m3n4o5p6q7", # Panadería La Espiga + str(DEMO_TENANT_PROFESSIONAL), # Professional demo tenant + str(DEMO_TENANT_ENTERPRISE_CHAIN), # Enterprise chain parent + str(DEMO_TENANT_CHILD_1), # Enterprise chain child 1 + str(DEMO_TENANT_CHILD_2), # Enterprise chain child 2 + str(DEMO_TENANT_CHILD_3), # Enterprise chain child 3 } # Allowed operations for demo accounts (limited write) @@ -117,12 +131,12 @@ class DemoMiddleware(BaseHTTPMiddleware): # Inject demo user context for auth middleware # Map demo account type to the actual demo user IDs from seed_demo_users.py DEMO_USER_IDS = { - "individual_bakery": "c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6", # María García López - "central_baker": "d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7" # Carlos Martínez Ruiz + "professional": "c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6", # María García López + "enterprise": "d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7" # Carlos Martínez Ruiz } demo_user_id = DEMO_USER_IDS.get( - session_info.get("demo_account_type", "individual_bakery"), - DEMO_USER_IDS["individual_bakery"] + session_info.get("demo_account_type", "professional"), + DEMO_USER_IDS["professional"] ) # This allows the request to pass through AuthMiddleware diff --git a/services/demo_session/app/api/demo_accounts.py b/services/demo_session/app/api/demo_accounts.py index add3ac38..33e677bc 100644 --- a/services/demo_session/app/api/demo_accounts.py +++ b/services/demo_session/app/api/demo_accounts.py @@ -32,16 +32,16 @@ async def get_demo_accounts(): "password": "DemoSanPablo2024!" if "sanpablo" in config["email"] else "DemoLaEspiga2024!", "description": ( "Panadería individual que produce todo localmente" - if account_type == "individual_bakery" + if account_type == "professional" else "Punto de venta con obrador central" ), "features": ( ["Gestión de Producción", "Recetas", "Inventario", "Ventas", "Previsión de Demanda"] - if account_type == "individual_bakery" + if account_type == "professional" else ["Gestión de Proveedores", "Pedidos", "Inventario", "Ventas", "Previsión de Demanda"] ), "business_model": ( - "Producción Local" if account_type == "individual_bakery" else "Obrador Central + Punto de Venta" + "Producción Local" if account_type == "professional" else "Obrador Central + Punto de Venta" ) }) diff --git a/services/demo_session/app/api/schemas.py b/services/demo_session/app/api/schemas.py index cc7346a6..4a4fa3dc 100644 --- a/services/demo_session/app/api/schemas.py +++ b/services/demo_session/app/api/schemas.py @@ -9,7 +9,8 @@ from datetime import datetime class DemoSessionCreate(BaseModel): """Create demo session request""" - demo_account_type: str = Field(..., description="individual_bakery or central_baker") + demo_account_type: str = Field(..., description="professional or enterprise") + subscription_tier: Optional[str] = Field(None, description="Force specific subscription tier (professional/enterprise)") user_id: Optional[str] = Field(None, description="Optional authenticated user ID") ip_address: Optional[str] = None user_agent: Optional[str] = None diff --git a/services/demo_session/app/core/config.py b/services/demo_session/app/core/config.py index e99c1b6a..de5d7aa5 100644 --- a/services/demo_session/app/core/config.py +++ b/services/demo_session/app/core/config.py @@ -3,26 +3,29 @@ Demo Session Service Configuration """ import os -from pydantic_settings import BaseSettings from typing import Optional +from shared.config.base import BaseServiceSettings -class Settings(BaseSettings): +class Settings(BaseServiceSettings): """Demo Session Service Settings""" - # Service info + # Service info (override base settings) + APP_NAME: str = "Demo Session Service" SERVICE_NAME: str = "demo-session" VERSION: str = "1.0.0" - DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true" + DESCRIPTION: str = "Demo session management and orchestration service" - # Database - DATABASE_URL: str = os.getenv( - "DEMO_SESSION_DATABASE_URL", - "postgresql+asyncpg://postgres:postgres@localhost:5432/demo_session_db" - ) + # Database (override base property) + @property + def DATABASE_URL(self) -> str: + """Build database URL from environment""" + return os.getenv( + "DEMO_SESSION_DATABASE_URL", + "postgresql+asyncpg://postgres:postgres@localhost:5432/demo_session_db" + ) - # Redis - REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0") + # Redis configuration (demo-specific) REDIS_KEY_PREFIX: str = "demo:session" REDIS_SESSION_TTL: int = 1800 # 30 minutes @@ -33,33 +36,47 @@ class Settings(BaseSettings): # Demo account credentials (public) DEMO_ACCOUNTS: dict = { - "individual_bakery": { - "email": "demo.individual@panaderiasanpablo.com", - "name": "Panadería San Pablo - Demo", - "subdomain": "demo-sanpablo", - "base_tenant_id": "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6" + "professional": { + "email": "demo.professional@panaderiaartesana.com", + "name": "Panadería Artesana Madrid - Demo", + "subdomain": "demo-artesana", + "base_tenant_id": "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6", + "subscription_tier": "professional", + "tenant_type": "standalone" }, - "central_baker": { - "email": "demo.central@panaderialaespiga.com", - "name": "Panadería La Espiga - Demo", - "subdomain": "demo-laespiga", - "base_tenant_id": "b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7" + "enterprise": { + "email": "demo.enterprise@panaderiacentral.com", + "name": "Panadería Central - Demo Enterprise", + "subdomain": "demo-central", + "base_tenant_id": "c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8", + "subscription_tier": "enterprise", + "tenant_type": "parent", + "children": [ + { + "name": "Madrid Centro", + "base_tenant_id": "d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9", + "location": {"city": "Madrid", "zone": "Centro", "latitude": 40.4168, "longitude": -3.7038} + }, + { + "name": "Barcelona Gràcia", + "base_tenant_id": "e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0", + "location": {"city": "Barcelona", "zone": "Gràcia", "latitude": 41.4036, "longitude": 2.1561} + }, + { + "name": "Valencia Ruzafa", + "base_tenant_id": "f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1", + "location": {"city": "Valencia", "zone": "Ruzafa", "latitude": 39.4623, "longitude": -0.3645} + } + ] } } - # Service URLs - AUTH_SERVICE_URL: str = os.getenv("AUTH_SERVICE_URL", "http://auth-service:8000") - TENANT_SERVICE_URL: str = os.getenv("TENANT_SERVICE_URL", "http://tenant-service:8000") - INVENTORY_SERVICE_URL: str = os.getenv("INVENTORY_SERVICE_URL", "http://inventory-service:8000") - RECIPES_SERVICE_URL: str = os.getenv("RECIPES_SERVICE_URL", "http://recipes-service:8000") - SALES_SERVICE_URL: str = os.getenv("SALES_SERVICE_URL", "http://sales-service:8000") - ORDERS_SERVICE_URL: str = os.getenv("ORDERS_SERVICE_URL", "http://orders-service:8000") - PRODUCTION_SERVICE_URL: str = os.getenv("PRODUCTION_SERVICE_URL", "http://production-service:8000") - SUPPLIERS_SERVICE_URL: str = os.getenv("SUPPLIERS_SERVICE_URL", "http://suppliers-service:8000") - ORCHESTRATOR_SERVICE_URL: str = os.getenv("ORCHESTRATOR_SERVICE_URL", "http://orchestrator-service:8000") - - # Logging - LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") + # Service URLs - these are inherited from BaseServiceSettings + # but we can override defaults if needed: + # - GATEWAY_URL (inherited) + # - AUTH_SERVICE_URL, TENANT_SERVICE_URL, etc. (inherited) + # - JWT_SECRET_KEY, JWT_ALGORITHM (inherited) + # - LOG_LEVEL (inherited) class Config: env_file = ".env" diff --git a/services/demo_session/app/models/demo_session.py b/services/demo_session/app/models/demo_session.py index 1598272d..1e05ccb9 100644 --- a/services/demo_session/app/models/demo_session.py +++ b/services/demo_session/app/models/demo_session.py @@ -46,7 +46,7 @@ class DemoSession(Base): # Demo tenant linking base_demo_tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True) virtual_tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True) - demo_account_type = Column(String(50), nullable=False) # 'individual_bakery', 'central_baker' + demo_account_type = Column(String(50), nullable=False) # 'professional', 'enterprise' # Session lifecycle status = Column(SQLEnum(DemoSessionStatus, values_callable=lambda obj: [e.value for e in obj]), default=DemoSessionStatus.PENDING, index=True) diff --git a/services/demo_session/app/services/cleanup_service.py b/services/demo_session/app/services/cleanup_service.py index 10d7e933..6c4f9edb 100644 --- a/services/demo_session/app/services/cleanup_service.py +++ b/services/demo_session/app/services/cleanup_service.py @@ -81,7 +81,34 @@ class DemoCleanupService: session.status = DemoSessionStatus.EXPIRED await self.db.commit() - # Delete session data + # Check if this is an enterprise demo with children + is_enterprise = session.demo_account_type == "enterprise" + child_tenant_ids = [] + + if is_enterprise and session.session_metadata: + child_tenant_ids = session.session_metadata.get("child_tenant_ids", []) + + # Delete child tenants first (for enterprise demos) + if child_tenant_ids: + logger.info( + "Cleaning up enterprise demo children", + session_id=session.session_id, + child_count=len(child_tenant_ids) + ) + for child_id in child_tenant_ids: + try: + await self.data_cloner.delete_session_data( + str(child_id), + session.session_id + ) + except Exception as child_error: + logger.error( + "Failed to delete child tenant", + child_id=child_id, + error=str(child_error) + ) + + # Delete parent/main session data await self.data_cloner.delete_session_data( str(session.virtual_tenant_id), session.session_id @@ -92,6 +119,8 @@ class DemoCleanupService: logger.info( "Session cleaned up", session_id=session.session_id, + is_enterprise=is_enterprise, + children_deleted=len(child_tenant_ids), age_minutes=(now - session.created_at).total_seconds() / 60 ) diff --git a/services/demo_session/app/services/clone_orchestrator.py b/services/demo_session/app/services/clone_orchestrator.py index b3b70fef..a8e13184 100644 --- a/services/demo_session/app/services/clone_orchestrator.py +++ b/services/demo_session/app/services/clone_orchestrator.py @@ -30,7 +30,8 @@ class CloneOrchestrator: """Orchestrates parallel demo data cloning across services""" def __init__(self): - self.internal_api_key = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production") + from app.core.config import settings + self.internal_api_key = settings.INTERNAL_API_KEY # Define services that participate in cloning # URLs should be internal Kubernetes service names @@ -114,7 +115,9 @@ class CloneOrchestrator: base_tenant_id: str, virtual_tenant_id: str, demo_account_type: str, - session_id: str + session_id: str, + session_metadata: Optional[Dict[str, Any]] = None, + services_filter: Optional[List[str]] = None ) -> Dict[str, Any]: """ Orchestrate cloning across all services in parallel @@ -124,103 +127,186 @@ class CloneOrchestrator: virtual_tenant_id: Target virtual tenant UUID demo_account_type: Type of demo account session_id: Session ID for tracing + session_metadata: Additional session metadata (for enterprise demos) + services_filter: Optional list of service names to clone (BUG-007 fix) Returns: Dictionary with overall status and per-service results """ + # BUG-007 FIX: Filter services if specified + services_to_clone = self.services + if services_filter: + services_to_clone = [s for s in self.services if s.name in services_filter] + logger.info( + f"Filtering to {len(services_to_clone)} services", + session_id=session_id, + services_filter=services_filter + ) + logger.info( "Starting orchestrated cloning", session_id=session_id, virtual_tenant_id=virtual_tenant_id, demo_account_type=demo_account_type, - service_count=len(self.services) + service_count=len(services_to_clone), + is_enterprise=demo_account_type == "enterprise" ) + # Check if this is an enterprise demo + if demo_account_type == "enterprise" and session_metadata: + # Validate that this is actually an enterprise demo based on metadata + is_enterprise = session_metadata.get("is_enterprise", False) + child_configs = session_metadata.get("child_configs", []) + child_tenant_ids = session_metadata.get("child_tenant_ids", []) + + if not is_enterprise: + logger.warning( + "Enterprise cloning requested for non-enterprise session", + session_id=session_id, + demo_account_type=demo_account_type + ) + elif not child_configs or not child_tenant_ids: + logger.warning( + "Enterprise cloning requested without proper child configuration", + session_id=session_id, + child_config_count=len(child_configs), + child_tenant_id_count=len(child_tenant_ids) + ) + + return await self._clone_enterprise_demo( + base_tenant_id, + virtual_tenant_id, + session_id, + session_metadata + ) + + # Additional validation: if account type is not enterprise but has enterprise metadata, log a warning + elif session_metadata and session_metadata.get("is_enterprise", False): + logger.warning( + "Non-enterprise account type with enterprise metadata detected", + session_id=session_id, + demo_account_type=demo_account_type + ) + start_time = datetime.now(timezone.utc) - # Create tasks for all services + # BUG-006 EXTENSION: Rollback stack for professional demos + rollback_stack = [] + + # BUG-007 FIX: Create tasks for filtered services tasks = [] service_map = {} - for service_def in self.services: - task = asyncio.create_task( - self._clone_service( - service_def=service_def, - base_tenant_id=base_tenant_id, - virtual_tenant_id=virtual_tenant_id, - demo_account_type=demo_account_type, - session_id=session_id + try: + for service_def in services_to_clone: + task = asyncio.create_task( + self._clone_service( + service_def=service_def, + base_tenant_id=base_tenant_id, + virtual_tenant_id=virtual_tenant_id, + demo_account_type=demo_account_type, + session_id=session_id, + session_metadata=session_metadata + ) ) - ) - tasks.append(task) - service_map[task] = service_def.name + tasks.append(task) + service_map[task] = service_def.name - # Wait for all tasks to complete (with individual timeouts) - results = await asyncio.gather(*tasks, return_exceptions=True) + # Wait for all tasks to complete (with individual timeouts) + results = await asyncio.gather(*tasks, return_exceptions=True) - # Process results - service_results = {} - total_records = 0 - failed_services = [] - required_service_failed = False + # Process results + service_results = {} + total_records = 0 + failed_services = [] + required_service_failed = False - for task, result in zip(tasks, results): - service_name = service_map[task] - service_def = next(s for s in self.services if s.name == service_name) + for task, result in zip(tasks, results): + service_name = service_map[task] + service_def = next(s for s in services_to_clone if s.name == service_name) - if isinstance(result, Exception): - logger.error( - "Service cloning failed with exception", - service=service_name, - error=str(result) - ) - service_results[service_name] = { - "status": CloningStatus.FAILED.value, - "records_cloned": 0, - "error": str(result), - "duration_ms": 0 - } - failed_services.append(service_name) - if service_def.required: - required_service_failed = True - else: - service_results[service_name] = result - if result.get("status") == "completed": - total_records += result.get("records_cloned", 0) - elif result.get("status") == "failed": + if isinstance(result, Exception): + logger.error( + "Service cloning failed with exception", + service=service_name, + error=str(result) + ) + service_results[service_name] = { + "status": CloningStatus.FAILED.value, + "records_cloned": 0, + "error": str(result), + "duration_ms": 0 + } failed_services.append(service_name) if service_def.required: required_service_failed = True + else: + service_results[service_name] = result + if result.get("status") == "completed": + total_records += result.get("records_cloned", 0) + # BUG-006 EXTENSION: Track successful services for rollback + rollback_stack.append({ + "service": service_name, + "virtual_tenant_id": virtual_tenant_id, + "session_id": session_id + }) + elif result.get("status") == "failed": + failed_services.append(service_name) + if service_def.required: + required_service_failed = True - # Determine overall status - if required_service_failed: - overall_status = "failed" - elif failed_services: - overall_status = "partial" - else: - overall_status = "ready" + # Determine overall status + if required_service_failed: + overall_status = "failed" + elif failed_services: + overall_status = "partial" + else: + overall_status = "ready" - duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) + duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) - result = { - "overall_status": overall_status, - "total_records_cloned": total_records, - "duration_ms": duration_ms, - "services": service_results, - "failed_services": failed_services, - "completed_at": datetime.now(timezone.utc).isoformat() - } + result = { + "overall_status": overall_status, + "total_records_cloned": total_records, + "duration_ms": duration_ms, + "services": service_results, + "failed_services": failed_services, + "completed_at": datetime.now(timezone.utc).isoformat() + } - logger.info( - "Orchestrated cloning completed", - session_id=session_id, - overall_status=overall_status, - total_records=total_records, - duration_ms=duration_ms, - failed_services=failed_services - ) + logger.info( + "Orchestrated cloning completed", + session_id=session_id, + overall_status=overall_status, + total_records=total_records, + duration_ms=duration_ms, + failed_services=failed_services + ) - return result + return result + + except Exception as e: + logger.error("Professional demo cloning failed with fatal exception", error=str(e), exc_info=True) + + # BUG-006 EXTENSION: Rollback professional demo on fatal exception + logger.warning("Fatal exception in professional demo, initiating rollback", session_id=session_id) + await self._rollback_professional_demo(rollback_stack, virtual_tenant_id) + + duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) + + return { + "overall_status": "failed", + "total_records_cloned": 0, + "duration_ms": duration_ms, + "services": {}, + "failed_services": [], + "error": f"Fatal exception, resources rolled back: {str(e)}", + "recovery_info": { + "services_completed": len(rollback_stack), + "rollback_performed": True + }, + "completed_at": datetime.now(timezone.utc).isoformat() + } async def _clone_service( self, @@ -228,7 +314,8 @@ class CloneOrchestrator: base_tenant_id: str, virtual_tenant_id: str, demo_account_type: str, - session_id: str + session_id: str, + session_metadata: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """ Clone data from a single service @@ -255,15 +342,22 @@ class CloneOrchestrator: # Get session creation time for date adjustment session_created_at = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') + params = { + "base_tenant_id": base_tenant_id, + "virtual_tenant_id": virtual_tenant_id, + "demo_account_type": demo_account_type, + "session_id": session_id, + "session_created_at": session_created_at + } + + # Add session metadata if available + if session_metadata: + import json + params["session_metadata"] = json.dumps(session_metadata) + response = await client.post( f"{service_def.url}/internal/demo/clone", - params={ - "base_tenant_id": base_tenant_id, - "virtual_tenant_id": virtual_tenant_id, - "demo_account_type": demo_account_type, - "session_id": session_id, - "session_created_at": session_created_at - }, + params=params, headers={ "X-Internal-API-Key": self.internal_api_key } @@ -356,3 +450,472 @@ class CloneOrchestrator: return response.status_code == 200 except Exception: return False + + async def _clone_enterprise_demo( + self, + base_tenant_id: str, + parent_tenant_id: str, + session_id: str, + session_metadata: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Clone enterprise demo (parent + children + distribution) with timeout protection + + Args: + base_tenant_id: Base template tenant ID for parent + parent_tenant_id: Virtual tenant ID for parent + session_id: Session ID + session_metadata: Session metadata with child configs + + Returns: + Dictionary with cloning results + """ + # BUG-005 FIX: Wrap implementation with overall timeout + try: + return await asyncio.wait_for( + self._clone_enterprise_demo_impl( + base_tenant_id=base_tenant_id, + parent_tenant_id=parent_tenant_id, + session_id=session_id, + session_metadata=session_metadata + ), + timeout=300.0 # 5 minutes max for entire enterprise flow + ) + except asyncio.TimeoutError: + logger.error( + "Enterprise demo cloning timed out", + session_id=session_id, + timeout_seconds=300 + ) + return { + "overall_status": "failed", + "error": "Enterprise cloning timed out after 5 minutes", + "parent": {}, + "children": [], + "distribution": {}, + "duration_ms": 300000 + } + + async def _clone_enterprise_demo_impl( + self, + base_tenant_id: str, + parent_tenant_id: str, + session_id: str, + session_metadata: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Implementation of enterprise demo cloning (called by timeout wrapper) + + Args: + base_tenant_id: Base template tenant ID for parent + parent_tenant_id: Virtual tenant ID for parent + session_id: Session ID + session_metadata: Session metadata with child configs + + Returns: + Dictionary with cloning results + """ + logger.info( + "Starting enterprise demo cloning", + session_id=session_id, + parent_tenant_id=parent_tenant_id + ) + + start_time = datetime.now(timezone.utc) + results = { + "parent": {}, + "children": [], + "distribution": {}, + "overall_status": "pending" + } + + # BUG-006 FIX: Track resources for rollback + rollback_stack = [] + + try: + # Step 1: Clone parent tenant + logger.info("Cloning parent tenant", session_id=session_id) + parent_result = await self.clone_all_services( + base_tenant_id=base_tenant_id, + virtual_tenant_id=parent_tenant_id, + demo_account_type="enterprise", + session_id=session_id + ) + results["parent"] = parent_result + + # BUG-006 FIX: Track parent for potential rollback + if parent_result.get("overall_status") not in ["failed"]: + rollback_stack.append({ + "type": "tenant", + "tenant_id": parent_tenant_id, + "session_id": session_id + }) + + # BUG-003 FIX: Validate parent cloning succeeded before proceeding + parent_status = parent_result.get("overall_status") + + if parent_status == "failed": + logger.error( + "Parent cloning failed, aborting enterprise demo", + session_id=session_id, + failed_services=parent_result.get("failed_services", []) + ) + results["overall_status"] = "failed" + results["error"] = "Parent tenant cloning failed" + results["duration_ms"] = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) + return results + + if parent_status == "partial": + logger.warning( + "Parent cloning partial, checking if critical services succeeded", + session_id=session_id + ) + # Check if tenant service succeeded (critical for children) + parent_services = parent_result.get("services", {}) + if parent_services.get("tenant", {}).get("status") != "completed": + logger.error( + "Tenant service failed in parent, cannot create children", + session_id=session_id + ) + results["overall_status"] = "failed" + results["error"] = "Parent tenant creation failed - cannot create child tenants" + results["duration_ms"] = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) + return results + + logger.info( + "Parent cloning succeeded, proceeding with children", + session_id=session_id, + parent_status=parent_status + ) + + # Step 2: Clone each child outlet in parallel + child_configs = session_metadata.get("child_configs", []) + child_tenant_ids = session_metadata.get("child_tenant_ids", []) + + if child_configs and child_tenant_ids: + logger.info( + "Cloning child outlets", + session_id=session_id, + child_count=len(child_configs) + ) + + child_tasks = [] + for idx, (child_config, child_id) in enumerate(zip(child_configs, child_tenant_ids)): + task = self._clone_child_outlet( + base_tenant_id=child_config["base_tenant_id"], + virtual_child_id=child_id, + parent_tenant_id=parent_tenant_id, + child_name=child_config["name"], + location=child_config["location"], + session_id=session_id + ) + child_tasks.append(task) + + children_results = await asyncio.gather(*child_tasks, return_exceptions=True) + results["children"] = [ + r if not isinstance(r, Exception) else {"status": "failed", "error": str(r)} + for r in children_results + ] + + # BUG-006 FIX: Track children for potential rollback + for child_result in results["children"]: + if child_result.get("status") not in ["failed"]: + rollback_stack.append({ + "type": "tenant", + "tenant_id": child_result.get("child_id"), + "session_id": session_id + }) + + # Step 3: Setup distribution data + distribution_url = os.getenv("DISTRIBUTION_SERVICE_URL", "http://distribution-service:8000") + logger.info("Setting up distribution data", session_id=session_id, distribution_url=distribution_url) + + try: + async with httpx.AsyncClient(timeout=120.0) as client: # Increased timeout for distribution setup + response = await client.post( + f"{distribution_url}/internal/demo/setup", + json={ + "parent_tenant_id": parent_tenant_id, + "child_tenant_ids": child_tenant_ids, + "session_id": session_id, + "session_metadata": session_metadata # Pass metadata for date adjustment + }, + headers={"X-Internal-API-Key": self.internal_api_key} + ) + + if response.status_code == 200: + results["distribution"] = response.json() + logger.info("Distribution setup completed successfully", session_id=session_id) + else: + error_detail = response.text if response.text else f"HTTP {response.status_code}" + results["distribution"] = { + "status": "failed", + "error": error_detail + } + logger.error(f"Distribution setup failed: {error_detail}", session_id=session_id) + + # BUG-006 FIX: Rollback on distribution failure + logger.warning("Distribution failed, initiating rollback", session_id=session_id) + await self._rollback_enterprise_demo(rollback_stack) + results["overall_status"] = "failed" + results["error"] = f"Distribution setup failed, resources rolled back: {error_detail}" + results["duration_ms"] = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) + return results + + except Exception as e: + logger.error("Distribution setup failed", error=str(e), exc_info=True) + results["distribution"] = {"status": "failed", "error": str(e)} + + # BUG-006 FIX: Rollback on distribution exception + logger.warning("Distribution exception, initiating rollback", session_id=session_id) + await self._rollback_enterprise_demo(rollback_stack) + results["overall_status"] = "failed" + results["error"] = f"Distribution setup exception, resources rolled back: {str(e)}" + results["duration_ms"] = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) + return results + + # BUG-004 FIX: Stricter status determination + # Only mark as "ready" if ALL components fully succeeded + parent_ready = parent_result.get("overall_status") == "ready" + all_children_ready = all(r.get("status") == "ready" for r in results["children"]) + distribution_ready = results["distribution"].get("status") == "completed" + + # Check for failures + parent_failed = parent_result.get("overall_status") == "failed" + any_child_failed = any(r.get("status") == "failed" for r in results["children"]) + distribution_failed = results["distribution"].get("status") == "failed" + + if parent_ready and all_children_ready and distribution_ready: + results["overall_status"] = "ready" + logger.info("Enterprise demo fully ready", session_id=session_id) + elif parent_failed or any_child_failed or distribution_failed: + results["overall_status"] = "failed" + logger.error("Enterprise demo failed", session_id=session_id) + else: + results["overall_status"] = "partial" + results["warning"] = "Some services did not fully clone" + logger.warning("Enterprise demo partially complete", session_id=session_id) + + results["duration_ms"] = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) + + logger.info( + "Enterprise demo cloning completed", + session_id=session_id, + overall_status=results["overall_status"], + duration_ms=results["duration_ms"] + ) + + except Exception as e: + logger.error("Enterprise demo cloning failed", error=str(e), exc_info=True) + + # BUG-006 FIX: Rollback on fatal exception + logger.warning("Fatal exception, initiating rollback", session_id=session_id) + await self._rollback_enterprise_demo(rollback_stack) + + results["overall_status"] = "failed" + results["error"] = f"Fatal exception, resources rolled back: {str(e)}" + results["recovery_info"] = { + "parent_completed": bool(results.get("parent")), + "children_completed": len(results.get("children", [])), + "distribution_attempted": bool(results.get("distribution")) + } + + return results + + async def _clone_child_outlet( + self, + base_tenant_id: str, + virtual_child_id: str, + parent_tenant_id: str, + child_name: str, + location: dict, + session_id: str + ) -> Dict[str, Any]: + """Clone data for a single child outlet""" + logger.info( + "Cloning child outlet", + session_id=session_id, + child_name=child_name, + virtual_child_id=virtual_child_id + ) + + try: + # First, create the child tenant with parent relationship + tenant_url = os.getenv("TENANT_SERVICE_URL", "http://tenant-service:8000") + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{tenant_url}/internal/demo/create-child", + json={ + "base_tenant_id": base_tenant_id, + "virtual_tenant_id": virtual_child_id, + "parent_tenant_id": parent_tenant_id, + "child_name": child_name, + "location": location, + "session_id": session_id + }, + headers={"X-Internal-API-Key": self.internal_api_key} + ) + + if response.status_code != 200: + return { + "child_id": virtual_child_id, + "child_name": child_name, + "status": "failed", + "error": f"Tenant creation failed: HTTP {response.status_code}" + } + + # BUG-007 FIX: Clone child-specific services only + # Children (retail outlets) only need: tenant, inventory, sales, orders, pos, forecasting + child_services_to_clone = ["tenant", "inventory", "sales", "orders", "pos", "forecasting"] + + child_results = await self.clone_all_services( + base_tenant_id=base_tenant_id, + virtual_tenant_id=virtual_child_id, + demo_account_type="enterprise_child", + session_id=session_id, + services_filter=child_services_to_clone # Now actually used! + ) + + return { + "child_id": virtual_child_id, + "child_name": child_name, + "status": child_results.get("overall_status", "completed"), + "records_cloned": child_results.get("total_records_cloned", 0) + } + + except Exception as e: + logger.error("Child outlet cloning failed", error=str(e), child_name=child_name) + return { + "child_id": virtual_child_id, + "child_name": child_name, + "status": "failed", + "error": str(e) + } + + async def _rollback_enterprise_demo(self, rollback_stack: List[Dict[str, Any]]): + """ + Rollback enterprise demo resources using cleanup endpoints + + Args: + rollback_stack: List of resources to rollback (in reverse order) + + Note: + This is a best-effort rollback. Some resources may fail to clean up, + but we log errors and continue to attempt cleanup of remaining resources. + """ + if not rollback_stack: + logger.info("No resources to rollback") + return + + logger.info(f"Starting rollback of {len(rollback_stack)} resources") + + # Rollback in reverse order (LIFO - Last In First Out) + for resource in reversed(rollback_stack): + try: + if resource["type"] == "tenant": + tenant_id = resource["tenant_id"] + session_id = resource.get("session_id") + + logger.info( + "Rolling back tenant", + tenant_id=tenant_id, + session_id=session_id + ) + + # Call demo session cleanup endpoint for this tenant + # This will trigger cleanup across all services + demo_session_url = os.getenv("DEMO_SESSION_SERVICE_URL", "http://demo-session-service:8000") + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{demo_session_url}/internal/demo/cleanup", + json={ + "tenant_id": tenant_id, + "session_id": session_id + }, + headers={"X-Internal-API-Key": self.internal_api_key} + ) + + if response.status_code == 200: + logger.info(f"Successfully rolled back tenant {tenant_id}") + else: + logger.error( + f"Failed to rollback tenant {tenant_id}: HTTP {response.status_code}", + response_text=response.text + ) + + except Exception as e: + logger.error( + f"Error during rollback of resource {resource}", + error=str(e), + exc_info=True + ) + # Continue with remaining rollbacks despite errors + + logger.info(f"Rollback completed for {len(rollback_stack)} resources") + + async def _rollback_professional_demo(self, rollback_stack: List[Dict[str, Any]], virtual_tenant_id: str): + """ + BUG-006 EXTENSION: Rollback professional demo resources using cleanup endpoints + + Args: + rollback_stack: List of successfully cloned services + virtual_tenant_id: Virtual tenant ID to clean up + + Note: + Similar to enterprise rollback but simpler - single tenant cleanup + """ + if not rollback_stack: + logger.info("No resources to rollback for professional demo") + return + + logger.info( + f"Starting professional demo rollback", + virtual_tenant_id=virtual_tenant_id, + services_count=len(rollback_stack) + ) + + # Call each service's cleanup endpoint + for resource in reversed(rollback_stack): + try: + service_name = resource["service"] + session_id = resource["session_id"] + + logger.info( + "Rolling back service", + service=service_name, + virtual_tenant_id=virtual_tenant_id + ) + + # Find service definition + service_def = next((s for s in self.services if s.name == service_name), None) + if not service_def: + logger.warning(f"Service definition not found for {service_name}, skipping rollback") + continue + + # Call service cleanup endpoint + cleanup_url = f"{service_def.url}/internal/demo/tenant/{virtual_tenant_id}" + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.delete( + cleanup_url, + headers={"X-Internal-API-Key": self.internal_api_key} + ) + + if response.status_code == 200: + logger.info(f"Successfully rolled back {service_name}") + else: + logger.warning( + f"Rollback returned non-200 status for {service_name}", + status_code=response.status_code + ) + + except Exception as e: + logger.error( + f"Error during rollback of service {resource.get('service')}", + error=str(e), + exc_info=True + ) + # Continue with remaining rollbacks despite errors + + logger.info(f"Professional demo rollback completed for {len(rollback_stack)} services") diff --git a/services/demo_session/app/services/data_cloner.py b/services/demo_session/app/services/data_cloner.py index f09e134a..47fa0724 100644 --- a/services/demo_session/app/services/data_cloner.py +++ b/services/demo_session/app/services/data_cloner.py @@ -98,15 +98,15 @@ class DemoDataCloner: """Get list of services to clone based on demo type""" base_services = ["inventory", "sales", "orders", "pos"] - if demo_account_type == "individual_bakery": - # Individual bakery has production, recipes, suppliers, and procurement + if demo_account_type == "professional": + # Professional has production, recipes, suppliers, and procurement return base_services + ["recipes", "production", "suppliers", "procurement"] - elif demo_account_type == "central_baker": - # Central baker satellite has suppliers and procurement + elif demo_account_type == "enterprise": + # Enterprise has suppliers and procurement return base_services + ["suppliers", "procurement"] else: # Basic tenant has suppliers and procurement - return base_services + ["suppliers", "procurement"] + return base_services + ["suppliers", "procurement", "distribution"] async def _clone_service_data( self, @@ -131,8 +131,9 @@ class DemoDataCloner: """ service_url = self._get_service_url(service_name) - # Get internal API key from environment - internal_api_key = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production") + # Get internal API key from settings + from app.core.config import settings + internal_api_key = settings.INTERNAL_API_KEY async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post( @@ -143,7 +144,7 @@ class DemoDataCloner: "session_id": session_id, "demo_account_type": demo_account_type }, - headers={"X-Internal-Api-Key": internal_api_key} + headers={"X-Internal-API-Key": internal_api_key} ) response.raise_for_status() @@ -249,6 +250,8 @@ class DemoDataCloner: "suppliers": settings.SUPPLIERS_SERVICE_URL, "pos": settings.POS_SERVICE_URL, "procurement": settings.PROCUREMENT_SERVICE_URL, + "distribution": settings.DISTRIBUTION_SERVICE_URL, + "forecasting": settings.FORECASTING_SERVICE_URL, } return url_map.get(service_name, "") @@ -281,6 +284,7 @@ class DemoDataCloner: "recipes", # Core data "suppliers", # Core data "pos", # Point of sale data + "distribution", # Distribution routes "procurement" # Procurement and purchase orders ] @@ -303,11 +307,12 @@ class DemoDataCloner: """Delete data from a specific service""" service_url = self._get_service_url(service_name) - # Get internal API key from environment - internal_api_key = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production") + # Get internal API key from settings + from app.core.config import settings + internal_api_key = settings.INTERNAL_API_KEY async with httpx.AsyncClient(timeout=30.0) as client: await client.delete( f"{service_url}/internal/demo/tenant/{virtual_tenant_id}", - headers={"X-Internal-Api-Key": internal_api_key} + headers={"X-Internal-API-Key": internal_api_key} ) diff --git a/services/demo_session/app/services/session_manager.py b/services/demo_session/app/services/session_manager.py index 8587e238..e2444ccc 100644 --- a/services/demo_session/app/services/session_manager.py +++ b/services/demo_session/app/services/session_manager.py @@ -9,6 +9,7 @@ from typing import Optional, Dict, Any import uuid import secrets import structlog +from sqlalchemy import select from app.models import DemoSession, DemoSessionStatus, CloningStatus from app.core.redis_wrapper import DemoRedisWrapper @@ -31,6 +32,7 @@ class DemoSessionManager: async def create_session( self, demo_account_type: str, + subscription_tier: Optional[str] = None, user_id: Optional[str] = None, ip_address: Optional[str] = None, user_agent: Optional[str] = None @@ -39,7 +41,8 @@ class DemoSessionManager: Create a new demo session Args: - demo_account_type: 'individual_bakery' or 'central_baker' + demo_account_type: 'professional' or 'enterprise' + subscription_tier: Force specific subscription tier (professional/enterprise) user_id: Optional user ID if authenticated ip_address: Client IP address user_agent: Client user agent @@ -47,7 +50,9 @@ class DemoSessionManager: Returns: Created demo session """ - logger.info("Creating demo session", demo_account_type=demo_account_type) + logger.info("Creating demo session", + demo_account_type=demo_account_type, + subscription_tier=subscription_tier) # Generate unique session ID session_id = f"demo_{secrets.token_urlsafe(16)}" @@ -60,6 +65,9 @@ class DemoSessionManager: if not demo_config: raise ValueError(f"Invalid demo account type: {demo_account_type}") + # Override subscription tier if specified + effective_subscription_tier = subscription_tier or demo_config.get("subscription_tier") + # Get base tenant ID for cloning base_tenant_id_str = demo_config.get("base_tenant_id") if not base_tenant_id_str: @@ -67,6 +75,20 @@ class DemoSessionManager: base_tenant_id = uuid.UUID(base_tenant_id_str) + # Validate that the base tenant ID exists in the tenant service + # This is important to prevent cloning from non-existent base tenants + await self._validate_base_tenant_exists(base_tenant_id, demo_account_type) + + # Handle enterprise chain setup + child_tenant_ids = [] + if demo_account_type == 'enterprise': + # Validate child template tenants exist before proceeding + child_configs = demo_config.get('children', []) + await self._validate_child_template_tenants(child_configs) + + # Generate child tenant IDs for enterprise demos + child_tenant_ids = [uuid.uuid4() for _ in child_configs] + # Create session record using repository session_data = { "session_id": session_id, @@ -86,7 +108,11 @@ class DemoSessionManager: "redis_populated": False, "session_metadata": { "demo_config": demo_config, - "extension_count": 0 + "subscription_tier": effective_subscription_tier, + "extension_count": 0, + "is_enterprise": demo_account_type == 'enterprise', + "child_tenant_ids": [str(tid) for tid in child_tenant_ids] if child_tenant_ids else [], + "child_configs": demo_config.get('children', []) if demo_account_type == 'enterprise' else [] } } @@ -99,6 +125,9 @@ class DemoSessionManager: "Demo session created", session_id=session_id, virtual_tenant_id=str(virtual_tenant_id), + demo_account_type=demo_account_type, + is_enterprise=demo_account_type == 'enterprise', + child_tenant_count=len(child_tenant_ids), expires_at=session.expires_at.isoformat() ) @@ -254,7 +283,8 @@ class DemoSessionManager: base_tenant_id=base_tenant_id, virtual_tenant_id=str(session.virtual_tenant_id), demo_account_type=session.demo_account_type, - session_id=session.session_id + session_id=session.session_id, + session_metadata=session.session_metadata ) # Update session with results @@ -262,6 +292,131 @@ class DemoSessionManager: return result + async def _validate_base_tenant_exists(self, base_tenant_id: uuid.UUID, demo_account_type: str) -> bool: + """ + Validate that the base tenant exists in the tenant service before starting cloning. + This prevents cloning from non-existent base tenants. + + Args: + base_tenant_id: The UUID of the base tenant to validate + demo_account_type: The demo account type for logging + + Returns: + True if tenant exists, raises exception otherwise + """ + logger.info( + "Validating base tenant exists before cloning", + base_tenant_id=str(base_tenant_id), + demo_account_type=demo_account_type + ) + + # Basic validation: check if UUID is valid (not empty/nil) + if str(base_tenant_id) == "00000000-0000-0000-0000-000000000000": + raise ValueError(f"Invalid base tenant ID: {base_tenant_id} for demo type: {demo_account_type}") + + # BUG-008 FIX: Actually validate with tenant service + try: + from shared.clients.tenant_client import TenantServiceClient + + tenant_client = TenantServiceClient(settings) + tenant = await tenant_client.get_tenant(str(base_tenant_id)) + + if not tenant: + error_msg = ( + f"Base tenant {base_tenant_id} does not exist for demo type {demo_account_type}. " + f"Please verify the base_tenant_id in demo configuration." + ) + logger.error( + "Base tenant validation failed", + base_tenant_id=str(base_tenant_id), + demo_account_type=demo_account_type + ) + raise ValueError(error_msg) + + logger.info( + "Base tenant validation passed", + base_tenant_id=str(base_tenant_id), + tenant_name=tenant.get("name", "unknown"), + demo_account_type=demo_account_type + ) + return True + + except ValueError: + # Re-raise ValueError from validation failure + raise + except Exception as e: + logger.error( + f"Error validating base tenant: {e}", + base_tenant_id=str(base_tenant_id), + demo_account_type=demo_account_type, + exc_info=True + ) + raise ValueError(f"Cannot validate base tenant {base_tenant_id}: {str(e)}") + + async def _validate_child_template_tenants(self, child_configs: list) -> bool: + """ + Validate that all child template tenants exist before cloning. + This prevents silent failures when child base tenants are missing. + + Args: + child_configs: List of child configurations with base_tenant_id + + Returns: + True if all child templates exist, raises exception otherwise + """ + if not child_configs: + logger.warning("No child configurations provided for validation") + return True + + logger.info("Validating child template tenants", child_count=len(child_configs)) + + try: + from shared.clients.tenant_client import TenantServiceClient + + tenant_client = TenantServiceClient(settings) + + for child_config in child_configs: + child_base_id = child_config.get("base_tenant_id") + child_name = child_config.get("name", "unknown") + + if not child_base_id: + raise ValueError(f"Child config missing base_tenant_id: {child_name}") + + # Validate child template exists + child_tenant = await tenant_client.get_tenant(child_base_id) + + if not child_tenant: + error_msg = ( + f"Child template tenant {child_base_id} ('{child_name}') does not exist. " + f"Please verify the base_tenant_id in demo configuration." + ) + logger.error( + "Child template validation failed", + base_tenant_id=child_base_id, + child_name=child_name + ) + raise ValueError(error_msg) + + logger.info( + "Child template validation passed", + base_tenant_id=child_base_id, + child_name=child_name, + tenant_name=child_tenant.get("name", "unknown") + ) + + logger.info("All child template tenants validated successfully") + return True + + except ValueError: + # Re-raise ValueError from validation failure + raise + except Exception as e: + logger.error( + f"Error validating child template tenants: {e}", + exc_info=True + ) + raise ValueError(f"Cannot validate child template tenants: {str(e)}") + async def _update_session_from_clone_result( self, session: DemoSession, diff --git a/services/tenant/app/api/internal_demo.py b/services/tenant/app/api/internal_demo.py index 5712622c..a3b2c7e1 100644 --- a/services/tenant/app/api/internal_demo.py +++ b/services/tenant/app/api/internal_demo.py @@ -13,22 +13,21 @@ from typing import Optional import os from app.core.database import get_db -from app.models.tenants import Tenant +from app.models.tenants import Tenant, Subscription, TenantMember +from app.models.tenant_location import TenantLocation + +from app.core.config import settings logger = structlog.get_logger() router = APIRouter(prefix="/internal/demo", tags=["internal"]) -# Internal API key for service-to-service auth -INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production") - # Base demo tenant IDs -DEMO_TENANT_SAN_PABLO = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6" -DEMO_TENANT_LA_ESPIGA = "b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7" +DEMO_TENANT_PROFESSIONAL = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6" def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)): """Verify internal API key for service-to-service communication""" - if x_internal_api_key != INTERNAL_API_KEY: + if x_internal_api_key != settings.INTERNAL_API_KEY: logger.warning("Unauthorized internal API access attempted") raise HTTPException(status_code=403, detail="Invalid internal API key") return True @@ -86,7 +85,6 @@ async def clone_demo_data( ) # Ensure the tenant has a subscription (copy from template if missing) - from app.models.tenants import Subscription from datetime import timedelta result = await db.execute( @@ -155,10 +153,10 @@ async def clone_demo_data( # Note: Use the actual demo user IDs from seed_demo_users.py # These match the demo users created in the auth service DEMO_OWNER_IDS = { - "individual_bakery": "c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6", # María García López (San Pablo) - "central_baker": "d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7" # Carlos Martínez Ruiz (La Espiga) + "professional": "c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6", # María García López + "enterprise": "d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7" # Carlos Martínez Ruiz } - demo_owner_uuid = uuid.UUID(DEMO_OWNER_IDS.get(demo_account_type, DEMO_OWNER_IDS["individual_bakery"])) + demo_owner_uuid = uuid.UUID(DEMO_OWNER_IDS.get(demo_account_type, DEMO_OWNER_IDS["professional"])) tenant = Tenant( id=virtual_uuid, @@ -169,6 +167,7 @@ async def clone_demo_data( business_type="bakery", is_demo=True, is_demo_template=False, + demo_session_id=session_id, # Link tenant to demo session business_model=demo_account_type, is_active=True, timezone="Europe/Madrid", @@ -178,23 +177,36 @@ async def clone_demo_data( db.add(tenant) await db.flush() # Flush to get the tenant ID - # Create demo subscription (enterprise tier for full access) - from app.models.tenants import Subscription + # Create demo subscription with appropriate tier based on demo account type + + # Determine subscription tier based on demo account type + if demo_account_type == "professional": + plan = "professional" + max_locations = 3 + elif demo_account_type in ["enterprise", "enterprise_parent"]: + plan = "enterprise" + max_locations = -1 # Unlimited + elif demo_account_type == "enterprise_child": + plan = "enterprise" + max_locations = 1 + else: + plan = "starter" + max_locations = 1 + demo_subscription = Subscription( tenant_id=tenant.id, - plan="enterprise", # Demo gets full access + plan=plan, # Set appropriate tier based on demo account type status="active", monthly_price=0.0, # Free for demo billing_cycle="monthly", - max_users=-1, # Unlimited - max_locations=-1, - max_products=-1, + max_users=-1, # Unlimited for demo + max_locations=max_locations, + max_products=-1, # Unlimited for demo features={} ) db.add(demo_subscription) # Create tenant member records for demo owner and staff - from app.models.tenants import TenantMember import json # Helper function to get permissions for role @@ -218,7 +230,7 @@ async def clone_demo_data( # Define staff users for each demo account type (must match seed_demo_tenant_members.py) STAFF_USERS = { - "individual_bakery": [ + "professional": [ # Owner { "user_id": uuid.UUID("c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6"), @@ -250,7 +262,7 @@ async def clone_demo_data( "role": "production_manager" } ], - "central_baker": [ + "enterprise": [ # Owner { "user_id": uuid.UUID("d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7"), @@ -310,50 +322,45 @@ async def clone_demo_data( members_created=members_created ) - # Clone subscription from template tenant - from app.models.tenants import Subscription - from datetime import timedelta + # Clone TenantLocations + from app.models.tenant_location import TenantLocation - # Get subscription from template tenant base_uuid = uuid.UUID(base_tenant_id) - result = await db.execute( - select(Subscription).where( - Subscription.tenant_id == base_uuid, - Subscription.status == "active" - ) + location_result = await db.execute( + select(TenantLocation).where(TenantLocation.tenant_id == base_uuid) ) - template_subscription = result.scalars().first() + base_locations = location_result.scalars().all() - subscription_plan = "unknown" - if template_subscription: - # Clone subscription from template - subscription = Subscription( - tenant_id=virtual_uuid, - plan=template_subscription.plan, - status=template_subscription.status, - monthly_price=template_subscription.monthly_price, - max_users=template_subscription.max_users, - max_locations=template_subscription.max_locations, - max_products=template_subscription.max_products, - features=template_subscription.features.copy() if template_subscription.features else {}, - trial_ends_at=template_subscription.trial_ends_at, - next_billing_date=datetime.now(timezone.utc) + timedelta(days=90) if template_subscription.next_billing_date else None + records_cloned = 1 + members_created # Tenant + TenantMembers + for base_location in base_locations: + virtual_location = TenantLocation( + id=uuid.uuid4(), + tenant_id=virtual_tenant_id, + name=base_location.name, + location_type=base_location.location_type, + address=base_location.address, + city=base_location.city, + postal_code=base_location.postal_code, + latitude=base_location.latitude, + longitude=base_location.longitude, + capacity=base_location.capacity, + delivery_windows=base_location.delivery_windows, + operational_hours=base_location.operational_hours, + max_delivery_radius_km=base_location.max_delivery_radius_km, + delivery_schedule_config=base_location.delivery_schedule_config, + is_active=base_location.is_active, + contact_person=base_location.contact_person, + contact_phone=base_location.contact_phone, + contact_email=base_location.contact_email, + metadata_=base_location.metadata_ if isinstance(base_location.metadata_, dict) else (base_location.metadata_ or {}) ) + db.add(virtual_location) + records_cloned += 1 - db.add(subscription) - subscription_plan = subscription.plan + logger.info("Cloned TenantLocations", count=len(base_locations)) - logger.info( - "Cloning subscription from template tenant", - template_tenant_id=base_tenant_id, - virtual_tenant_id=virtual_tenant_id, - plan=subscription_plan - ) - else: - logger.warning( - "No subscription found on template tenant - virtual tenant will have no subscription", - base_tenant_id=base_tenant_id - ) + # Subscription already created earlier based on demo_account_type (lines 179-206) + # No need to clone from template - this prevents duplicate subscription creation await db.commit() await db.refresh(tenant) @@ -361,16 +368,14 @@ async def clone_demo_data( duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) logger.info( - "Virtual tenant created successfully with cloned subscription", + "Virtual tenant created successfully with subscription", virtual_tenant_id=virtual_tenant_id, tenant_name=tenant.name, - subscription_plan=subscription_plan, + subscription_plan=plan, duration_ms=duration_ms ) - records_cloned = 1 + members_created # Tenant + TenantMembers - if template_subscription: - records_cloned += 1 # Subscription + records_cloned = 1 + members_created + 1 # Tenant + TenantMembers + Subscription return { "service": "tenant", @@ -383,8 +388,8 @@ async def clone_demo_data( "business_model": tenant.business_model, "owner_id": str(demo_owner_uuid), "members_created": members_created, - "subscription_plan": subscription_plan, - "subscription_cloned": template_subscription is not None + "subscription_plan": plan, + "subscription_created": True } } @@ -412,6 +417,260 @@ async def clone_demo_data( } +@router.post("/create-child") +async def create_child_outlet( + request: dict, + db: AsyncSession = Depends(get_db), + _: bool = Depends(verify_internal_api_key) +): + """ + Create a child outlet tenant for enterprise demos + + Args: + request: JSON request body with child tenant details + + Returns: + Creation status and tenant details + """ + # Extract parameters from request body + base_tenant_id = request.get("base_tenant_id") + virtual_tenant_id = request.get("virtual_tenant_id") + parent_tenant_id = request.get("parent_tenant_id") + child_name = request.get("child_name") + location = request.get("location", {}) + session_id = request.get("session_id") + + start_time = datetime.now(timezone.utc) + + logger.info( + "Creating child outlet tenant", + virtual_tenant_id=virtual_tenant_id, + parent_tenant_id=parent_tenant_id, + child_name=child_name, + session_id=session_id + ) + + try: + # Validate UUIDs + virtual_uuid = uuid.UUID(virtual_tenant_id) + parent_uuid = uuid.UUID(parent_tenant_id) + + # Check if child tenant already exists + result = await db.execute(select(Tenant).where(Tenant.id == virtual_uuid)) + existing_tenant = result.scalars().first() + + if existing_tenant: + logger.info( + "Child tenant already exists", + virtual_tenant_id=virtual_tenant_id, + tenant_name=existing_tenant.name + ) + + # Return existing tenant - idempotent operation + duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) + return { + "service": "tenant", + "status": "completed", + "records_created": 0, + "duration_ms": duration_ms, + "details": { + "tenant_id": str(virtual_uuid), + "tenant_name": existing_tenant.name, + "already_exists": True + } + } + + # Create child tenant with parent relationship + child_tenant = Tenant( + id=virtual_uuid, + name=child_name, + address=location.get("address", f"Calle Outlet {location.get('city', 'Madrid')}"), + city=location.get("city", "Madrid"), + postal_code=location.get("postal_code", "28001"), + business_type="bakery", + is_demo=True, + is_demo_template=False, + demo_session_id=session_id, # Link child tenant to demo session + business_model="retail_outlet", + is_active=True, + timezone="Europe/Madrid", + # Set parent relationship + parent_tenant_id=parent_uuid, + tenant_type="child", + hierarchy_path=f"{str(parent_uuid)}.{str(virtual_uuid)}", + + # Owner ID - using demo owner ID from parent + # In real implementation, this would be the same owner as the parent tenant + owner_id=uuid.UUID("c1a2b3c4-d5e6-47a8-b9c0-d1e2f3a4b5c6") # Demo owner ID + ) + + db.add(child_tenant) + await db.flush() # Flush to get the tenant ID + + # Create TenantLocation for this retail outlet + child_location = TenantLocation( + id=uuid.uuid4(), + tenant_id=virtual_uuid, + name=f"{child_name} - Retail Outlet", + location_type="retail_outlet", + address=location.get("address", f"Calle Outlet {location.get('city', 'Madrid')}"), + city=location.get("city", "Madrid"), + postal_code=location.get("postal_code", "28001"), + latitude=location.get("latitude"), + longitude=location.get("longitude"), + delivery_windows={ + "monday": "07:00-10:00", + "wednesday": "07:00-10:00", + "friday": "07:00-10:00" + }, + operational_hours={ + "monday": "07:00-21:00", + "tuesday": "07:00-21:00", + "wednesday": "07:00-21:00", + "thursday": "07:00-21:00", + "friday": "07:00-21:00", + "saturday": "08:00-21:00", + "sunday": "09:00-21:00" + }, + delivery_schedule_config={ + "delivery_days": ["monday", "wednesday", "friday"], + "time_window": "07:00-10:00" + }, + is_active=True + ) + db.add(child_location) + logger.info("Created TenantLocation for child", child_id=str(virtual_uuid), location_name=child_location.name) + + # Create parent tenant lookup to get the correct plan for the child + parent_result = await db.execute( + select(Subscription).where( + Subscription.tenant_id == parent_uuid, + Subscription.status == "active" + ) + ) + parent_subscription = parent_result.scalars().first() + + # Child inherits the same plan as parent + parent_plan = parent_subscription.plan if parent_subscription else "enterprise" + + child_subscription = Subscription( + tenant_id=child_tenant.id, + plan=parent_plan, # Child inherits the same plan as parent + status="active", + monthly_price=0.0, # Free for demo + billing_cycle="monthly", + max_users=10, # Demo limits + max_locations=1, # Single location for outlet + max_products=200, + features={} + ) + db.add(child_subscription) + + # Create basic tenant members like parent + import json + + # Demo owner is the same as central_baker/enterprise_chain owner (not individual_bakery) + demo_owner_uuid = uuid.UUID("d2e3f4a5-b6c7-48d9-e0f1-a2b3c4d5e6f7") + + # Create tenant member for owner + child_owner_member = TenantMember( + tenant_id=virtual_uuid, + user_id=demo_owner_uuid, + role="owner", + permissions=json.dumps(["read", "write", "admin", "delete"]), + is_active=True, + invited_by=demo_owner_uuid, + invited_at=datetime.now(timezone.utc), + joined_at=datetime.now(timezone.utc), + created_at=datetime.now(timezone.utc) + ) + db.add(child_owner_member) + + # Create some staff members for the outlet (simplified) + staff_users = [ + { + "user_id": uuid.UUID("50000000-0000-0000-0000-000000000002"), # Sales user + "role": "sales" + }, + { + "user_id": uuid.UUID("50000000-0000-0000-0000-000000000003"), # Quality control user + "role": "quality_control" + }, + { + "user_id": uuid.UUID("50000000-0000-0000-0000-000000000005"), # Warehouse user + "role": "warehouse" + } + ] + + members_created = 1 # Start with owner + for staff_member in staff_users: + tenant_member = TenantMember( + tenant_id=virtual_uuid, + user_id=staff_member["user_id"], + role=staff_member["role"], + permissions=json.dumps(["read", "write"]) if staff_member["role"] != "admin" else json.dumps(["read", "write", "admin"]), + is_active=True, + invited_by=demo_owner_uuid, + invited_at=datetime.now(timezone.utc), + joined_at=datetime.now(timezone.utc), + created_at=datetime.now(timezone.utc) + ) + db.add(tenant_member) + members_created += 1 + + await db.commit() + await db.refresh(child_tenant) + + duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) + + logger.info( + "Child outlet created successfully", + virtual_tenant_id=str(virtual_tenant_id), + parent_tenant_id=str(parent_tenant_id), + child_name=child_name, + duration_ms=duration_ms + ) + + return { + "service": "tenant", + "status": "completed", + "records_created": 2 + members_created, # Tenant + Subscription + Members + "duration_ms": duration_ms, + "details": { + "tenant_id": str(child_tenant.id), + "tenant_name": child_tenant.name, + "parent_tenant_id": str(parent_tenant_id), + "location": location, + "members_created": members_created, + "subscription_plan": "enterprise" + } + } + + except ValueError as e: + logger.error("Invalid UUID format", error=str(e), virtual_tenant_id=virtual_tenant_id) + raise HTTPException(status_code=400, detail=f"Invalid UUID: {str(e)}") + + except Exception as e: + logger.error( + "Failed to create child outlet", + error=str(e), + virtual_tenant_id=virtual_tenant_id, + parent_tenant_id=parent_tenant_id, + exc_info=True + ) + + # Rollback on error + await db.rollback() + + return { + "service": "tenant", + "status": "failed", + "records_created": 0, + "duration_ms": int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000), + "error": str(e) + } + + @router.get("/clone/health") async def clone_health_check(_: bool = Depends(verify_internal_api_key)): """