feat: reliability enhancements for AI services and capital planning

1. Health Scores — separate operating/reserve refresh
   - Added POST /health-scores/calculate/operating and /calculate/reserve
   - Each health card now has its own Refresh button
   - On failure, shows cached (last good) data with "last analysis failed"
     watermark instead of blank "Error calculating score"
   - Backend getLatestScores returns latest complete score + failure flag

2. Investment Planning — increased AI timeout to 5 minutes
   - Backend callAI timeout: 180s → 300s
   - Frontend axios timeout: set explicitly to 300s (was browser default)
   - Host nginx proxy_read_timeout: 180s → 300s
   - Loading message updated to reflect longer wait times

3. Capital Planning — Unscheduled column moved to rightmost position
   - Kanban column order: current year → future → unscheduled (was leftmost)
   - Puts immediate/near-term projects front and center

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 12:02:30 -05:00
parent 467fdd2a6c
commit 337b6061b2
7 changed files with 175 additions and 47 deletions

View File

@@ -19,7 +19,7 @@ export class HealthScoresController {
}
@Post('calculate')
@ApiOperation({ summary: 'Trigger health score recalculation for current tenant' })
@ApiOperation({ summary: 'Trigger both health score recalculations (used by scheduler)' })
@AllowViewer()
async calculate(@Req() req: any) {
const schema = req.user?.orgSchema;
@@ -29,4 +29,22 @@ export class HealthScoresController {
]);
return { operating, reserve };
}
@Post('calculate/operating')
@ApiOperation({ summary: 'Recalculate operating fund health score only' })
@AllowViewer()
async calculateOperating(@Req() req: any) {
const schema = req.user?.orgSchema;
const operating = await this.service.calculateScore(schema, 'operating');
return { operating };
}
@Post('calculate/reserve')
@ApiOperation({ summary: 'Recalculate reserve fund health score only' })
@AllowViewer()
async calculateReserve(@Req() req: any) {
const schema = req.user?.orgSchema;
const reserve = await this.service.calculateScore(schema, 'reserve');
return { reserve };
}
}

View File

@@ -47,23 +47,49 @@ export class HealthScoresService {
// ── Public API ──
async getLatestScores(schema: string): Promise<{ operating: HealthScore | null; reserve: HealthScore | null }> {
async getLatestScores(schema: string): Promise<{
operating: HealthScore | null;
reserve: HealthScore | null;
operating_last_failed: boolean;
reserve_last_failed: boolean;
}> {
const qr = this.dataSource.createQueryRunner();
try {
await qr.connect();
await qr.query(`SET search_path TO "${schema}"`);
const operating = await qr.query(
`SELECT * FROM health_scores WHERE score_type = 'operating' ORDER BY calculated_at DESC LIMIT 1`,
);
const reserve = await qr.query(
`SELECT * FROM health_scores WHERE score_type = 'reserve' ORDER BY calculated_at DESC LIMIT 1`,
);
// For each score type, return the latest *successful* score for display,
// and flag whether the most recent attempt (any status) was an error.
const result = { operating: null as HealthScore | null, reserve: null as HealthScore | null, operating_last_failed: false, reserve_last_failed: false };
return {
operating: operating[0] || null,
reserve: reserve[0] || null,
};
for (const scoreType of ['operating', 'reserve'] as const) {
// Most recent row (any status)
const latest = await qr.query(
`SELECT * FROM health_scores WHERE score_type = $1 ORDER BY calculated_at DESC LIMIT 1`,
[scoreType],
);
const latestRow = latest[0] || null;
if (!latestRow) {
// No scores at all
continue;
}
if (latestRow.status === 'error') {
// Most recent attempt failed — return the latest *complete* score instead
const lastGood = await qr.query(
`SELECT * FROM health_scores WHERE score_type = $1 AND status = 'complete' ORDER BY calculated_at DESC LIMIT 1`,
[scoreType],
);
result[scoreType] = lastGood[0] || latestRow; // fall back to error row if no good score exists
result[`${scoreType}_last_failed`] = true;
} else {
result[scoreType] = latestRow;
result[`${scoreType}_last_failed`] = false;
}
}
return result;
} finally {
await qr.release();
}

View File

@@ -873,7 +873,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: 180000, // 3 minute timeout
timeout: 300000, // 5 minute timeout
};
const req = https.request(options, (res) => {
@@ -887,7 +887,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 180s`));
reject(new Error(`Request timed out after 300s`));
});
req.write(bodyString);