From 54662dde79fa0e7bf9c850e79c816554850f788b Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Sat, 27 Dec 2025 21:30:42 +0100 Subject: [PATCH] Add frontend loading imporvements --- frontend/src/api/hooks/subscription.ts | 60 ++-- frontend/src/api/hooks/useControlPanelData.ts | 3 +- .../dashboard/DashboardSkeleton.tsx | 68 ++++ .../src/components/layout/Sidebar/Sidebar.tsx | 22 +- frontend/src/contexts/AuthContext.tsx | 15 +- frontend/src/contexts/SSEContext.tsx | 40 ++- frontend/src/hooks/useOnboardingStatus.ts | 51 +++ frontend/src/pages/app/DashboardPage.tsx | 226 +++++++------- frontend/src/pages/public/DemoPage.tsx | 6 +- frontend/src/stores/useTenantInitializer.ts | 75 +---- gateway/app/middleware/auth.py | 5 + gateway/app/routes/tenant.py | 4 +- .../demo_session/app/api/demo_sessions.py | 13 +- .../app/services/clone_orchestrator.py | 290 ++++++++++-------- services/inventory/app/api/internal_demo.py | 33 +- services/recipes/app/api/internal_demo.py | 34 +- services/suppliers/app/api/internal_demo.py | 34 +- services/tenant/app/api/onboarding.py | 133 ++++++++ services/tenant/app/api/tenant_operations.py | 22 +- services/tenant/app/main.py | 3 +- .../services/subscription_limit_service.py | 25 +- 21 files changed, 799 insertions(+), 363 deletions(-) create mode 100644 frontend/src/components/dashboard/DashboardSkeleton.tsx create mode 100644 frontend/src/hooks/useOnboardingStatus.ts create mode 100644 services/tenant/app/api/onboarding.py diff --git a/frontend/src/api/hooks/subscription.ts b/frontend/src/api/hooks/subscription.ts index ea5f8d3c..832fabd7 100644 --- a/frontend/src/api/hooks/subscription.ts +++ b/frontend/src/api/hooks/subscription.ts @@ -3,6 +3,7 @@ */ import { useState, useEffect, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { subscriptionService } from '../services/subscription'; import { SUBSCRIPTION_TIERS, @@ -34,49 +35,32 @@ export interface SubscriptionInfo { } export const useSubscription = () => { - const [subscriptionInfo, setSubscriptionInfo] = useState({ - plan: 'starter', - status: 'active', - features: {}, - loading: true, - }); - const currentTenant = useCurrentTenant(); const user = useAuthUser(); const tenantId = currentTenant?.id || user?.tenant_id; - const { notifySubscriptionChanged, subscriptionVersion } = useSubscriptionEvents(); + const { subscriptionVersion } = useSubscriptionEvents(); - // Load subscription data - const loadSubscriptionData = useCallback(async () => { - if (!tenantId) { - setSubscriptionInfo(prev => ({ ...prev, loading: false, error: 'No tenant ID available' })); - return; - } + // Initialize with tenant's subscription_plan if available, otherwise default to starter + const initialPlan = currentTenant?.subscription_plan || currentTenant?.subscription_tier || 'starter'; - try { - setSubscriptionInfo(prev => ({ ...prev, loading: true, error: undefined })); + // Use React Query to fetch subscription data (automatically deduplicates & caches) + const { data: usageSummary, isLoading, error, refetch } = useQuery({ + queryKey: ['subscription-usage', tenantId, subscriptionVersion], + queryFn: () => subscriptionService.getUsageSummary(tenantId!), + enabled: !!tenantId, + staleTime: 30 * 1000, // Cache for 30 seconds (matches backend cache) + gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes + retry: 1, + }); - const usageSummary = await subscriptionService.getUsageSummary(tenantId); - - setSubscriptionInfo({ - plan: usageSummary.plan, - status: usageSummary.status, - features: usageSummary.usage || {}, - loading: false, - }); - } catch (error) { - console.error('Error loading subscription data:', error); - setSubscriptionInfo(prev => ({ - ...prev, - loading: false, - error: 'Failed to load subscription data' - })); - } - }, [tenantId]); - - useEffect(() => { - loadSubscriptionData(); - }, [loadSubscriptionData, subscriptionVersion]); + // Derive subscription info from query data or tenant fallback + const subscriptionInfo: SubscriptionInfo = { + plan: usageSummary?.plan || initialPlan, + status: usageSummary?.status || 'active', + features: usageSummary?.usage || {}, + loading: isLoading, + error: error ? 'Failed to load subscription data' : undefined, + }; // Check if user has a specific feature const hasFeature = useCallback(async (featureName: string): Promise => { @@ -175,7 +159,7 @@ export const useSubscription = () => { canAccessForecasting, canAccessAIInsights, checkLimits, - refreshSubscription: loadSubscriptionData, + refreshSubscription: refetch, }; }; diff --git a/frontend/src/api/hooks/useControlPanelData.ts b/frontend/src/api/hooks/useControlPanelData.ts index babe1b2b..61fa3d62 100644 --- a/frontend/src/api/hooks/useControlPanelData.ts +++ b/frontend/src/api/hooks/useControlPanelData.ts @@ -382,7 +382,8 @@ export function useControlPanelData(tenantId: string) { }, enabled: !!tenantId, staleTime: 20000, // 20 seconds - refetchOnMount: 'always', + refetchOnMount: true, + refetchOnWindowFocus: false, retry: 2, }); diff --git a/frontend/src/components/dashboard/DashboardSkeleton.tsx b/frontend/src/components/dashboard/DashboardSkeleton.tsx new file mode 100644 index 00000000..f0910df8 --- /dev/null +++ b/frontend/src/components/dashboard/DashboardSkeleton.tsx @@ -0,0 +1,68 @@ +import React from 'react'; + +export const DashboardSkeleton: React.FC = () => ( +
+ {/* System Status Block Skeleton */} +
+
+
+ {[1, 2, 3, 4].map(i => ( +
+
+
+
+ ))} +
+
+ + {/* Pending Purchases Skeleton */} +
+
+
+ {[1, 2, 3].map(i => ( +
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ + {/* Production Status Skeleton */} +
+
+
+ {[1, 2, 3].map(i => ( +
+
+
+
+
+ ))} +
+
+ + {/* Alerts Skeleton */} +
+
+
+ {[1, 2].map(i => ( +
+
+
+
+
+
+
+
+ ))} +
+
+
+); diff --git a/frontend/src/components/layout/Sidebar/Sidebar.tsx b/frontend/src/components/layout/Sidebar/Sidebar.tsx index 110f8bcb..1a16ebbb 100644 --- a/frontend/src/components/layout/Sidebar/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar/Sidebar.tsx @@ -185,7 +185,7 @@ export const Sidebar = forwardRef(({ // Get subscription-aware navigation routes const baseNavigationRoutes = useMemo(() => getNavigationRoutes(), []); - const { filteredRoutes: subscriptionFilteredRoutes } = useSubscriptionAwareRoutes(baseNavigationRoutes); + const { filteredRoutes: subscriptionFilteredRoutes, isLoading: subscriptionLoading } = useSubscriptionAwareRoutes(baseNavigationRoutes); // Map route paths to translation keys const getTranslationKey = (routePath: string): string => { @@ -845,9 +845,23 @@ export const Sidebar = forwardRef(({ {/* Navigation */} {/* Profile section */} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index c6380cb4..800e53d8 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -39,8 +39,19 @@ export const AuthProvider: React.FC = ({ children }) => { const initializeAuth = async () => { setIsInitializing(true); - // Wait a bit for zustand persist to rehydrate - await new Promise(resolve => setTimeout(resolve, 100)); + // Check if zustand has already rehydrated + if (!(useAuthStore.persist as any).hasHydrated?.()) { + // Wait for rehydration event with minimal timeout fallback + await Promise.race([ + new Promise(resolve => { + const unsubscribe = useAuthStore.persist.onFinishHydration(() => { + unsubscribe(); + resolve(); + }); + }), + new Promise(resolve => setTimeout(resolve, 50)) + ]); + } // Check if we have stored auth data if (authStore.token && authStore.refreshToken) { diff --git a/frontend/src/contexts/SSEContext.tsx b/frontend/src/contexts/SSEContext.tsx index cd443da6..07f9e483 100644 --- a/frontend/src/contexts/SSEContext.tsx +++ b/frontend/src/contexts/SSEContext.tsx @@ -35,12 +35,15 @@ interface SSEProviderProps { export const SSEProvider: React.FC = ({ children }) => { const [isConnected, setIsConnected] = useState(false); const [lastEvent, setLastEvent] = useState(null); - + const eventSourceRef = useRef(null); const eventListenersRef = useRef void>>>(new Map()); const reconnectTimeoutRef = useRef(); const reconnectAttempts = useRef(0); - + + // Global deduplication: Track processed event IDs to prevent duplicate callbacks + const processedEventIdsRef = useRef>(new Set()); + const { isAuthenticated, token } = useAuthStore(); const currentTenant = useCurrentTenant(); @@ -130,6 +133,23 @@ export const SSEProvider: React.FC = ({ children }) => { eventSource.addEventListener('alert', (event) => { try { const data = JSON.parse(event.data); + + // GLOBAL DEDUPLICATION: Skip if this event was already processed + if (data.id && processedEventIdsRef.current.has(data.id)) { + console.log('⏭️ [SSE] Skipping duplicate alert:', data.id); + return; + } + + // Mark event as processed + if (data.id) { + processedEventIdsRef.current.add(data.id); + // Limit cache size (keep last 1000 event IDs) + if (processedEventIdsRef.current.size > 1000) { + const firstId = Array.from(processedEventIdsRef.current)[0]; + processedEventIdsRef.current.delete(firstId); + } + } + const sseEvent: SSEEvent = { type: 'alert', data, @@ -208,6 +228,22 @@ export const SSEProvider: React.FC = ({ children }) => { eventSource.addEventListener('notification', (event) => { try { const data = JSON.parse(event.data); + + // GLOBAL DEDUPLICATION: Skip if this event was already processed + if (data.id && processedEventIdsRef.current.has(data.id)) { + console.log('⏭️ [SSE] Skipping duplicate notification:', data.id); + return; + } + + // Mark event as processed + if (data.id) { + processedEventIdsRef.current.add(data.id); + if (processedEventIdsRef.current.size > 1000) { + const firstId = Array.from(processedEventIdsRef.current)[0]; + processedEventIdsRef.current.delete(firstId); + } + } + const sseEvent: SSEEvent = { type: 'notification', data, diff --git a/frontend/src/hooks/useOnboardingStatus.ts b/frontend/src/hooks/useOnboardingStatus.ts new file mode 100644 index 00000000..c0e262a8 --- /dev/null +++ b/frontend/src/hooks/useOnboardingStatus.ts @@ -0,0 +1,51 @@ +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; + +interface OnboardingStatus { + ingredients_count: number; + suppliers_count: number; + recipes_count: number; + has_minimum_setup: boolean; + progress_percentage: number; + requirements: { + ingredients: { + current: number; + minimum: number; + met: boolean; + }; + suppliers: { + current: number; + minimum: number; + met: boolean; + }; + recipes: { + current: number; + minimum: number; + met: boolean; + }; + }; +} + +export const useOnboardingStatus = (tenantId: string) => { + return useQuery({ + queryKey: ['onboarding-status', tenantId], + queryFn: async () => { + console.log('[useOnboardingStatus] Fetching for tenant:', tenantId); + try { + // apiClient.get() already returns response.data (unwrapped) + const data = await apiClient.get( + `/tenants/${tenantId}/onboarding/status` + ); + console.log('[useOnboardingStatus] Success:', data); + return data; + } catch (error) { + console.error('[useOnboardingStatus] Error:', error); + throw error; + } + }, + enabled: !!tenantId, + staleTime: 60 * 1000, + gcTime: 5 * 60 * 1000, + retry: 1, + }); +}; diff --git a/frontend/src/pages/app/DashboardPage.tsx b/frontend/src/pages/app/DashboardPage.tsx index f6accbb6..794ad836 100644 --- a/frontend/src/pages/app/DashboardPage.tsx +++ b/frontend/src/pages/app/DashboardPage.tsx @@ -25,15 +25,13 @@ import { useApprovePurchaseOrder, useStartProductionBatch, } from '../../api/hooks/useProfessionalDashboard'; -import { useControlPanelData, useControlPanelRealtimeSync } from '../../api/hooks/useControlPanelData'; +import { useControlPanelData } from '../../api/hooks/useControlPanelData'; import { useRejectPurchaseOrder } from '../../api/hooks/purchase-orders'; -import { useIngredients } from '../../api/hooks/inventory'; -import { useSuppliers } from '../../api/hooks/suppliers'; -import { useRecipes } from '../../api/hooks/recipes'; import { useUserProgress } from '../../api/hooks/onboarding'; -import { useQualityTemplates } from '../../api/hooks/qualityTemplates'; +import { useOnboardingStatus } from '../../hooks/useOnboardingStatus'; import { SetupWizardBlocker } from '../../components/dashboard/SetupWizardBlocker'; import { CollapsibleSetupBanner } from '../../components/dashboard/CollapsibleSetupBanner'; +import { DashboardSkeleton } from '../../components/dashboard/DashboardSkeleton'; import { SystemStatusBlock, PendingPurchasesBlock, @@ -69,49 +67,27 @@ export function BakeryDashboard({ plan }: { plan?: string }) { const [isPOModalOpen, setIsPOModalOpen] = useState(false); const [poModalMode, setPOModalMode] = useState<'view' | 'edit'>('view'); - // Setup Progress Data - use localStorage as fallback during loading - const setupProgressFromStorage = useMemo(() => { - try { - const cached = localStorage.getItem(`setup_progress_${tenantId}`); - return cached ? parseInt(cached, 10) : 0; - } catch { - return 0; - } - }, [tenantId]); + // ALWAYS use lightweight onboarding status endpoint for ALL users (demo + authenticated) + // This is faster and more efficient than fetching full datasets + const { data: onboardingStatus, isLoading: loadingOnboarding } = useOnboardingStatus(tenantId); - // Fetch setup data to determine true progress - const { data: ingredients = [], isLoading: loadingIngredients } = useIngredients( - tenantId, - {}, - { enabled: !!tenantId } - ); - const { data: suppliers = [], isLoading: loadingSuppliers } = useSuppliers( - tenantId, - {}, - { enabled: !!tenantId } - ); - const { data: recipes = [], isLoading: loadingRecipes } = useRecipes( - tenantId, - {}, - { enabled: !!tenantId } - ); - const { data: qualityData, isLoading: loadingQuality } = useQualityTemplates( - tenantId, - {}, - { enabled: !!tenantId } - ); - const qualityTemplates = Array.isArray(qualityData?.templates) ? qualityData.templates : []; + // DEBUG: Log onboarding status + useEffect(() => { + console.log('[DashboardPage] Onboarding Status:', { + onboardingStatus, + loadingOnboarding, + tenantId, + }); + }, [onboardingStatus, loadingOnboarding, tenantId]); // NEW: Enhanced control panel data fetch with SSE integration + // Note: useControlPanelData already includes SSE integration and auto-refetch const { data: dashboardData, isLoading: dashboardLoading, refetch: refetchDashboard, } = useControlPanelData(tenantId); - // Enable enhanced SSE real-time state synchronization - useControlPanelRealtimeSync(tenantId); - // Mutations const approvePO = useApprovePurchaseOrder(); const rejectPO = useRejectPurchaseOrder(); @@ -161,6 +137,12 @@ export function BakeryDashboard({ plan }: { plan?: string }) { const SafeBookOpenIcon = BookOpen; const SafeShieldIcon = Shield; + // ALWAYS use onboardingStatus counts for ALL users + // This is lightweight and doesn't require fetching full datasets + const ingredientsCount = onboardingStatus?.ingredients_count ?? 0; + const suppliersCount = onboardingStatus?.suppliers_count ?? 0; + const recipesCount = onboardingStatus?.recipes_count ?? 0; + // Validate that all icons are properly imported before using them const sections = [ { @@ -168,10 +150,10 @@ export function BakeryDashboard({ plan }: { plan?: string }) { title: t('dashboard:config.inventory', 'Inventory'), icon: SafePackageIcon, path: '/app/database/inventory', - count: ingredients.length, + count: ingredientsCount, minimum: 3, recommended: 10, - isComplete: ingredients.length >= 3, + isComplete: ingredientsCount >= 3, description: t('dashboard:config.add_ingredients', 'Add at least {{count}} ingredients', { count: 3 }), }, { @@ -179,10 +161,10 @@ export function BakeryDashboard({ plan }: { plan?: string }) { title: t('dashboard:config.suppliers', 'Suppliers'), icon: SafeUsersIcon, path: '/app/database/suppliers', - count: suppliers.length, + count: suppliersCount, minimum: 1, recommended: 3, - isComplete: suppliers.length >= 1, + isComplete: suppliersCount >= 1, description: t('dashboard:config.add_supplier', 'Add your first supplier'), }, { @@ -190,10 +172,10 @@ export function BakeryDashboard({ plan }: { plan?: string }) { title: t('dashboard:config.recipes', 'Recipes'), icon: SafeBookOpenIcon, path: '/app/database/recipes', - count: recipes.length, + count: recipesCount, minimum: 1, recommended: 3, - isComplete: recipes.length >= 1, + isComplete: recipesCount >= 1, description: t('dashboard:config.add_recipe', 'Create your first recipe'), }, { @@ -201,30 +183,50 @@ export function BakeryDashboard({ plan }: { plan?: string }) { title: t('dashboard:config.quality', 'Quality Standards'), icon: SafeShieldIcon, path: '/app/operations/production/quality', - count: qualityTemplates.length, + count: 0, // Quality templates are optional, not tracked in onboarding minimum: 0, recommended: 2, - isComplete: true, // Optional + isComplete: true, // Optional - always complete description: t('dashboard:config.add_quality', 'Add quality checks (optional)'), }, ]; return sections; - }, [ingredients.length, suppliers.length, recipes.length, qualityTemplates.length, t]); + }, [onboardingStatus, t]); // Calculate overall progress const { completedSections, totalSections, progressPercentage, criticalMissing, recommendedMissing } = useMemo(() => { - // If data is still loading, use stored value as fallback to prevent flickering - if (loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality) { + // If onboarding data is still loading, show loading state + if (loadingOnboarding) { + console.log('[DashboardPage] Progress calculation: Loading state'); return { completedSections: 0, totalSections: 4, // 4 required sections - progressPercentage: setupProgressFromStorage, // Use stored value during loading + progressPercentage: 0, // Loading state criticalMissing: [], recommendedMissing: [], }; } + // OPTIMIZATION: If we have onboarding status from API, use it directly + if (onboardingStatus?.progress_percentage !== undefined) { + const apiProgress = onboardingStatus.progress_percentage; + console.log('[DashboardPage] Progress calculation: Using API progress', { + apiProgress, + has_minimum_setup: onboardingStatus.has_minimum_setup, + onboardingStatus, + }); + return { + completedSections: onboardingStatus.has_minimum_setup ? 3 : 0, + totalSections: 3, + progressPercentage: apiProgress, + criticalMissing: apiProgress < 50 ? setupSections.filter(s => s.id !== 'quality' && !s.isComplete) : [], + recommendedMissing: setupSections.filter(s => s.count < s.recommended), + }; + } + + console.log('[DashboardPage] Progress calculation: Fallback to manual calculation'); + // Guard against undefined or invalid setupSections if (!setupSections || !Array.isArray(setupSections) || setupSections.length === 0) { return { @@ -258,7 +260,7 @@ export function BakeryDashboard({ plan }: { plan?: string }) { criticalMissing: critical, recommendedMissing: recommended, }; - }, [setupSections, tenantId, loadingIngredients, loadingSuppliers, loadingRecipes, loadingQuality, setupProgressFromStorage]); + }, [onboardingStatus, setupSections, tenantId, loadingOnboarding]); const handleAddWizardComplete = (itemType: ItemType, data?: any) => { console.log('Item created:', itemType, data); @@ -302,7 +304,7 @@ export function BakeryDashboard({ plan }: { plan?: string }) { const redirectStartStep = parseInt(sessionStorage.getItem('demo_tour_start_step') || '0', 10); if (isDemoMode && (shouldStart || shouldStartFromRedirect)) { - console.log('[Dashboard] Starting tour in 1.5s...'); + console.log('[Dashboard] Starting tour...'); const timer = setTimeout(() => { console.log('[Dashboard] Executing startTour()'); if (shouldStartFromRedirect) { @@ -316,7 +318,7 @@ export function BakeryDashboard({ plan }: { plan?: string }) { startTour(); clearTourStartPending(); } - }, 1500); + }, 300); return () => clearTimeout(timer); } @@ -362,8 +364,8 @@ export function BakeryDashboard({ plan }: { plan?: string }) {
{/* Setup Flow - Three States */} - {loadingIngredients || loadingSuppliers || loadingRecipes || loadingQuality ? ( - /* Loading state - only show spinner until setup data is ready */ + {loadingOnboarding ? ( + /* Loading state for onboarding checks */
@@ -384,62 +386,66 @@ export function BakeryDashboard({ plan }: { plan?: string }) { )} {/* Main Dashboard Layout - 4 New Focused Blocks */} -
- {/* BLOCK 1: System Status + AI Summary */} -
- -
- - {/* BLOCK 2: Pending Purchases (PO Approvals) */} -
- -
- - {/* BLOCK 3: Pending Deliveries (Overdue + Today) */} -
- -
- - {/* BLOCK 4: Production Status (Late/Running/Pending) */} -
- -
- - {/* BLOCK 5: AI Insights (Professional/Enterprise only) */} - {(plan === SUBSCRIPTION_TIERS.PROFESSIONAL || plan === SUBSCRIPTION_TIERS.ENTERPRISE) && ( -
- { - // Navigate to AI Insights page - window.location.href = '/app/analytics/ai-insights'; - }} + {dashboardLoading ? ( + + ) : ( +
+ {/* BLOCK 1: System Status + AI Summary */} +
+
- )} -
+ + {/* BLOCK 2: Pending Purchases (PO Approvals) */} +
+ +
+ + {/* BLOCK 3: Pending Deliveries (Overdue + Today) */} +
+ +
+ + {/* BLOCK 4: Production Status (Late/Running/Pending) */} +
+ +
+ + {/* BLOCK 5: AI Insights (Professional/Enterprise only) */} + {(plan === SUBSCRIPTION_TIERS.PROFESSIONAL || plan === SUBSCRIPTION_TIERS.ENTERPRISE) && ( +
+ { + // Navigate to AI Insights page + window.location.href = '/app/analytics/ai-insights'; + }} + /> +
+ )} +
+ )} )}
diff --git a/frontend/src/pages/public/DemoPage.tsx b/frontend/src/pages/public/DemoPage.tsx index d31ebfd7..e01f31cd 100644 --- a/frontend/src/pages/public/DemoPage.tsx +++ b/frontend/src/pages/public/DemoPage.tsx @@ -295,10 +295,10 @@ const DemoPage = () => { // BUG-010 FIX: Handle ready status separately from partial if (statusData.status === 'ready') { - // Full success - set to 100% and navigate after delay + // Full success - set to 100% and navigate immediately clearInterval(progressInterval); setCloneProgress(prev => ({ ...prev, overall: 100 })); - setTimeout(() => { + requestAnimationFrame(() => { // Reset state before navigation setCreatingTier(null); setProgressStartTime(null); @@ -311,7 +311,7 @@ const DemoPage = () => { }); // Navigate to the main dashboard which will automatically route to enterprise or bakery dashboard based on subscription tier navigate('/app/dashboard'); - }, 1500); // Increased from 1000ms to show 100% completion + }); return; } else if (statusData.status === 'PARTIAL' || statusData.status === 'partial') { // BUG-010 FIX: Show warning modal for partial status diff --git a/frontend/src/stores/useTenantInitializer.ts b/frontend/src/stores/useTenantInitializer.ts index a5a393da..0b1b5330 100644 --- a/frontend/src/stores/useTenantInitializer.ts +++ b/frontend/src/stores/useTenantInitializer.ts @@ -131,68 +131,27 @@ export const useTenantInitializer = () => { console.log('✅ [TenantInitializer] Set API client tenant ID:', virtualTenantId); }); - // For enterprise demos, wait for session to be ready, then load tenants + // For enterprise demos, load child tenants immediately (session is already ready when we navigate here) if (demoAccountType === 'enterprise') { - console.log('🔄 [TenantInitializer] Waiting for enterprise demo session to be ready...'); + console.log('🔄 [TenantInitializer] Loading available tenants for enterprise demo...'); + const mockUserId = 'demo-user'; - // Poll session status until ready - const pollSessionStatus = async (sessionId: string, maxAttempts = 30) => { - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const response = await fetch(`/api/v1/demo-sessions/${sessionId}/status`); - if (response.ok) { - const status = await response.json(); - console.log(`⏳ [TenantInitializer] Session status poll ${attempt}/${maxAttempts}:`, status.status); - - if (status.status === 'ready') { - console.log('✅ [TenantInitializer] Demo session is ready!'); - return true; - } else if (status.status === 'failed') { - console.error('❌ [TenantInitializer] Demo session failed:', status); - return false; - } - // Status is 'initializing' or 'cloning_data' - continue polling + import('../api/services/tenant').then(({ TenantService }) => { + const tenantService = new TenantService(); + tenantService.getUserTenants(mockUserId) + .then(tenants => { + console.log('📋 [TenantInitializer] Loaded available tenants:', tenants.length); + if (tenants.length === 0) { + console.warn('⚠️ [TenantInitializer] No child tenants found yet - they may still be cloning'); } - } catch (error) { - console.warn(`⚠️ [TenantInitializer] Status poll ${attempt} failed:`, error); - } - - // Wait 1 second before next poll (except on last attempt) - if (attempt < maxAttempts) { - await new Promise(resolve => setTimeout(resolve, 1000)); - } - } - - console.error('❌ [TenantInitializer] Session readiness timeout after 30 seconds'); - return false; - }; - - // Wait for session to be ready, then load tenants - pollSessionStatus(demoSessionId).then(isReady => { - if (isReady) { - console.log('🔄 [TenantInitializer] Loading available tenants for enterprise demo...'); - const mockUserId = 'demo-user'; - - import('../api/services/tenant').then(({ TenantService }) => { - const tenantService = new TenantService(); - tenantService.getUserTenants(mockUserId) - .then(tenants => { - console.log('📋 [TenantInitializer] Loaded available tenants:', tenants.length); - if (tenants.length === 0) { - console.warn('⚠️ [TenantInitializer] Session ready but no tenants found - possible sync issue'); - } - // Update the tenant store with available tenants - import('../stores/tenant.store').then(({ useTenantStore }) => { - useTenantStore.getState().setAvailableTenants(tenants); - }); - }) - .catch(error => { - console.error('❌ [TenantInitializer] Failed to load available tenants:', error); - }); + // Update the tenant store with available tenants + import('../stores/tenant.store').then(({ useTenantStore }) => { + useTenantStore.getState().setAvailableTenants(tenants); + }); + }) + .catch(error => { + console.error('❌ [TenantInitializer] Failed to load available tenants:', error); }); - } else { - console.error('❌ [TenantInitializer] Cannot load tenants - session not ready'); - } }); } } diff --git a/gateway/app/middleware/auth.py b/gateway/app/middleware/auth.py index 057ee351..533203c9 100644 --- a/gateway/app/middleware/auth.py +++ b/gateway/app/middleware/auth.py @@ -39,6 +39,11 @@ PUBLIC_ROUTES = [ "/api/v1/demo/sessions" ] +# Routes accessible with demo session (no JWT required, just demo session header) +DEMO_ACCESSIBLE_ROUTES = [ + "/api/v1/tenants/", # All tenant endpoints accessible in demo mode +] + class AuthMiddleware(BaseHTTPMiddleware): """ Enhanced Authentication Middleware with Tenant Access Control diff --git a/gateway/app/routes/tenant.py b/gateway/app/routes/tenant.py index 0c5673f1..d39c40e2 100644 --- a/gateway/app/routes/tenant.py +++ b/gateway/app/routes/tenant.py @@ -290,9 +290,9 @@ async def proxy_tenant_insights(request: Request, tenant_id: str = Path(...), pa @router.api_route("/{tenant_id}/onboarding/{path:path}", methods=["GET", "POST", "OPTIONS"]) async def proxy_tenant_onboarding(request: Request, tenant_id: str = Path(...), path: str = ""): - """Proxy tenant onboarding requests to sales service""" + """Proxy tenant onboarding requests to tenant service""" target_path = f"/api/v1/tenants/{tenant_id}/onboarding/{path}".rstrip("/") - return await _proxy_to_sales_service(request, target_path) + return await _proxy_to_tenant_service(request, target_path) # ================================================================ # TENANT-SCOPED TRAINING SERVICE ENDPOINTS diff --git a/services/demo_session/app/api/demo_sessions.py b/services/demo_session/app/api/demo_sessions.py index 8d21663e..33f72629 100644 --- a/services/demo_session/app/api/demo_sessions.py +++ b/services/demo_session/app/api/demo_sessions.py @@ -224,6 +224,14 @@ async def create_demo_session( algorithm=settings.JWT_ALGORITHM ) + # Map demo_account_type to subscription tier + subscription_tier = "enterprise" if session.demo_account_type == "enterprise" else "professional" + tenant_name = ( + "Panadería Artesana España - Central" + if session.demo_account_type == "enterprise" + else "Panadería Artesana Madrid - Demo" + ) + return { "session_id": session.session_id, "virtual_tenant_id": str(session.virtual_tenant_id), @@ -232,7 +240,10 @@ async def create_demo_session( "created_at": session.created_at, "expires_at": session.expires_at, "demo_config": session.session_metadata.get("demo_config", {}), - "session_token": session_token + "session_token": session_token, + "subscription_tier": subscription_tier, + "is_enterprise": session.demo_account_type == "enterprise", + "tenant_name": tenant_name } except Exception as e: diff --git a/services/demo_session/app/services/clone_orchestrator.py b/services/demo_session/app/services/clone_orchestrator.py index c0c4319a..a6957e8f 100644 --- a/services/demo_session/app/services/clone_orchestrator.py +++ b/services/demo_session/app/services/clone_orchestrator.py @@ -48,6 +48,9 @@ class CloneOrchestrator: self.internal_api_key = settings.INTERNAL_API_KEY self.redis_manager = redis_manager # For real-time progress updates + # Shared HTTP client with connection pooling + self._http_client: Optional[httpx.AsyncClient] = None + # Define services that participate in cloning # URLs should be internal Kubernetes service names self.services = [ @@ -125,6 +128,20 @@ class CloneOrchestrator: ), ] + async def _get_http_client(self) -> httpx.AsyncClient: + """Get or create shared HTTP client with connection pooling""" + if self._http_client is None or self._http_client.is_closed: + self._http_client = httpx.AsyncClient( + timeout=httpx.Timeout(30.0, connect=5.0), + limits=httpx.Limits(max_connections=100, max_keepalive_connections=20) + ) + return self._http_client + + async def close(self): + """Close the HTTP client""" + if self._http_client and not self._http_client.is_closed: + await self._http_client.aclose() + async def _update_progress_in_redis( self, session_id: str, @@ -352,30 +369,13 @@ class CloneOrchestrator: "duration_ms": duration_ms } - # If cloning completed successfully, trigger post-clone operations + # If cloning completed successfully, trigger post-clone operations in background if overall_status in ["completed", "partial"]: - try: - # Trigger alert generation - alert_results = await self._trigger_alert_generation_post_clone( - virtual_tenant_id=virtual_tenant_id, - demo_account_type=demo_account_type - ) - result["alert_generation"] = alert_results - - # Trigger AI insights generation - insights_results = await self._trigger_ai_insights_generation_post_clone( - virtual_tenant_id=virtual_tenant_id, - demo_account_type=demo_account_type - ) - result["ai_insights_generation"] = insights_results - - except Exception as e: - logger.error( - "Failed to trigger post-clone operations (non-fatal)", - session_id=session_id, - error=str(e) - ) - result["post_clone_error"] = str(e) + asyncio.create_task(self._run_post_clone_enrichments( + virtual_tenant_id=virtual_tenant_id, + demo_account_type=demo_account_type, + session_id=session_id + )) logger.info( "Cloning completed", @@ -528,92 +528,91 @@ class CloneOrchestrator: timeout=service.timeout ) - async with httpx.AsyncClient(timeout=service.timeout) as client: - logger.debug( - "Sending clone request", + client = await self._get_http_client() + logger.debug( + "Sending clone request", + service=service.name, + base_tenant_id=base_tenant_id, + virtual_tenant_id=virtual_tenant_id, + demo_account_type=demo_account_type + ) + + response = await client.post( + f"{service.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.isoformat() + }, + headers={"X-Internal-API-Key": self.internal_api_key}, + timeout=service.timeout + ) + + duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) + duration_seconds = duration_ms / 1000 + + logger.debug( + "Received response from service", + service=service.name, + status_code=response.status_code, + duration_ms=duration_ms + ) + + demo_cross_service_calls_total.labels( + source_service="demo-session", + target_service=service.name, + status="success" + ).inc() + demo_cross_service_call_duration_seconds.labels( + source_service="demo-session", + target_service=service.name + ).observe(duration_seconds) + demo_service_clone_duration_seconds.labels( + tier=demo_account_type, + service=service.name + ).observe(duration_seconds) + + if response.status_code == 200: + result = response.json() + logger.info( + "Service cloning completed", service=service.name, - base_tenant_id=base_tenant_id, - virtual_tenant_id=virtual_tenant_id, - demo_account_type=demo_account_type - ) - - response = await client.post( - f"{service.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.isoformat() - }, - headers={"X-Internal-API-Key": self.internal_api_key} - ) - - duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) - duration_seconds = duration_ms / 1000 - - logger.debug( - "Received response from service", - service=service.name, - status_code=response.status_code, + records_cloned=result.get("records_cloned", 0), duration_ms=duration_ms ) + return result + else: + error_msg = f"HTTP {response.status_code}: {response.text}" + logger.error( + "Service cloning failed", + service=service.name, + status_code=response.status_code, + error=error_msg, + response_text=response.text + ) - # Update Prometheus metrics demo_cross_service_calls_total.labels( source_service="demo-session", target_service=service.name, - status="success" + status="failed" ).inc() - demo_cross_service_call_duration_seconds.labels( - source_service="demo-session", - target_service=service.name - ).observe(duration_seconds) - demo_service_clone_duration_seconds.labels( + demo_cloning_errors_total.labels( tier=demo_account_type, - service=service.name - ).observe(duration_seconds) + service=service.name, + error_type="http_error" + ).inc() - if response.status_code == 200: - result = response.json() - logger.info( - "Service cloning completed", - service=service.name, - records_cloned=result.get("records_cloned", 0), - duration_ms=duration_ms - ) - return result - else: - error_msg = f"HTTP {response.status_code}: {response.text}" - logger.error( - "Service cloning failed", - service=service.name, - status_code=response.status_code, - error=error_msg, - response_text=response.text - ) - - # Update error metrics - demo_cross_service_calls_total.labels( - source_service="demo-session", - target_service=service.name, - status="failed" - ).inc() - demo_cloning_errors_total.labels( - tier=demo_account_type, - service=service.name, - error_type="http_error" - ).inc() - - return { - "service": service.name, - "status": "failed", - "error": error_msg, - "records_cloned": 0, - "duration_ms": duration_ms, - "response_status": response.status_code, - "response_text": response.text - } + return { + "service": service.name, + "status": "failed", + "error": error_msg, + "records_cloned": 0, + "duration_ms": duration_ms, + "response_status": response.status_code, + "response_text": response.text + } except httpx.TimeoutException: duration_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000) @@ -798,28 +797,29 @@ class CloneOrchestrator: try: # First, create child tenant via tenant service 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": child_base_id, - "virtual_tenant_id": virtual_child_id, - "parent_tenant_id": virtual_parent_id, - "child_name": child_name, - "location": location, - "session_id": session_id - }, - headers={"X-Internal-API-Key": self.internal_api_key} - ) + client = await self._get_http_client() + response = await client.post( + f"{tenant_url}/internal/demo/create-child", + json={ + "base_tenant_id": child_base_id, + "virtual_tenant_id": virtual_child_id, + "parent_tenant_id": virtual_parent_id, + "child_name": child_name, + "location": location, + "session_id": session_id + }, + headers={"X-Internal-API-Key": self.internal_api_key}, + timeout=30.0 + ) - 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}", - "records_cloned": 0 - } + 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}", + "records_cloned": 0 + } # Then clone data from all services for this child records_cloned = 0 @@ -942,9 +942,6 @@ class CloneOrchestrator: logger.error("Failed to trigger production alerts", tenant_id=virtual_tenant_id, error=str(e)) results["production_alerts"] = {"error": str(e)} - # Wait 1.5s for alert enrichment - await asyncio.sleep(1.5) - logger.info( "Alert generation post-clone completed", tenant_id=virtual_tenant_id, @@ -1052,9 +1049,6 @@ class CloneOrchestrator: logger.error("Failed to trigger demand insights", tenant_id=virtual_tenant_id, error=str(e)) results["demand_insights"] = {"error": str(e)} - # Wait 2s for insights to be processed - await asyncio.sleep(2.0) - logger.info( "AI insights generation post-clone completed", tenant_id=virtual_tenant_id, @@ -1063,3 +1057,47 @@ class CloneOrchestrator: results["total_insights_generated"] = total_insights return results + + async def _run_post_clone_enrichments( + self, + virtual_tenant_id: str, + demo_account_type: str, + session_id: str + ) -> None: + """ + Background task for non-blocking enrichments (alerts and AI insights). + Runs in fire-and-forget mode to avoid blocking session readiness. + """ + try: + logger.info( + "Starting background enrichments", + session_id=session_id, + tenant_id=virtual_tenant_id + ) + + await asyncio.gather( + self._trigger_alert_generation_post_clone(virtual_tenant_id, demo_account_type), + self._trigger_ai_insights_generation_post_clone(virtual_tenant_id, demo_account_type), + return_exceptions=True + ) + + if self.redis_manager: + client = await self.redis_manager.get_client() + await client.set( + f"session:{session_id}:enrichments_complete", + "true", + ex=7200 + ) + + logger.info( + "Background enrichments completed", + session_id=session_id, + tenant_id=virtual_tenant_id + ) + + except Exception as e: + logger.error( + "Background enrichments failed", + session_id=session_id, + error=str(e) + ) diff --git a/services/inventory/app/api/internal_demo.py b/services/inventory/app/api/internal_demo.py index ddd0da17..c91c2713 100644 --- a/services/inventory/app/api/internal_demo.py +++ b/services/inventory/app/api/internal_demo.py @@ -600,4 +600,35 @@ async def delete_demo_tenant_data( raise HTTPException( status_code=500, detail=f"Failed to delete demo data: {str(e)}" - ) \ No newline at end of file + ) + + +@router.get("/internal/count") +async def get_ingredient_count( + tenant_id: str, + db: AsyncSession = Depends(get_db), + _: bool = Depends(verify_internal_api_key) +): + """ + Get count of active ingredients for onboarding status check. + Internal endpoint for tenant service. + """ + try: + from sqlalchemy import select, func + + count = await db.scalar( + select(func.count()).select_from(Ingredient) + .where( + Ingredient.tenant_id == UUID(tenant_id), + Ingredient.is_active == True + ) + ) + + return { + "count": count or 0, + "tenant_id": tenant_id + } + + except Exception as e: + logger.error("Failed to get ingredient count", tenant_id=tenant_id, error=str(e)) + raise HTTPException(status_code=500, detail=f"Failed to get ingredient count: {str(e)}") \ No newline at end of file diff --git a/services/recipes/app/api/internal_demo.py b/services/recipes/app/api/internal_demo.py index bed25da3..ddc334d7 100644 --- a/services/recipes/app/api/internal_demo.py +++ b/services/recipes/app/api/internal_demo.py @@ -431,4 +431,36 @@ async def delete_demo_tenant_data( raise HTTPException( status_code=500, detail=f"Failed to delete demo data: {str(e)}" - ) \ No newline at end of file + ) + + +@router.get("/internal/count") +async def get_recipe_count( + tenant_id: str, + db: AsyncSession = Depends(get_db), + _: bool = Depends(verify_internal_api_key) +): + """ + Get count of active recipes for onboarding status check. + Internal endpoint for tenant service. + """ + try: + from sqlalchemy import select, func + from app.models.recipes import RecipeStatus + + count = await db.scalar( + select(func.count()).select_from(Recipe) + .where( + Recipe.tenant_id == UUID(tenant_id), + Recipe.status == RecipeStatus.ACTIVE + ) + ) + + return { + "count": count or 0, + "tenant_id": tenant_id + } + + except Exception as e: + logger.error("Failed to get recipe count", tenant_id=tenant_id, error=str(e)) + raise HTTPException(status_code=500, detail=f"Failed to get recipe count: {str(e)}") diff --git a/services/suppliers/app/api/internal_demo.py b/services/suppliers/app/api/internal_demo.py index fc609489..77dae8b1 100644 --- a/services/suppliers/app/api/internal_demo.py +++ b/services/suppliers/app/api/internal_demo.py @@ -406,4 +406,36 @@ async def delete_demo_tenant_data( raise HTTPException( status_code=500, detail=f"Failed to delete demo data: {str(e)}" - ) \ No newline at end of file + ) + + +@router.get("/internal/count") +async def get_supplier_count( + tenant_id: str, + db: AsyncSession = Depends(get_db), + _: bool = Depends(verify_internal_api_key) +): + """ + Get count of active suppliers for onboarding status check. + Internal endpoint for tenant service. + """ + try: + from sqlalchemy import select, func + from app.models.suppliers import SupplierStatus + + count = await db.scalar( + select(func.count()).select_from(Supplier) + .where( + Supplier.tenant_id == UUID(tenant_id), + Supplier.status == SupplierStatus.active + ) + ) + + return { + "count": count or 0, + "tenant_id": tenant_id + } + + except Exception as e: + logger.error("Failed to get supplier count", tenant_id=tenant_id, error=str(e)) + raise HTTPException(status_code=500, detail=f"Failed to get supplier count: {str(e)}") diff --git a/services/tenant/app/api/onboarding.py b/services/tenant/app/api/onboarding.py new file mode 100644 index 00000000..d0e09e31 --- /dev/null +++ b/services/tenant/app/api/onboarding.py @@ -0,0 +1,133 @@ +""" +Onboarding Status API +Provides lightweight onboarding status checks by aggregating counts from multiple services +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +import structlog +import asyncio +import httpx +import os + +from app.core.database import get_db +from app.core.config import settings +from shared.auth.decorators import get_current_tenant_id_dep +from shared.routing.route_builder import RouteBuilder + +logger = structlog.get_logger() +router = APIRouter() +route_builder = RouteBuilder("tenants") + + +@router.get(route_builder.build_base_route("{tenant_id}/onboarding/status", include_tenant_prefix=False)) +async def get_onboarding_status( + tenant_id: str, + db: AsyncSession = Depends(get_db) +): + """ + Get lightweight onboarding status by fetching counts from each service. + + Returns: + - ingredients_count: Number of active ingredients + - suppliers_count: Number of active suppliers + - recipes_count: Number of active recipes + - has_minimum_setup: Boolean indicating if minimum requirements are met + - progress_percentage: Overall onboarding progress (0-100) + """ + try: + # Service URLs from environment + inventory_url = os.getenv("INVENTORY_SERVICE_URL", "http://inventory-service:8000") + suppliers_url = os.getenv("SUPPLIERS_SERVICE_URL", "http://suppliers-service:8000") + recipes_url = os.getenv("RECIPES_SERVICE_URL", "http://recipes-service:8000") + + internal_api_key = settings.INTERNAL_API_KEY + + # Fetch counts from all services in parallel + async with httpx.AsyncClient(timeout=10.0) as client: + results = await asyncio.gather( + client.get( + f"{inventory_url}/internal/count", + params={"tenant_id": tenant_id}, + headers={"X-Internal-API-Key": internal_api_key} + ), + client.get( + f"{suppliers_url}/internal/count", + params={"tenant_id": tenant_id}, + headers={"X-Internal-API-Key": internal_api_key} + ), + client.get( + f"{recipes_url}/internal/count", + params={"tenant_id": tenant_id}, + headers={"X-Internal-API-Key": internal_api_key} + ), + return_exceptions=True + ) + + # Extract counts with fallback to 0 + ingredients_count = 0 + suppliers_count = 0 + recipes_count = 0 + + if not isinstance(results[0], Exception) and results[0].status_code == 200: + ingredients_count = results[0].json().get("count", 0) + + if not isinstance(results[1], Exception) and results[1].status_code == 200: + suppliers_count = results[1].json().get("count", 0) + + if not isinstance(results[2], Exception) and results[2].status_code == 200: + recipes_count = results[2].json().get("count", 0) + + # Calculate minimum setup requirements + # Minimum: 3 ingredients, 1 supplier, 1 recipe + has_minimum_ingredients = ingredients_count >= 3 + has_minimum_suppliers = suppliers_count >= 1 + has_minimum_recipes = recipes_count >= 1 + + has_minimum_setup = all([ + has_minimum_ingredients, + has_minimum_suppliers, + has_minimum_recipes + ]) + + # Calculate progress percentage + # Each requirement contributes 33.33% + progress = 0 + if has_minimum_ingredients: + progress += 33 + if has_minimum_suppliers: + progress += 33 + if has_minimum_recipes: + progress += 34 + + return { + "ingredients_count": ingredients_count, + "suppliers_count": suppliers_count, + "recipes_count": recipes_count, + "has_minimum_setup": has_minimum_setup, + "progress_percentage": progress, + "requirements": { + "ingredients": { + "current": ingredients_count, + "minimum": 3, + "met": has_minimum_ingredients + }, + "suppliers": { + "current": suppliers_count, + "minimum": 1, + "met": has_minimum_suppliers + }, + "recipes": { + "current": recipes_count, + "minimum": 1, + "met": has_minimum_recipes + } + } + } + + except Exception as e: + logger.error("Failed to get onboarding status", tenant_id=tenant_id, error=str(e)) + raise HTTPException( + status_code=500, + detail=f"Failed to get onboarding status: {str(e)}" + ) diff --git a/services/tenant/app/api/tenant_operations.py b/services/tenant/app/api/tenant_operations.py index d7c0c7bb..4406cdf0 100644 --- a/services/tenant/app/api/tenant_operations.py +++ b/services/tenant/app/api/tenant_operations.py @@ -745,10 +745,30 @@ async def get_usage_summary( current_user: Dict[str, Any] = Depends(get_current_user_dep), limit_service: SubscriptionLimitService = Depends(get_subscription_limit_service) ): - """Get usage summary vs limits for a tenant""" + """Get usage summary vs limits for a tenant (cached for 30s for performance)""" try: + # Try to get from cache first (30s TTL) + from shared.redis_utils import get_redis_client + import json + + cache_key = f"usage_summary:{tenant_id}" + redis_client = await get_redis_client() + + if redis_client: + cached = await redis_client.get(cache_key) + if cached: + logger.debug("Usage summary cache hit", tenant_id=str(tenant_id)) + return json.loads(cached) + + # Cache miss - fetch fresh data usage = await limit_service.get_usage_summary(str(tenant_id)) + + # Store in cache with 30s TTL + if redis_client: + await redis_client.setex(cache_key, 30, json.dumps(usage)) + logger.debug("Usage summary cached", tenant_id=str(tenant_id)) + return usage except Exception as e: diff --git a/services/tenant/app/main.py b/services/tenant/app/main.py index 5882cce0..dba91eb5 100644 --- a/services/tenant/app/main.py +++ b/services/tenant/app/main.py @@ -7,7 +7,7 @@ from fastapi import FastAPI from sqlalchemy import text from app.core.config import settings from app.core.database import database_manager -from app.api import tenants, tenant_members, tenant_operations, webhooks, plans, subscription, tenant_settings, whatsapp_admin, usage_forecast, enterprise_upgrade, tenant_locations, tenant_hierarchy, internal_demo, network_alerts +from app.api import tenants, tenant_members, tenant_operations, webhooks, plans, subscription, tenant_settings, whatsapp_admin, usage_forecast, enterprise_upgrade, tenant_locations, tenant_hierarchy, internal_demo, network_alerts, onboarding from shared.service_base import StandardFastAPIService @@ -158,6 +158,7 @@ service.add_router(internal_demo.router, tags=["internal-demo"]) # Internal dem service.add_router(tenant_hierarchy.router, tags=["tenant-hierarchy"]) # Tenant hierarchy endpoints service.add_router(internal_demo.router, tags=["internal-demo"]) # Internal demo data cloning service.add_router(network_alerts.router, tags=["network-alerts"]) # Network alerts aggregation endpoints +service.add_router(onboarding.router, tags=["onboarding"]) # Onboarding status endpoints if __name__ == "__main__": import uvicorn diff --git a/services/tenant/app/services/subscription_limit_service.py b/services/tenant/app/services/subscription_limit_service.py index 38e6911c..fc2f0471 100644 --- a/services/tenant/app/services/subscription_limit_service.py +++ b/services/tenant/app/services/subscription_limit_service.py @@ -437,18 +437,21 @@ class SubscriptionLimitService: current_users = len(members) current_locations = 1 # Each tenant has one primary location - # Get current usage - Products & Inventory - current_products = await self._get_ingredient_count(tenant_id) - current_recipes = await self._get_recipe_count(tenant_id) - current_suppliers = await self._get_supplier_count(tenant_id) + # Get current usage - Products & Inventory (parallel calls for performance) + import asyncio + current_products, current_recipes, current_suppliers = await asyncio.gather( + self._get_ingredient_count(tenant_id), + self._get_recipe_count(tenant_id), + self._get_supplier_count(tenant_id) + ) - # Get current usage - IA & Analytics (Redis-based daily quotas) - training_jobs_usage = await self._get_training_jobs_today(tenant_id, subscription.plan) - forecasts_usage = await self._get_forecasts_today(tenant_id, subscription.plan) - - # Get current usage - API & Storage (Redis-based) - api_calls_usage = await self._get_api_calls_this_hour(tenant_id, subscription.plan) - storage_usage = await self._get_file_storage_usage_gb(tenant_id, subscription.plan) + # Get current usage - IA & Analytics + API & Storage (parallel Redis calls for performance) + training_jobs_usage, forecasts_usage, api_calls_usage, storage_usage = await asyncio.gather( + self._get_training_jobs_today(tenant_id, subscription.plan), + self._get_forecasts_today(tenant_id, subscription.plan), + self._get_api_calls_this_hour(tenant_id, subscription.plan), + self._get_file_storage_usage_gb(tenant_id, subscription.plan) + ) # Get limits from subscription recipes_limit = await self._get_limit_from_plan(subscription.plan, 'recipes')