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:
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user