Compare commits
2 Commits
claude/pra
...
feature/in
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b83defbc3 | |||
| 1e31595d7f |
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.7-beta",
|
"version": "2026.3.2-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.7-beta",
|
"version": "2026.3.2-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.7-beta",
|
"version": "2026.3.2-beta",
|
||||||
"description": "HOA LedgerIQ - Backend API",
|
"description": "HOA LedgerIQ - Backend API",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -330,8 +330,6 @@ export class TenantSchemaService {
|
|||||||
risk_notes JSONB,
|
risk_notes JSONB,
|
||||||
requested_by UUID,
|
requested_by UUID,
|
||||||
response_time_ms INTEGER,
|
response_time_ms INTEGER,
|
||||||
status VARCHAR(20) DEFAULT 'complete',
|
|
||||||
error_message TEXT,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ async function bootstrap() {
|
|||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('HOA LedgerIQ API')
|
.setTitle('HOA LedgerIQ API')
|
||||||
.setDescription('API for the HOA LedgerIQ')
|
.setDescription('API for the HOA LedgerIQ')
|
||||||
.setVersion('2026.3.7')
|
.setVersion('2026.3.2')
|
||||||
.addBearerAuth()
|
.addBearerAuth()
|
||||||
.build();
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Controller, Get, Post, UseGuards, Req, Logger } from '@nestjs/common';
|
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
@@ -9,8 +9,6 @@ import { HealthScoresService } from './health-scores.service';
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class HealthScoresController {
|
export class HealthScoresController {
|
||||||
private readonly logger = new Logger(HealthScoresController.name);
|
|
||||||
|
|
||||||
constructor(private service: HealthScoresService) {}
|
constructor(private service: HealthScoresService) {}
|
||||||
|
|
||||||
@Get('latest')
|
@Get('latest')
|
||||||
@@ -21,56 +19,32 @@ export class HealthScoresController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('calculate')
|
@Post('calculate')
|
||||||
@ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
|
@ApiOperation({ summary: 'Trigger both health score recalculations (used by scheduler)' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
async calculate(@Req() req: any) {
|
async calculate(@Req() req: any) {
|
||||||
const schema = req.user?.orgSchema;
|
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, 'operating'),
|
||||||
this.service.calculateScore(schema, 'reserve'),
|
this.service.calculateScore(schema, 'reserve'),
|
||||||
]).catch((err) => {
|
]);
|
||||||
this.logger.error(`Background health score calculation failed: ${err.message}`);
|
return { operating, reserve };
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: 'processing',
|
|
||||||
message: 'Health score calculations started. Results will appear when ready.',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('calculate/operating')
|
@Post('calculate/operating')
|
||||||
@ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
|
@ApiOperation({ summary: 'Recalculate operating fund health score only' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
async calculateOperating(@Req() req: any) {
|
async calculateOperating(@Req() req: any) {
|
||||||
const schema = req.user?.orgSchema;
|
const schema = req.user?.orgSchema;
|
||||||
|
const operating = await this.service.calculateScore(schema, 'operating');
|
||||||
// Fire-and-forget
|
return { operating };
|
||||||
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')
|
@Post('calculate/reserve')
|
||||||
@ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
|
@ApiOperation({ summary: 'Recalculate reserve fund health score only' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
async calculateReserve(@Req() req: any) {
|
async calculateReserve(@Req() req: any) {
|
||||||
const schema = req.user?.orgSchema;
|
const schema = req.user?.orgSchema;
|
||||||
|
const reserve = await this.service.calculateScore(schema, 'reserve');
|
||||||
// Fire-and-forget
|
return { reserve };
|
||||||
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.',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1115,7 +1115,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
||||||
},
|
},
|
||||||
timeout: 600000, // 10 minute timeout
|
timeout: 120000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
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('error', (err) => reject(err));
|
||||||
req.on('timeout', () => {
|
req.on('timeout', () => {
|
||||||
req.destroy();
|
req.destroy();
|
||||||
reject(new Error('Request timed out after 600s'));
|
reject(new Error('Request timed out after 120s'));
|
||||||
});
|
});
|
||||||
|
|
||||||
req.write(bodyString);
|
req.write(bodyString);
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ export class InvestmentPlanningController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('recommendations')
|
@Post('recommendations')
|
||||||
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
|
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
triggerRecommendations(@Req() req: any) {
|
getRecommendations(@Req() req: any) {
|
||||||
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
|
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,6 @@ export interface SavedRecommendation {
|
|||||||
risk_notes: string[];
|
risk_notes: string[];
|
||||||
response_time_ms: number;
|
response_time_ms: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
status: 'processing' | 'complete' | 'error';
|
|
||||||
last_failed: boolean;
|
|
||||||
error_message?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -199,33 +196,14 @@ export class InvestmentPlanningService {
|
|||||||
return rates.cd;
|
return rates.cd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure the status/error_message columns exist (for tenants created before this migration).
|
|
||||||
*/
|
|
||||||
private async ensureStatusColumn(): Promise<void> {
|
|
||||||
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.
|
* Get the latest saved AI recommendation for this tenant.
|
||||||
* Returns status and last_failed flag for UI state management.
|
|
||||||
*/
|
*/
|
||||||
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
|
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
|
||||||
try {
|
try {
|
||||||
await this.ensureStatusColumn();
|
|
||||||
|
|
||||||
const rows = await this.tenant.query(
|
const rows = await this.tenant.query(
|
||||||
`SELECT id, recommendations_json, overall_assessment, risk_notes,
|
`SELECT id, recommendations_json, overall_assessment, risk_notes,
|
||||||
response_time_ms, status, error_message, created_at
|
response_time_ms, created_at
|
||||||
FROM ai_recommendations
|
FROM ai_recommendations
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
@@ -234,64 +212,6 @@ export class InvestmentPlanningService {
|
|||||||
if (!rows || rows.length === 0) return null;
|
if (!rows || rows.length === 0) return null;
|
||||||
|
|
||||||
const row = rows[0];
|
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 || {};
|
const recData = row.recommendations_json || {};
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -300,8 +220,6 @@ export class InvestmentPlanningService {
|
|||||||
risk_notes: row.risk_notes || recData.risk_notes || [],
|
risk_notes: row.risk_notes || recData.risk_notes || [],
|
||||||
response_time_ms: row.response_time_ms || 0,
|
response_time_ms: row.response_time_ms || 0,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
status: 'complete',
|
|
||||||
last_failed: false,
|
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// Table might not exist yet (pre-migration tenants)
|
// Table might not exist yet (pre-migration tenants)
|
||||||
@@ -310,153 +228,15 @@ export class InvestmentPlanningService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Save a 'processing' placeholder record and return its ID.
|
|
||||||
*/
|
|
||||||
private async saveProcessingRecord(userId?: string): Promise<string> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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.
|
* 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<void> {
|
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.ensureStatusColumn();
|
|
||||||
await this.tenant.query(
|
await this.tenant.query(
|
||||||
`INSERT INTO ai_recommendations
|
`INSERT INTO ai_recommendations
|
||||||
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms, status)
|
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms)
|
||||||
VALUES ($1, $2, $3, $4, $5, 'complete')`,
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
[
|
[
|
||||||
JSON.stringify(aiResponse),
|
JSON.stringify(aiResponse),
|
||||||
aiResponse.overall_assessment || '',
|
aiResponse.overall_assessment || '',
|
||||||
@@ -1093,7 +873,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
||||||
},
|
},
|
||||||
timeout: 600000, // 10 minute timeout
|
timeout: 300000, // 5 minute timeout
|
||||||
};
|
};
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
@@ -1107,7 +887,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
|
|||||||
req.on('error', (err) => reject(err));
|
req.on('error', (err) => reject(err));
|
||||||
req.on('timeout', () => {
|
req.on('timeout', () => {
|
||||||
req.destroy();
|
req.destroy();
|
||||||
reject(new Error(`Request timed out after 600s`));
|
reject(new Error(`Request timed out after 300s`));
|
||||||
});
|
});
|
||||||
|
|
||||||
req.write(bodyString);
|
req.write(bodyString);
|
||||||
|
|||||||
@@ -13,16 +13,6 @@ export class JournalEntriesService {
|
|||||||
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
|
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
|
||||||
let sql = `
|
let sql = `
|
||||||
SELECT je.*,
|
SELECT je.*,
|
||||||
CASE
|
|
||||||
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
|
|
||||||
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.debit ELSE 0 END), 0)
|
|
||||||
ELSE COALESCE(SUM(jel.debit), 0)
|
|
||||||
END as total_debit,
|
|
||||||
CASE
|
|
||||||
WHEN SUM(CASE WHEN a.account_type IN ('income','expense') THEN 1 ELSE 0 END) > 0
|
|
||||||
THEN COALESCE(SUM(CASE WHEN a.account_type IN ('income','expense') THEN jel.credit ELSE 0 END), 0)
|
|
||||||
ELSE COALESCE(SUM(jel.credit), 0)
|
|
||||||
END as total_credit,
|
|
||||||
json_agg(json_build_object(
|
json_agg(json_build_object(
|
||||||
'id', jel.id, 'account_id', jel.account_id,
|
'id', jel.id, 'account_id', jel.account_id,
|
||||||
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
|
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,
|
||||||
|
|||||||
@@ -153,14 +153,6 @@ export class OrganizationsService {
|
|||||||
existing.role = data.role;
|
existing.role = data.role;
|
||||||
return this.userOrgRepository.save(existing);
|
return this.userOrgRepository.save(existing);
|
||||||
}
|
}
|
||||||
// Update password for existing user being added to a new org
|
|
||||||
if (data.password) {
|
|
||||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
|
||||||
await dataSource.query(
|
|
||||||
`UPDATE shared.users SET password_hash = $1 WHERE id = $2`,
|
|
||||||
[passwordHash, userId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Create new user
|
// Create new user
|
||||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.7-beta",
|
"version": "2026.3.2-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.7-beta",
|
"version": "2026.3.2-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^7.15.3",
|
"@mantine/core": "^7.15.3",
|
||||||
"@mantine/dates": "^7.15.3",
|
"@mantine/dates": "^7.15.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.7-beta",
|
"version": "2026.3.2-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button, ActionIcon, Tooltip } from '@mantine/core';
|
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
IconLogout,
|
IconLogout,
|
||||||
@@ -9,12 +9,9 @@ import {
|
|||||||
IconUserCog,
|
IconUserCog,
|
||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
IconSun,
|
|
||||||
IconMoon,
|
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { AppTour } from '../onboarding/AppTour';
|
import { AppTour } from '../onboarding/AppTour';
|
||||||
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
||||||
@@ -23,7 +20,6 @@ import logoSrc from '../../assets/logo.svg';
|
|||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const [opened, { toggle, close }] = useDisclosure();
|
const [opened, { toggle, close }] = useDisclosure();
|
||||||
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
|
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
|
||||||
const { colorScheme, toggleColorScheme } = usePreferencesStore();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isImpersonating = !!impersonationOriginal;
|
const isImpersonating = !!impersonationOriginal;
|
||||||
@@ -112,16 +108,6 @@ export function AppLayout() {
|
|||||||
{currentOrg && (
|
{currentOrg && (
|
||||||
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
||||||
)}
|
)}
|
||||||
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
size="lg"
|
|
||||||
onClick={toggleColorScheme}
|
|
||||||
aria-label="Toggle color scheme"
|
|
||||||
>
|
|
||||||
{colorScheme === 'dark' ? <IconSun size={18} /> : <IconMoon size={18} />}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<Menu shadow="md" width={220}>
|
<Menu shadow="md" width={220}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import '@mantine/dates/styles.css';
|
|||||||
import '@mantine/notifications/styles.css';
|
import '@mantine/notifications/styles.css';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { theme } from './theme/theme';
|
import { theme } from './theme/theme';
|
||||||
import { usePreferencesStore } from './stores/preferencesStore';
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -22,11 +21,9 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function Root() {
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
const colorScheme = usePreferencesStore((s) => s.colorScheme);
|
<React.StrictMode>
|
||||||
|
<MantineProvider theme={theme}>
|
||||||
return (
|
|
||||||
<MantineProvider theme={theme} forceColorScheme={colorScheme}>
|
|
||||||
<Notifications position="top-right" />
|
<Notifications position="top-right" />
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -36,11 +33,5 @@ function Root() {
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<Root />
|
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useState, useCallback } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
@@ -313,9 +313,9 @@ export function DashboardPage() {
|
|||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Track whether a refresh is in progress (per score type) for async polling
|
// Track whether last refresh attempt failed (per score type)
|
||||||
const [operatingRefreshing, setOperatingRefreshing] = useState(false);
|
const [operatingFailed, setOperatingFailed] = useState(false);
|
||||||
const [reserveRefreshing, setReserveRefreshing] = useState(false);
|
const [reserveFailed, setReserveFailed] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading } = useQuery<DashboardData>({
|
const { data, isLoading } = useQuery<DashboardData>({
|
||||||
queryKey: ['dashboard'],
|
queryKey: ['dashboard'],
|
||||||
@@ -327,66 +327,33 @@ export function DashboardPage() {
|
|||||||
queryKey: ['health-scores'],
|
queryKey: ['health-scores'],
|
||||||
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
||||||
enabled: !!currentOrg,
|
enabled: !!currentOrg,
|
||||||
// Poll every 3 seconds while a refresh is in progress
|
|
||||||
refetchInterval: (operatingRefreshing || reserveRefreshing) ? 3000 : false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Async refresh handlers — trigger the backend and poll for results
|
// Separate mutations for each score type
|
||||||
const handleRefreshOperating = useCallback(async () => {
|
const recalcOperatingMutation = useMutation({
|
||||||
const prevId = healthScores?.operating?.id;
|
mutationFn: () => api.post('/health-scores/calculate/operating'),
|
||||||
setOperatingRefreshing(true);
|
onSuccess: () => {
|
||||||
try {
|
setOperatingFailed(false);
|
||||||
await api.post('/health-scores/calculate/operating');
|
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||||
} catch {
|
},
|
||||||
// Trigger failed at network level — polling will pick up any backend-saved error
|
onError: () => {
|
||||||
}
|
setOperatingFailed(true);
|
||||||
// Start polling — watch for the health score to change (new id or updated timestamp)
|
// Still refresh to get whatever the backend saved (could be cached data)
|
||||||
const pollUntilDone = () => {
|
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||||
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 handleRefreshReserve = useCallback(async () => {
|
const recalcReserveMutation = useMutation({
|
||||||
const prevId = healthScores?.reserve?.id;
|
mutationFn: () => api.post('/health-scores/calculate/reserve'),
|
||||||
setReserveRefreshing(true);
|
onSuccess: () => {
|
||||||
try {
|
setReserveFailed(false);
|
||||||
await api.post('/health-scores/calculate/reserve');
|
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||||
} catch {
|
},
|
||||||
// Trigger failed at network level
|
onError: () => {
|
||||||
}
|
setReserveFailed(true);
|
||||||
const pollUntilDone = () => {
|
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
|
||||||
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) =>
|
const fmt = (v: string | number) =>
|
||||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
@@ -414,6 +381,7 @@ export function DashboardPage() {
|
|||||||
<Center h={200}><Loader /></Center>
|
<Center h={200}><Loader /></Center>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
<HealthScoreCard
|
<HealthScoreCard
|
||||||
score={healthScores?.operating || null}
|
score={healthScores?.operating || null}
|
||||||
@@ -423,9 +391,9 @@ export function DashboardPage() {
|
|||||||
<IconHeartbeat size={20} />
|
<IconHeartbeat size={20} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
isRefreshing={operatingRefreshing}
|
isRefreshing={recalcOperatingMutation.isPending}
|
||||||
onRefresh={handleRefreshOperating}
|
onRefresh={() => recalcOperatingMutation.mutate()}
|
||||||
lastFailed={!!healthScores?.operating_last_failed}
|
lastFailed={operatingFailed || !!healthScores?.operating_last_failed}
|
||||||
/>
|
/>
|
||||||
<HealthScoreCard
|
<HealthScoreCard
|
||||||
score={healthScores?.reserve || null}
|
score={healthScores?.reserve || null}
|
||||||
@@ -435,9 +403,9 @@ export function DashboardPage() {
|
|||||||
<IconHeartbeat size={20} />
|
<IconHeartbeat size={20} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
isRefreshing={reserveRefreshing}
|
isRefreshing={recalcReserveMutation.isPending}
|
||||||
onRefresh={handleRefreshReserve}
|
onRefresh={() => recalcReserveMutation.mutate()}
|
||||||
lastFailed={!!healthScores?.reserve_last_failed}
|
lastFailed={reserveFailed || !!healthScores?.reserve_last_failed}
|
||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
@@ -107,9 +107,6 @@ interface SavedRecommendation {
|
|||||||
risk_notes: string[];
|
risk_notes: string[];
|
||||||
response_time_ms: number;
|
response_time_ms: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
status: 'processing' | 'complete' | 'error';
|
|
||||||
last_failed: boolean;
|
|
||||||
error_message?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──
|
// ── Helpers ──
|
||||||
@@ -184,29 +181,14 @@ function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean
|
|||||||
|
|
||||||
// ── Recommendations Display Component ──
|
// ── Recommendations Display Component ──
|
||||||
|
|
||||||
function RecommendationsDisplay({
|
function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIResponse; lastUpdated?: string }) {
|
||||||
aiResult,
|
|
||||||
lastUpdated,
|
|
||||||
lastFailed,
|
|
||||||
}: {
|
|
||||||
aiResult: AIResponse;
|
|
||||||
lastUpdated?: string;
|
|
||||||
lastFailed?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
{/* Last Updated timestamp + failure message */}
|
{/* Last Updated timestamp */}
|
||||||
{lastUpdated && (
|
{lastUpdated && (
|
||||||
<Stack gap={0} align="flex-end">
|
<Text size="xs" c="dimmed" ta="right">
|
||||||
<Text size="xs" c="dimmed" ta="right">
|
Last updated: {new Date(lastUpdated).toLocaleString()}
|
||||||
Last updated: {new Date(lastUpdated).toLocaleString()}
|
</Text>
|
||||||
</Text>
|
|
||||||
{lastFailed && (
|
|
||||||
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
|
|
||||||
last analysis failed — showing cached data
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Overall Assessment */}
|
{/* Overall Assessment */}
|
||||||
@@ -345,8 +327,9 @@ function RecommendationsDisplay({
|
|||||||
// ── Main Component ──
|
// ── Main Component ──
|
||||||
|
|
||||||
export function InvestmentPlanningPage() {
|
export function InvestmentPlanningPage() {
|
||||||
|
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
||||||
const [ratesExpanded, setRatesExpanded] = useState(true);
|
const [ratesExpanded, setRatesExpanded] = useState(true);
|
||||||
const [isTriggering, setIsTriggering] = useState(false);
|
|
||||||
|
|
||||||
// Load financial snapshot on mount
|
// Load financial snapshot on mount
|
||||||
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
||||||
@@ -366,86 +349,50 @@ export function InvestmentPlanningPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load saved recommendation — polls every 3s when processing
|
// Load saved recommendation on mount
|
||||||
const { data: savedRec } = useQuery<SavedRecommendation | null>({
|
const { data: savedRec } = useQuery<SavedRecommendation | null>({
|
||||||
queryKey: ['investment-planning-saved-recommendation'],
|
queryKey: ['investment-planning-saved-recommendation'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get('/investment-planning/saved-recommendation');
|
const { data } = await api.get('/investment-planning/saved-recommendation');
|
||||||
return data;
|
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;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Derive display state from saved recommendation
|
// Populate AI results from saved recommendation on load
|
||||||
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;
|
|
||||||
|
|
||||||
// Clear triggering flag once backend confirms processing or completes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTriggering && savedRec?.status === 'processing') {
|
if (savedRec && !aiResult) {
|
||||||
setIsTriggering(false);
|
setAiResult({
|
||||||
}
|
recommendations: savedRec.recommendations,
|
||||||
if (isTriggering && savedRec?.status === 'complete') {
|
overall_assessment: savedRec.overall_assessment,
|
||||||
setIsTriggering(false);
|
risk_notes: savedRec.risk_notes,
|
||||||
}
|
|
||||||
}, [savedRec?.status, isTriggering]);
|
|
||||||
|
|
||||||
// Show notification when processing completes (transition from processing)
|
|
||||||
const prevStatusRef = useState<string | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const [prevStatus, setPrevStatus] = prevStatusRef;
|
|
||||||
if (prevStatus === 'processing' && savedRec?.status === 'complete') {
|
|
||||||
notifications.show({
|
|
||||||
message: `Generated ${savedRec.recommendations.length} investment recommendations`,
|
|
||||||
color: 'green',
|
|
||||||
});
|
});
|
||||||
|
setLastUpdated(savedRec.created_at);
|
||||||
}
|
}
|
||||||
if (prevStatus === 'processing' && savedRec?.status === 'error') {
|
}, [savedRec]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
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)
|
// AI recommendation (on-demand)
|
||||||
const handleTriggerAI = useCallback(async () => {
|
const aiMutation = useMutation({
|
||||||
setIsTriggering(true);
|
mutationFn: async () => {
|
||||||
try {
|
const { data } = await api.post('/investment-planning/recommendations', {}, { timeout: 300000 });
|
||||||
await api.post('/investment-planning/recommendations');
|
return data as AIResponse;
|
||||||
} catch (err: any) {
|
},
|
||||||
setIsTriggering(false);
|
onSuccess: (data) => {
|
||||||
notifications.show({
|
setAiResult(data);
|
||||||
message: err.response?.data?.message || 'Failed to start AI analysis',
|
setLastUpdated(new Date().toISOString());
|
||||||
color: 'red',
|
if (data.recommendations.length > 0) {
|
||||||
});
|
notifications.show({
|
||||||
}
|
message: `Generated ${data.recommendations.length} investment recommendations`,
|
||||||
}, []);
|
color: 'green',
|
||||||
|
});
|
||||||
// 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)
|
},
|
||||||
? {
|
onError: (err: any) => {
|
||||||
recommendations: savedRec!.recommendations,
|
notifications.show({
|
||||||
overall_assessment: savedRec!.overall_assessment,
|
message: err.response?.data?.message || 'Failed to get AI recommendations',
|
||||||
risk_notes: savedRec!.risk_notes,
|
color: 'red',
|
||||||
}
|
});
|
||||||
: null;
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (snapshotLoading) {
|
if (snapshotLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -698,8 +645,8 @@ export function InvestmentPlanningPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconSparkles size={16} />}
|
leftSection={<IconSparkles size={16} />}
|
||||||
onClick={handleTriggerAI}
|
onClick={() => aiMutation.mutate()}
|
||||||
loading={isProcessing}
|
loading={aiMutation.isPending}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
gradient={{ from: 'grape', to: 'violet' }}
|
gradient={{ from: 'grape', to: 'violet' }}
|
||||||
>
|
>
|
||||||
@@ -707,8 +654,8 @@ export function InvestmentPlanningPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Processing State */}
|
{/* Loading State */}
|
||||||
{isProcessing && (
|
{aiMutation.isPending && (
|
||||||
<Center py="xl">
|
<Center py="xl">
|
||||||
<Stack align="center" gap="sm">
|
<Stack align="center" gap="sm">
|
||||||
<Loader size="lg" type="dots" />
|
<Loader size="lg" type="dots" />
|
||||||
@@ -716,32 +663,19 @@ export function InvestmentPlanningPage() {
|
|||||||
Analyzing your financial data and market rates...
|
Analyzing your financial data and market rates...
|
||||||
</Text>
|
</Text>
|
||||||
<Text c="dimmed" size="xs">
|
<Text c="dimmed" size="xs">
|
||||||
You can navigate away — results will appear when ready
|
This may take a few minutes for complex tenant data
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error State (no cached data) */}
|
{/* Results */}
|
||||||
{hasError && !isProcessing && (
|
{aiResult && !aiMutation.isPending && (
|
||||||
<Alert color="red" variant="light" title="Analysis Failed" mb="md">
|
<RecommendationsDisplay aiResult={aiResult} lastUpdated={lastUpdated || undefined} />
|
||||||
<Text size="sm">
|
|
||||||
{savedRec?.error_message || 'The last AI analysis failed. Please try again.'}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Results (with optional failure watermark) */}
|
|
||||||
{aiResult && !isProcessing && (
|
|
||||||
<RecommendationsDisplay
|
|
||||||
aiResult={aiResult}
|
|
||||||
lastUpdated={savedRec?.created_at || undefined}
|
|
||||||
lastFailed={lastFailed}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty State */}
|
{/* Empty State */}
|
||||||
{!aiResult && !isProcessing && !hasError && (
|
{!aiResult && !aiMutation.isPending && (
|
||||||
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
|
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
|
||||||
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
|
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
|
||||||
<IconSparkles size={28} />
|
<IconSparkles size={28} />
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import {
|
|||||||
IconUser, IconPalette, IconClock, IconBell, IconEye,
|
IconUser, IconPalette, IconClock, IconBell, IconEye,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
|
||||||
|
|
||||||
export function UserPreferencesPage() {
|
export function UserPreferencesPage() {
|
||||||
const { user, currentOrg } = useAuthStore();
|
const { user, currentOrg } = useAuthStore();
|
||||||
const { colorScheme, toggleColorScheme } = usePreferencesStore();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -68,10 +66,7 @@ export function UserPreferencesPage() {
|
|||||||
<Text size="sm">Dark Mode</Text>
|
<Text size="sm">Dark Mode</Text>
|
||||||
<Text size="xs" c="dimmed">Switch to dark color theme</Text>
|
<Text size="xs" c="dimmed">Switch to dark color theme</Text>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch disabled />
|
||||||
checked={colorScheme === 'dark'}
|
|
||||||
onChange={toggleColorScheme}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -81,7 +76,7 @@ export function UserPreferencesPage() {
|
|||||||
<Switch disabled />
|
<Switch disabled />
|
||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>
|
<Text size="xs" c="dimmed" ta="center">Display preferences coming in a future release</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function SettingsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Version</Text>
|
<Text size="sm" c="dimmed">Version</Text>
|
||||||
<Badge variant="light">2026.3.7 (Beta)</Badge>
|
<Badge variant="light">2026.3.2 (beta)</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">API</Text>
|
<Text size="sm" c="dimmed">API</Text>
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { create } from 'zustand';
|
|
||||||
import { persist } from 'zustand/middleware';
|
|
||||||
|
|
||||||
type ColorScheme = 'light' | 'dark';
|
|
||||||
|
|
||||||
interface PreferencesState {
|
|
||||||
colorScheme: ColorScheme;
|
|
||||||
toggleColorScheme: () => void;
|
|
||||||
setColorScheme: (scheme: ColorScheme) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const usePreferencesStore = create<PreferencesState>()(
|
|
||||||
persist(
|
|
||||||
(set) => ({
|
|
||||||
colorScheme: 'light',
|
|
||||||
toggleColorScheme: () =>
|
|
||||||
set((state) => ({
|
|
||||||
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
|
|
||||||
})),
|
|
||||||
setColorScheme: (scheme) => set({ colorScheme: scheme }),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: 'ledgeriq-preferences',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -23,8 +23,21 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
# AI endpoints now return immediately (async processing in background)
|
# AI recommendation endpoint needs a longer timeout (up to 3 minutes)
|
||||||
# No special timeout needed — kept for documentation purposes
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
# Everything else -> Vite dev server (frontend)
|
# Everything else -> Vite dev server (frontend)
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -74,8 +74,20 @@ server {
|
|||||||
proxy_send_timeout 15s;
|
proxy_send_timeout 15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# AI endpoints now return immediately (async processing in background)
|
# AI endpoints — longer timeouts (LLM calls can take minutes)
|
||||||
# No special timeout overrides needed
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
# --- Frontend → React SPA served by nginx (port 3001) ---
|
# --- Frontend → React SPA served by nginx (port 3001) ---
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -40,8 +40,20 @@ server {
|
|||||||
proxy_send_timeout 15s;
|
proxy_send_timeout 15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# AI endpoints now return immediately (async processing in background)
|
# AI endpoints → longer timeouts
|
||||||
# No special timeout overrides needed
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
# --- Static frontend → built React assets ---
|
# --- Static frontend → built React assets ---
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -60,8 +60,37 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
proxy_cache_bypass $http_upgrade;
|
||||||
}
|
}
|
||||||
|
|
||||||
# AI endpoints now return immediately (async processing in background)
|
# AI recommendation endpoint needs a longer timeout (up to 3 minutes)
|
||||||
# No special timeout overrides needed
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
# Everything else -> Vite dev server (frontend)
|
# Everything else -> Vite dev server (frontend)
|
||||||
location / {
|
location / {
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# reset-password.sh — Reset a user's password in HOA LedgerIQ
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./scripts/reset-password.sh <email> <new-password>
|
|
||||||
#
|
|
||||||
# Examples:
|
|
||||||
# ./scripts/reset-password.sh admin@hoaledgeriq.com MyNewPassword123
|
|
||||||
# ./scripts/reset-password.sh admin@sunrisevalley.org SecurePass!
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ---- Defaults ----
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
DB_USER="${POSTGRES_USER:-hoafinance}"
|
|
||||||
DB_NAME="${POSTGRES_DB:-hoafinance}"
|
|
||||||
COMPOSE_CMD="docker compose"
|
|
||||||
|
|
||||||
# If running with the SSL override, detect it
|
|
||||||
if [ -f "$PROJECT_DIR/docker-compose.ssl.yml" ] && \
|
|
||||||
docker compose -f "$PROJECT_DIR/docker-compose.yml" \
|
|
||||||
-f "$PROJECT_DIR/docker-compose.ssl.yml" ps --quiet 2>/dev/null | head -1 | grep -q .; then
|
|
||||||
COMPOSE_CMD="docker compose -f $PROJECT_DIR/docker-compose.yml -f $PROJECT_DIR/docker-compose.ssl.yml"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ---- Colors ----
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
|
||||||
|
|
||||||
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
|
|
||||||
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
|
||||||
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
|
||||||
die() { err "$@"; exit 1; }
|
|
||||||
|
|
||||||
# ---- Helpers ----
|
|
||||||
|
|
||||||
ensure_containers_running() {
|
|
||||||
if ! $COMPOSE_CMD ps postgres 2>/dev/null | grep -q "running\|Up"; then
|
|
||||||
die "PostgreSQL container is not running. Start it with: docker compose up -d postgres"
|
|
||||||
fi
|
|
||||||
if ! $COMPOSE_CMD ps backend 2>/dev/null | grep -q "running\|Up"; then
|
|
||||||
die "Backend container is not running. Start it with: docker compose up -d backend"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- CLI ----
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
HOA LedgerIQ Password Reset
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
$(basename "$0") <email> <new-password>
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
$(basename "$0") admin@hoaledgeriq.com MyNewPassword123
|
|
||||||
$(basename "$0") admin@sunrisevalley.org SecurePass!
|
|
||||||
|
|
||||||
This script:
|
|
||||||
1. Verifies the user exists in the database
|
|
||||||
2. Generates a bcrypt hash using bcryptjs (same library the app uses)
|
|
||||||
3. Updates the password in the database
|
|
||||||
4. Verifies the new hash works
|
|
||||||
|
|
||||||
EOF
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Parse args
|
|
||||||
case "${1:-}" in
|
|
||||||
-h|--help|help|"") usage ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
[ $# -lt 2 ] && die "Usage: $(basename "$0") <email> <new-password>"
|
|
||||||
|
|
||||||
EMAIL="$1"
|
|
||||||
NEW_PASSWORD="$2"
|
|
||||||
|
|
||||||
# Load .env if present
|
|
||||||
if [ -f "$PROJECT_DIR/.env" ]; then
|
|
||||||
set -a
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
source "$PROJECT_DIR/.env"
|
|
||||||
set +a
|
|
||||||
DB_USER="${POSTGRES_USER:-hoafinance}"
|
|
||||||
DB_NAME="${POSTGRES_DB:-hoafinance}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure containers are running
|
|
||||||
info "Checking containers ..."
|
|
||||||
ensure_containers_running
|
|
||||||
|
|
||||||
# Verify user exists
|
|
||||||
info "Looking up user: ${EMAIL} ..."
|
|
||||||
USER_RECORD=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" \
|
|
||||||
-t -A -c "SELECT id, email, first_name, last_name, is_superadmin FROM shared.users WHERE email = '${EMAIL}';" 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -z "$USER_RECORD" ]; then
|
|
||||||
die "No user found with email: ${EMAIL}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Parse user info for display
|
|
||||||
IFS='|' read -r USER_ID USER_EMAIL FIRST_NAME LAST_NAME IS_SUPER <<< "$USER_RECORD"
|
|
||||||
info "Found user: ${FIRST_NAME} ${LAST_NAME} (${USER_EMAIL})"
|
|
||||||
if [ "$IS_SUPER" = "t" ]; then
|
|
||||||
warn "This is a superadmin account"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Generate bcrypt hash using bcryptjs inside the backend container
|
|
||||||
info "Generating bcrypt hash ..."
|
|
||||||
HASH=$($COMPOSE_CMD exec -T backend node -e "
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
bcrypt.hash(process.argv[1], 12).then(h => process.stdout.write(h));
|
|
||||||
" "$NEW_PASSWORD" 2>/dev/null)
|
|
||||||
|
|
||||||
if [ -z "$HASH" ] || [ ${#HASH} -lt 50 ]; then
|
|
||||||
die "Failed to generate bcrypt hash. Is the backend container running?"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Update the password using a heredoc to avoid shell escaping issues with $ in hashes
|
|
||||||
info "Updating password ..."
|
|
||||||
UPDATE_RESULT=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -t -A <<EOSQL
|
|
||||||
UPDATE shared.users SET password_hash = '${HASH}', updated_at = NOW() WHERE email = '${EMAIL}';
|
|
||||||
EOSQL
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ "$UPDATE_RESULT" != *"UPDATE 1"* ]]; then
|
|
||||||
die "Password update failed. Result: ${UPDATE_RESULT}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify the new hash works
|
|
||||||
info "Verifying new password ..."
|
|
||||||
VERIFY=$($COMPOSE_CMD exec -T backend node -e "
|
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
bcrypt.compare(process.argv[1], process.argv[2]).then(r => process.stdout.write(String(r)));
|
|
||||||
" "$NEW_PASSWORD" "$HASH" 2>/dev/null)
|
|
||||||
|
|
||||||
if [ "$VERIFY" != "true" ]; then
|
|
||||||
die "Verification failed — the hash does not match the password. Something went wrong."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
ok "Password reset successful!"
|
|
||||||
echo ""
|
|
||||||
info " User: ${FIRST_NAME} ${LAST_NAME} (${USER_EMAIL})"
|
|
||||||
info " Login: ${EMAIL}"
|
|
||||||
echo ""
|
|
||||||
Reference in New Issue
Block a user