diff --git a/backend/src/database/tenant-schema.service.ts b/backend/src/database/tenant-schema.service.ts index d5e8d41..de60593 100644 --- a/backend/src/database/tenant-schema.service.ts +++ b/backend/src/database/tenant-schema.service.ts @@ -325,6 +325,8 @@ export class TenantSchemaService { risk_notes JSONB, requested_by UUID, response_time_ms INTEGER, + status VARCHAR(20) DEFAULT 'complete', + error_message TEXT, created_at TIMESTAMPTZ DEFAULT NOW() )`, diff --git a/backend/src/modules/health-scores/health-scores.controller.ts b/backend/src/modules/health-scores/health-scores.controller.ts index 378214e..4e2bddb 100644 --- a/backend/src/modules/health-scores/health-scores.controller.ts +++ b/backend/src/modules/health-scores/health-scores.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common'; +import { Controller, Get, Post, UseGuards, Req, Logger } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { AllowViewer } from '../../common/decorators/allow-viewer.decorator'; @@ -9,6 +9,8 @@ import { HealthScoresService } from './health-scores.service'; @ApiBearerAuth() @UseGuards(JwtAuthGuard) export class HealthScoresController { + private readonly logger = new Logger(HealthScoresController.name); + constructor(private service: HealthScoresService) {} @Get('latest') @@ -19,32 +21,56 @@ export class HealthScoresController { } @Post('calculate') - @ApiOperation({ summary: 'Trigger both health score recalculations (used by scheduler)' }) + @ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' }) @AllowViewer() async calculate(@Req() req: any) { const schema = req.user?.orgSchema; - const [operating, reserve] = await Promise.all([ + + // Fire-and-forget — background processing saves results to DB + Promise.all([ this.service.calculateScore(schema, 'operating'), this.service.calculateScore(schema, 'reserve'), - ]); - return { operating, reserve }; + ]).catch((err) => { + this.logger.error(`Background health score calculation failed: ${err.message}`); + }); + + return { + status: 'processing', + message: 'Health score calculations started. Results will appear when ready.', + }; } @Post('calculate/operating') - @ApiOperation({ summary: 'Recalculate operating fund health score only' }) + @ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' }) @AllowViewer() async calculateOperating(@Req() req: any) { const schema = req.user?.orgSchema; - const operating = await this.service.calculateScore(schema, 'operating'); - return { operating }; + + // Fire-and-forget + this.service.calculateScore(schema, 'operating').catch((err) => { + this.logger.error(`Background operating score failed: ${err.message}`); + }); + + return { + status: 'processing', + message: 'Operating fund health score calculation started.', + }; } @Post('calculate/reserve') - @ApiOperation({ summary: 'Recalculate reserve fund health score only' }) + @ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' }) @AllowViewer() async calculateReserve(@Req() req: any) { const schema = req.user?.orgSchema; - const reserve = await this.service.calculateScore(schema, 'reserve'); - return { reserve }; + + // Fire-and-forget + this.service.calculateScore(schema, 'reserve').catch((err) => { + this.logger.error(`Background reserve score failed: ${err.message}`); + }); + + return { + status: 'processing', + message: 'Reserve fund health score calculation started.', + }; } } diff --git a/backend/src/modules/health-scores/health-scores.service.ts b/backend/src/modules/health-scores/health-scores.service.ts index 3c41969..762d516 100644 --- a/backend/src/modules/health-scores/health-scores.service.ts +++ b/backend/src/modules/health-scores/health-scores.service.ts @@ -1115,7 +1115,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyString, 'utf-8'), }, - timeout: 120000, + timeout: 600000, // 10 minute timeout }; const req = https.request(options, (res) => { @@ -1129,7 +1129,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF req.on('error', (err) => reject(err)); req.on('timeout', () => { req.destroy(); - reject(new Error('Request timed out after 120s')); + reject(new Error('Request timed out after 600s')); }); req.write(bodyString); diff --git a/backend/src/modules/investment-planning/investment-planning.controller.ts b/backend/src/modules/investment-planning/investment-planning.controller.ts index 7b996b0..4d3c087 100644 --- a/backend/src/modules/investment-planning/investment-planning.controller.ts +++ b/backend/src/modules/investment-planning/investment-planning.controller.ts @@ -36,9 +36,9 @@ export class InvestmentPlanningController { } @Post('recommendations') - @ApiOperation({ summary: 'Get AI-powered investment recommendations' }) + @ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' }) @AllowViewer() - getRecommendations(@Req() req: any) { - return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId); + triggerRecommendations(@Req() req: any) { + return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId); } } diff --git a/backend/src/modules/investment-planning/investment-planning.service.ts b/backend/src/modules/investment-planning/investment-planning.service.ts index 2115423..245c630 100644 --- a/backend/src/modules/investment-planning/investment-planning.service.ts +++ b/backend/src/modules/investment-planning/investment-planning.service.ts @@ -65,6 +65,9 @@ export interface SavedRecommendation { risk_notes: string[]; response_time_ms: number; created_at: string; + status: 'processing' | 'complete' | 'error'; + last_failed: boolean; + error_message?: string; } @Injectable() @@ -196,14 +199,33 @@ export class InvestmentPlanningService { return rates.cd; } + /** + * Ensure the status/error_message columns exist (for tenants created before this migration). + */ + private async ensureStatusColumn(): Promise { + try { + await this.tenant.query( + `ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'complete'`, + ); + await this.tenant.query( + `ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS error_message TEXT`, + ); + } catch { + // Ignore — column may already exist or table may not exist + } + } + /** * Get the latest saved AI recommendation for this tenant. + * Returns status and last_failed flag for UI state management. */ async getSavedRecommendation(): Promise { try { + await this.ensureStatusColumn(); + const rows = await this.tenant.query( `SELECT id, recommendations_json, overall_assessment, risk_notes, - response_time_ms, created_at + response_time_ms, status, error_message, created_at FROM ai_recommendations ORDER BY created_at DESC LIMIT 1`, @@ -212,6 +234,64 @@ export class InvestmentPlanningService { if (!rows || rows.length === 0) return null; const row = rows[0]; + const status = row.status || 'complete'; + + // If still processing, return processing status + if (status === 'processing') { + return { + id: row.id, + recommendations: [], + overall_assessment: '', + risk_notes: [], + response_time_ms: 0, + created_at: row.created_at, + status: 'processing', + last_failed: false, + }; + } + + // If latest attempt failed, return the last successful result with last_failed flag + if (status === 'error') { + const lastGood = await this.tenant.query( + `SELECT id, recommendations_json, overall_assessment, risk_notes, + response_time_ms, created_at + FROM ai_recommendations + WHERE status = 'complete' + ORDER BY created_at DESC + LIMIT 1`, + ); + + if (lastGood?.length) { + const goodRow = lastGood[0]; + const recData = goodRow.recommendations_json || {}; + return { + id: goodRow.id, + recommendations: recData.recommendations || [], + overall_assessment: goodRow.overall_assessment || recData.overall_assessment || '', + risk_notes: goodRow.risk_notes || recData.risk_notes || [], + response_time_ms: goodRow.response_time_ms || 0, + created_at: goodRow.created_at, + status: 'complete', + last_failed: true, + error_message: row.error_message, + }; + } + + // No previous good result — return error state + return { + id: row.id, + recommendations: [], + overall_assessment: row.error_message || 'AI analysis failed. Please try again.', + risk_notes: [], + response_time_ms: 0, + created_at: row.created_at, + status: 'error', + last_failed: true, + error_message: row.error_message, + }; + } + + // Complete — return the data normally const recData = row.recommendations_json || {}; return { id: row.id, @@ -220,6 +300,8 @@ export class InvestmentPlanningService { risk_notes: row.risk_notes || recData.risk_notes || [], response_time_ms: row.response_time_ms || 0, created_at: row.created_at, + status: 'complete', + last_failed: false, }; } catch (err: any) { // Table might not exist yet (pre-migration tenants) @@ -228,15 +310,153 @@ export class InvestmentPlanningService { } } + /** + * Save a 'processing' placeholder record and return its ID. + */ + private async saveProcessingRecord(userId?: string): Promise { + await this.ensureStatusColumn(); + const rows = await this.tenant.query( + `INSERT INTO ai_recommendations + (recommendations_json, overall_assessment, risk_notes, requested_by, status) + VALUES ('{}', '', '[]', $1, 'processing') + RETURNING id`, + [userId || null], + ); + return rows[0].id; + } + + /** + * Update a processing record with completed results. + */ + private async updateRecommendationComplete( + jobId: string, + aiResponse: AIResponse, + userId: string | undefined, + elapsed: number, + ): Promise { + try { + await this.tenant.query( + `UPDATE ai_recommendations + SET recommendations_json = $1, + overall_assessment = $2, + risk_notes = $3, + response_time_ms = $4, + status = 'complete' + WHERE id = $5`, + [ + JSON.stringify(aiResponse), + aiResponse.overall_assessment || '', + JSON.stringify(aiResponse.risk_notes || []), + elapsed, + jobId, + ], + ); + } catch (err: any) { + this.logger.warn(`Could not update recommendation ${jobId}: ${err.message}`); + } + } + + /** + * Update a processing record with error status. + */ + private async updateRecommendationError(jobId: string, errorMessage: string): Promise { + try { + await this.tenant.query( + `UPDATE ai_recommendations + SET status = 'error', + error_message = $1 + WHERE id = $2`, + [errorMessage, jobId], + ); + } catch (err: any) { + this.logger.warn(`Could not update recommendation error ${jobId}: ${err.message}`); + } + } + + /** + * Trigger AI recommendations asynchronously. + * Saves a 'processing' record, starts the AI work in the background, and returns immediately. + * The TenantService instance remains alive via closure reference for the duration of the background work. + */ + async triggerAIRecommendations(userId?: string, orgId?: string): Promise<{ status: string; message: string }> { + const jobId = await this.saveProcessingRecord(userId); + this.logger.log(`AI recommendation triggered (job ${jobId}), starting background processing...`); + + // Fire-and-forget — the Promise keeps this service instance (and TenantService) alive + this.runBackgroundRecommendations(jobId, userId, orgId).catch((err) => { + this.logger.error(`Background AI recommendation failed (job ${jobId}): ${err.message}`); + }); + + return { + status: 'processing', + message: 'AI analysis has been started. You can navigate away safely — results will appear when ready.', + }; + } + + /** + * Run the full AI recommendation pipeline in the background. + */ + private async runBackgroundRecommendations(jobId: string, userId?: string, orgId?: string): Promise { + try { + const startTime = Date.now(); + + const [snapshot, allRates, monthlyForecast] = await Promise.all([ + this.getFinancialSnapshot(), + this.getMarketRates(), + this.getMonthlyForecast(), + ]); + + this.debug('background_snapshot_summary', { + job_id: jobId, + operating_cash: snapshot.summary.operating_cash, + reserve_cash: snapshot.summary.reserve_cash, + total_all: snapshot.summary.total_all, + investment_accounts: snapshot.investment_accounts.length, + }); + + const messages = this.buildPromptMessages(snapshot, allRates, monthlyForecast); + const aiResponse = await this.callAI(messages); + const elapsed = Date.now() - startTime; + + this.debug('background_final_response', { + job_id: jobId, + recommendation_count: aiResponse.recommendations.length, + has_assessment: !!aiResponse.overall_assessment, + elapsed_ms: elapsed, + }); + + // Check if the AI returned a graceful error (empty recommendations with error message) + const isGracefulError = aiResponse.recommendations.length === 0 && + (aiResponse.overall_assessment?.includes('Unable to generate') || + aiResponse.overall_assessment?.includes('invalid response')); + + if (isGracefulError) { + await this.updateRecommendationError(jobId, aiResponse.overall_assessment); + } else { + await this.updateRecommendationComplete(jobId, aiResponse, userId, elapsed); + } + + // Log AI usage (fire-and-forget) + this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {}); + + this.logger.log(`Background AI recommendation completed (job ${jobId}) in ${elapsed}ms`); + } catch (err: any) { + this.logger.error(`Background AI recommendation error (job ${jobId}): ${err.message}`); + await this.updateRecommendationError(jobId, err.message); + } + } + /** * Save AI recommendation result to tenant schema. + * @deprecated Use triggerAIRecommendations() for async flow instead */ private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise { try { + await this.ensureStatusColumn(); await this.tenant.query( `INSERT INTO ai_recommendations - (recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms) - VALUES ($1, $2, $3, $4, $5)`, + (recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms, status) + VALUES ($1, $2, $3, $4, $5, 'complete')`, [ JSON.stringify(aiResponse), aiResponse.overall_assessment || '', @@ -873,7 +1093,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(bodyString, 'utf-8'), }, - timeout: 300000, // 5 minute timeout + timeout: 600000, // 10 minute timeout }; const req = https.request(options, (res) => { @@ -887,7 +1107,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca req.on('error', (err) => reject(err)); req.on('timeout', () => { req.destroy(); - reject(new Error(`Request timed out after 300s`)); + reject(new Error(`Request timed out after 600s`)); }); req.write(bodyString); diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index 2c20c85..1ec5837 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -16,8 +16,8 @@ import { IconRefresh, IconInfoCircle, } from '@tabler/icons-react'; -import { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState, useCallback } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useAuthStore } from '../../stores/authStore'; import api from '../../services/api'; @@ -313,9 +313,9 @@ export function DashboardPage() { const currentOrg = useAuthStore((s) => s.currentOrg); const queryClient = useQueryClient(); - // Track whether last refresh attempt failed (per score type) - const [operatingFailed, setOperatingFailed] = useState(false); - const [reserveFailed, setReserveFailed] = useState(false); + // Track whether a refresh is in progress (per score type) for async polling + const [operatingRefreshing, setOperatingRefreshing] = useState(false); + const [reserveRefreshing, setReserveRefreshing] = useState(false); const { data, isLoading } = useQuery({ queryKey: ['dashboard'], @@ -327,33 +327,66 @@ export function DashboardPage() { queryKey: ['health-scores'], queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; }, enabled: !!currentOrg, + // Poll every 3 seconds while a refresh is in progress + refetchInterval: (operatingRefreshing || reserveRefreshing) ? 3000 : false, }); - // Separate mutations for each score type - const recalcOperatingMutation = useMutation({ - mutationFn: () => api.post('/health-scores/calculate/operating'), - onSuccess: () => { - setOperatingFailed(false); - queryClient.invalidateQueries({ queryKey: ['health-scores'] }); - }, - onError: () => { - setOperatingFailed(true); - // Still refresh to get whatever the backend saved (could be cached data) - queryClient.invalidateQueries({ queryKey: ['health-scores'] }); - }, - }); + // Async refresh handlers — trigger the backend and poll for results + const handleRefreshOperating = useCallback(async () => { + const prevId = healthScores?.operating?.id; + setOperatingRefreshing(true); + try { + await api.post('/health-scores/calculate/operating'); + } catch { + // Trigger failed at network level — polling will pick up any backend-saved error + } + // Start polling — watch for the health score to change (new id or updated timestamp) + const pollUntilDone = () => { + const checkInterval = setInterval(async () => { + try { + const { data: latest } = await api.get('/health-scores/latest'); + const newScore = latest?.operating; + if (newScore && newScore.id !== prevId) { + setOperatingRefreshing(false); + queryClient.setQueryData(['health-scores'], latest); + clearInterval(checkInterval); + } + } catch { + // Keep polling + } + }, 3000); + // Safety timeout — stop polling after 11 minutes + setTimeout(() => { clearInterval(checkInterval); setOperatingRefreshing(false); }, 660000); + }; + pollUntilDone(); + }, [healthScores?.operating?.id, queryClient]); - const recalcReserveMutation = useMutation({ - mutationFn: () => api.post('/health-scores/calculate/reserve'), - onSuccess: () => { - setReserveFailed(false); - queryClient.invalidateQueries({ queryKey: ['health-scores'] }); - }, - onError: () => { - setReserveFailed(true); - queryClient.invalidateQueries({ queryKey: ['health-scores'] }); - }, - }); + const handleRefreshReserve = useCallback(async () => { + const prevId = healthScores?.reserve?.id; + setReserveRefreshing(true); + try { + await api.post('/health-scores/calculate/reserve'); + } catch { + // Trigger failed at network level + } + const pollUntilDone = () => { + const checkInterval = setInterval(async () => { + try { + const { data: latest } = await api.get('/health-scores/latest'); + const newScore = latest?.reserve; + if (newScore && newScore.id !== prevId) { + setReserveRefreshing(false); + queryClient.setQueryData(['health-scores'], latest); + clearInterval(checkInterval); + } + } catch { + // Keep polling + } + }, 3000); + setTimeout(() => { clearInterval(checkInterval); setReserveRefreshing(false); }, 660000); + }; + pollUntilDone(); + }, [healthScores?.reserve?.id, queryClient]); const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); @@ -391,9 +424,9 @@ export function DashboardPage() { } - isRefreshing={recalcOperatingMutation.isPending} - onRefresh={() => recalcOperatingMutation.mutate()} - lastFailed={operatingFailed || !!healthScores?.operating_last_failed} + isRefreshing={operatingRefreshing} + onRefresh={handleRefreshOperating} + lastFailed={!!healthScores?.operating_last_failed} /> } - isRefreshing={recalcReserveMutation.isPending} - onRefresh={() => recalcReserveMutation.mutate()} - lastFailed={reserveFailed || !!healthScores?.reserve_last_failed} + isRefreshing={reserveRefreshing} + onRefresh={handleRefreshReserve} + lastFailed={!!healthScores?.reserve_last_failed} /> diff --git a/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx b/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx index 48f5bc1..1dfb4ab 100644 --- a/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx +++ b/frontend/src/pages/investment-planning/InvestmentPlanningPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Title, Text, @@ -33,7 +33,7 @@ import { IconChevronDown, IconChevronUp, } from '@tabler/icons-react'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { notifications } from '@mantine/notifications'; import api from '../../services/api'; @@ -107,6 +107,9 @@ interface SavedRecommendation { risk_notes: string[]; response_time_ms: number; created_at: string; + status: 'processing' | 'complete' | 'error'; + last_failed: boolean; + error_message?: string; } // ── Helpers ── @@ -181,14 +184,29 @@ function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean // ── Recommendations Display Component ── -function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIResponse; lastUpdated?: string }) { +function RecommendationsDisplay({ + aiResult, + lastUpdated, + lastFailed, +}: { + aiResult: AIResponse; + lastUpdated?: string; + lastFailed?: boolean; +}) { return ( - {/* Last Updated timestamp */} + {/* Last Updated timestamp + failure message */} {lastUpdated && ( - - Last updated: {new Date(lastUpdated).toLocaleString()} - + + + Last updated: {new Date(lastUpdated).toLocaleString()} + + {lastFailed && ( + + last analysis failed — showing cached data + + )} + )} {/* Overall Assessment */} @@ -327,9 +345,8 @@ function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIRespons // ── Main Component ── export function InvestmentPlanningPage() { - const [aiResult, setAiResult] = useState(null); - const [lastUpdated, setLastUpdated] = useState(null); const [ratesExpanded, setRatesExpanded] = useState(true); + const [isTriggering, setIsTriggering] = useState(false); // Load financial snapshot on mount const { data: snapshot, isLoading: snapshotLoading } = useQuery({ @@ -349,50 +366,86 @@ export function InvestmentPlanningPage() { }, }); - // Load saved recommendation on mount + // Load saved recommendation — polls every 3s when processing const { data: savedRec } = useQuery({ queryKey: ['investment-planning-saved-recommendation'], queryFn: async () => { const { data } = await api.get('/investment-planning/saved-recommendation'); return data; }, + refetchInterval: (query) => { + const rec = query.state.data; + // Poll every 3 seconds while processing + if (rec?.status === 'processing') return 3000; + // Also poll if we just triggered (status may not be 'processing' yet) + if (isTriggering) return 3000; + return false; + }, }); - // Populate AI results from saved recommendation on load - useEffect(() => { - if (savedRec && !aiResult) { - setAiResult({ - recommendations: savedRec.recommendations, - overall_assessment: savedRec.overall_assessment, - risk_notes: savedRec.risk_notes, - }); - setLastUpdated(savedRec.created_at); - } - }, [savedRec]); // eslint-disable-line react-hooks/exhaustive-deps + // Derive display state from saved recommendation + const isProcessing = savedRec?.status === 'processing' || isTriggering; + const lastFailed = savedRec?.last_failed || false; + const hasResults = savedRec && savedRec.status === 'complete' && savedRec.recommendations.length > 0; + const hasError = savedRec?.status === 'error' && !savedRec?.recommendations?.length; - // AI recommendation (on-demand) - const aiMutation = useMutation({ - mutationFn: async () => { - const { data } = await api.post('/investment-planning/recommendations', {}, { timeout: 300000 }); - return data as AIResponse; - }, - onSuccess: (data) => { - setAiResult(data); - setLastUpdated(new Date().toISOString()); - if (data.recommendations.length > 0) { - notifications.show({ - message: `Generated ${data.recommendations.length} investment recommendations`, - color: 'green', - }); - } - }, - onError: (err: any) => { + // Clear triggering flag once backend confirms processing or completes + useEffect(() => { + if (isTriggering && savedRec?.status === 'processing') { + setIsTriggering(false); + } + if (isTriggering && savedRec?.status === 'complete') { + setIsTriggering(false); + } + }, [savedRec?.status, isTriggering]); + + // Show notification when processing completes (transition from processing) + const prevStatusRef = useState(null); + useEffect(() => { + const [prevStatus, setPrevStatus] = prevStatusRef; + if (prevStatus === 'processing' && savedRec?.status === 'complete') { notifications.show({ - message: err.response?.data?.message || 'Failed to get AI recommendations', + message: `Generated ${savedRec.recommendations.length} investment recommendations`, + color: 'green', + }); + } + if (prevStatus === 'processing' && savedRec?.status === 'error') { + notifications.show({ + message: savedRec.error_message || 'AI recommendation analysis failed', color: 'red', }); - }, - }); + } + setPrevStatus(savedRec?.status || null); + }, [savedRec?.status]); // eslint-disable-line react-hooks/exhaustive-deps + + // Trigger AI recommendations (async — returns immediately) + const handleTriggerAI = useCallback(async () => { + setIsTriggering(true); + try { + await api.post('/investment-planning/recommendations'); + } catch (err: any) { + setIsTriggering(false); + notifications.show({ + message: err.response?.data?.message || 'Failed to start AI analysis', + color: 'red', + }); + } + }, []); + + // Build AI result from saved recommendation for display + const aiResult: AIResponse | null = hasResults + ? { + recommendations: savedRec!.recommendations, + overall_assessment: savedRec!.overall_assessment, + risk_notes: savedRec!.risk_notes, + } + : (lastFailed && savedRec?.recommendations?.length) + ? { + recommendations: savedRec!.recommendations, + overall_assessment: savedRec!.overall_assessment, + risk_notes: savedRec!.risk_notes, + } + : null; if (snapshotLoading) { return ( @@ -645,8 +698,8 @@ export function InvestmentPlanningPage() { - {/* Loading State */} - {aiMutation.isPending && ( + {/* Processing State */} + {isProcessing && (
@@ -663,19 +716,32 @@ export function InvestmentPlanningPage() { Analyzing your financial data and market rates... - This may take a few minutes for complex tenant data + You can navigate away — results will appear when ready
)} - {/* Results */} - {aiResult && !aiMutation.isPending && ( - + {/* Error State (no cached data) */} + {hasError && !isProcessing && ( + + + {savedRec?.error_message || 'The last AI analysis failed. Please try again.'} + + + )} + + {/* Results (with optional failure watermark) */} + {aiResult && !isProcessing && ( + )} {/* Empty State */} - {!aiResult && !aiMutation.isPending && ( + {!aiResult && !isProcessing && !hasError && ( diff --git a/nginx/default.conf b/nginx/default.conf index 61feae0..6f921b3 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -23,21 +23,8 @@ server { proxy_cache_bypass $http_upgrade; } - # AI recommendation endpoint needs a longer timeout (up to 3 minutes) - location /api/investment-planning/recommendations { - proxy_pass http://backend; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_cache_bypass $http_upgrade; - proxy_read_timeout 180s; - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - } + # AI endpoints now return immediately (async processing in background) + # No special timeout needed — kept for documentation purposes # Everything else -> Vite dev server (frontend) location / { diff --git a/nginx/host-production.conf b/nginx/host-production.conf index 785fbcd..d2ea243 100644 --- a/nginx/host-production.conf +++ b/nginx/host-production.conf @@ -74,20 +74,8 @@ server { proxy_send_timeout 15s; } - # AI endpoints — longer timeouts (LLM calls can take minutes) - location /api/investment-planning/recommendations { - proxy_pass http://127.0.0.1:3000; - proxy_read_timeout 300s; - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - } - - location /api/health-scores/calculate { - proxy_pass http://127.0.0.1:3000; - proxy_read_timeout 180s; - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - } + # AI endpoints now return immediately (async processing in background) + # No special timeout overrides needed # --- Frontend → React SPA served by nginx (port 3001) --- location / { diff --git a/nginx/production.conf b/nginx/production.conf index 9eec704..11d0826 100644 --- a/nginx/production.conf +++ b/nginx/production.conf @@ -40,20 +40,8 @@ server { proxy_send_timeout 15s; } - # AI endpoints → longer timeouts - location /api/investment-planning/recommendations { - proxy_pass http://backend; - proxy_read_timeout 180s; - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - } - - location /api/health-scores/calculate { - proxy_pass http://backend; - proxy_read_timeout 180s; - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - } + # AI endpoints now return immediately (async processing in background) + # No special timeout overrides needed # --- Static frontend → built React assets --- location / { diff --git a/nginx/ssl.conf b/nginx/ssl.conf index 7625aea..bd98952 100644 --- a/nginx/ssl.conf +++ b/nginx/ssl.conf @@ -60,37 +60,8 @@ server { proxy_cache_bypass $http_upgrade; } - # AI recommendation endpoint needs a longer timeout (up to 3 minutes) - location /api/investment-planning/recommendations { - proxy_pass http://backend; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_cache_bypass $http_upgrade; - proxy_read_timeout 180s; - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - } - - # AI health-score endpoint also needs a longer timeout - location /api/health-scores/calculate { - proxy_pass http://backend; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_cache_bypass $http_upgrade; - proxy_read_timeout 180s; - proxy_connect_timeout 10s; - proxy_send_timeout 30s; - } + # AI endpoints now return immediately (async processing in background) + # No special timeout overrides needed # Everything else -> Vite dev server (frontend) location / {