10 Commits

Author SHA1 Message Date
b0282b7f8b fix: show P&L debit/credit totals on journal entries list
The previous aggregation used simple SUM(debit)/SUM(credit) which
always produced equal values for balanced entries. This was misleading
for entries with income/expense lines (e.g., monthly actuals).

Now, when an entry has income/expense lines, the totals reflect only
P&L account activity (expenses as debits, income as credits), excluding
the cash offset. For balance-sheet-only entries (opening balances,
adjustments), the full entry totals are shown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:41:26 -04:00
ac72905ecb fix: add total_debit/total_credit aggregations to journal entries list
The findAll query was missing SUM aggregations, so the frontend received
no total_debit/total_credit fields and fell back to displaying $0.00.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 09:17:08 -04:00
3bf6b8c6c9 fix: update password when adding existing user to new org
When an existing user was added to a new organization via the member
management UI, the password entered in the form was silently ignored.
This caused the user to be unable to log in with the password they
were given, since the hash in the database was from their original
account creation for a different org.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:49:23 -04:00
4759374883 feat: add dark mode with persistent user preference
Add dark mode support using Mantine's built-in color scheme system,
persisted via a new Zustand preferences store. Includes a quick toggle
in the app header and an enabled switch in User Preferences. Also
removes the "AI Health Scores" title from the dashboard to reclaim
vertical space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:36:11 -04:00
cb6e34d5ce feat: add password reset utility script
Usage: ./scripts/reset-password.sh <email> <new-password>
Generates bcrypt hash via bcryptjs in the backend container,
updates the database, and verifies the hash matches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:19:22 -05:00
2b72951e66 chore: bump version to 2026.3.7 (Beta)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
69dad7cc74 fix: resolve 5 invoice/payment issues from user feedback
- Replace misleading 'sent' status with 'pending' (no email capability)
- Show assessment group name instead of raw 'regular_assessment' type
- Add owner last name to invoice table
- Fix payment creation Internal Server Error (PostgreSQL $2 type cast)
- Add edit/delete capability for payment records with invoice recalc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
efa5aca35f feat: add flexible billing frequency support for invoices
Assessment groups can now define billing frequency (monthly, quarterly,
annual) with configurable due months and due day. Invoice generation
respects each group's schedule - only generating invoices when the
selected month is a billing month for that group. Adds a generation
preview showing which groups will be billed, period tracking on
invoices, and billing period context in the payments UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:01:57 -05:00
c429dcc033 Merge pull request 'fix: improve AI health score accuracy and consistency' (#1) from ai-improvements into main
Reviewed-on: #1
2026-03-06 14:44:39 -05:00
9146118df1 feat: async AI calls, 10-min timeout, and failure messaging
- Make all AI endpoints (health scores + investment recommendations)
  fire-and-forget: POST returns immediately, frontend polls for results
- Extend AI API timeout from 2-5 min to 10 min for both services
- Add "last analysis failed — showing cached data" message to the
  Investment Recommendations panel (matches health score widgets)
- Add status/error_message columns to ai_recommendations table
- Remove nginx AI timeout overrides (no longer needed)
- Users can now navigate away during AI processing without interruption

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 14:42:53 -05:00
24 changed files with 697 additions and 195 deletions

View File

@@ -1,12 +1,12 @@
{ {
"name": "hoa-ledgeriq-backend", "name": "hoa-ledgeriq-backend",
"version": "2026.3.2-beta", "version": "2026.3.7-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hoa-ledgeriq-backend", "name": "hoa-ledgeriq-backend",
"version": "2026.3.2-beta", "version": "2026.3.7-beta",
"dependencies": { "dependencies": {
"@nestjs/common": "^10.4.15", "@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0", "@nestjs/config": "^3.3.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoa-ledgeriq-backend", "name": "hoa-ledgeriq-backend",
"version": "2026.3.2-beta", "version": "2026.3.7-beta",
"description": "HOA LedgerIQ - Backend API", "description": "HOA LedgerIQ - Backend API",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -330,6 +330,8 @@ 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()
)`, )`,

View File

@@ -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.2') .setVersion('2026.3.7')
.addBearerAuth() .addBearerAuth()
.build(); .build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);

View File

@@ -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 { 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,6 +9,8 @@ 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')
@@ -19,32 +21,56 @@ export class HealthScoresController {
} }
@Post('calculate') @Post('calculate')
@ApiOperation({ summary: 'Trigger both health score recalculations (used by scheduler)' }) @ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
@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) => {
return { operating, reserve }; 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') @Post('calculate/operating')
@ApiOperation({ summary: 'Recalculate operating fund health score only' }) @ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
@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');
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') @Post('calculate/reserve')
@ApiOperation({ summary: 'Recalculate reserve fund health score only' }) @ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
@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');
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.',
};
} }
} }

View File

@@ -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: 120000, timeout: 600000, // 10 minute timeout
}; };
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 120s')); reject(new Error('Request timed out after 600s'));
}); });
req.write(bodyString); req.write(bodyString);

View File

@@ -36,9 +36,9 @@ export class InvestmentPlanningController {
} }
@Post('recommendations') @Post('recommendations')
@ApiOperation({ summary: 'Get AI-powered investment recommendations' }) @ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
@AllowViewer() @AllowViewer()
getRecommendations(@Req() req: any) { triggerRecommendations(@Req() req: any) {
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId); return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
} }
} }

View File

@@ -65,6 +65,9 @@ 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()
@@ -196,14 +199,33 @@ 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, created_at response_time_ms, status, error_message, created_at
FROM ai_recommendations FROM ai_recommendations
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1`, LIMIT 1`,
@@ -212,6 +234,64 @@ 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,
@@ -220,6 +300,8 @@ 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)
@@ -228,15 +310,153 @@ 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) (recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms, status)
VALUES ($1, $2, $3, $4, $5)`, VALUES ($1, $2, $3, $4, $5, 'complete')`,
[ [
JSON.stringify(aiResponse), JSON.stringify(aiResponse),
aiResponse.overall_assessment || '', 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-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'), 'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
}, },
timeout: 300000, // 5 minute timeout timeout: 600000, // 10 minute timeout
}; };
const req = https.request(options, (res) => { 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('error', (err) => reject(err));
req.on('timeout', () => { req.on('timeout', () => {
req.destroy(); req.destroy();
reject(new Error(`Request timed out after 300s`)); reject(new Error(`Request timed out after 600s`));
}); });
req.write(bodyString); req.write(bodyString);

View File

@@ -13,6 +13,16 @@ 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,

View File

@@ -153,6 +153,14 @@ 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);

View File

@@ -1,12 +1,12 @@
{ {
"name": "hoa-ledgeriq-frontend", "name": "hoa-ledgeriq-frontend",
"version": "2026.3.2-beta", "version": "2026.3.7-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hoa-ledgeriq-frontend", "name": "hoa-ledgeriq-frontend",
"version": "2026.3.2-beta", "version": "2026.3.7-beta",
"dependencies": { "dependencies": {
"@mantine/core": "^7.15.3", "@mantine/core": "^7.15.3",
"@mantine/dates": "^7.15.3", "@mantine/dates": "^7.15.3",

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoa-ledgeriq-frontend", "name": "hoa-ledgeriq-frontend",
"version": "2026.3.2-beta", "version": "2026.3.7-beta",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core'; import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button, ActionIcon, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { import {
IconLogout, IconLogout,
@@ -9,9 +9,12 @@ 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';
@@ -20,6 +23,7 @@ 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;
@@ -108,6 +112,16 @@ 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>

View File

@@ -10,6 +10,7 @@ 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: {
@@ -21,9 +22,11 @@ const queryClient = new QueryClient({
}, },
}); });
ReactDOM.createRoot(document.getElementById('root')!).render( function Root() {
<React.StrictMode> const colorScheme = usePreferencesStore((s) => s.colorScheme);
<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}>
@@ -33,5 +36,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</QueryClientProvider> </QueryClientProvider>
</ModalsProvider> </ModalsProvider>
</MantineProvider> </MantineProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Root />
</React.StrictMode>, </React.StrictMode>,
); );

View File

@@ -16,8 +16,8 @@ import {
IconRefresh, IconRefresh,
IconInfoCircle, IconInfoCircle,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useState } from 'react'; import { useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, 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 last refresh attempt failed (per score type) // Track whether a refresh is in progress (per score type) for async polling
const [operatingFailed, setOperatingFailed] = useState(false); const [operatingRefreshing, setOperatingRefreshing] = useState(false);
const [reserveFailed, setReserveFailed] = useState(false); const [reserveRefreshing, setReserveRefreshing] = useState(false);
const { data, isLoading } = useQuery<DashboardData>({ const { data, isLoading } = useQuery<DashboardData>({
queryKey: ['dashboard'], queryKey: ['dashboard'],
@@ -327,33 +327,66 @@ 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,
}); });
// Separate mutations for each score type // Async refresh handlers — trigger the backend and poll for results
const recalcOperatingMutation = useMutation({ const handleRefreshOperating = useCallback(async () => {
mutationFn: () => api.post('/health-scores/calculate/operating'), const prevId = healthScores?.operating?.id;
onSuccess: () => { setOperatingRefreshing(true);
setOperatingFailed(false); try {
queryClient.invalidateQueries({ queryKey: ['health-scores'] }); await api.post('/health-scores/calculate/operating');
}, } catch {
onError: () => { // Trigger failed at network level — polling will pick up any backend-saved error
setOperatingFailed(true); }
// Still refresh to get whatever the backend saved (could be cached data) // Start polling — watch for the health score to change (new id or updated timestamp)
queryClient.invalidateQueries({ queryKey: ['health-scores'] }); 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({ const handleRefreshReserve = useCallback(async () => {
mutationFn: () => api.post('/health-scores/calculate/reserve'), const prevId = healthScores?.reserve?.id;
onSuccess: () => { setReserveRefreshing(true);
setReserveFailed(false); try {
queryClient.invalidateQueries({ queryKey: ['health-scores'] }); await api.post('/health-scores/calculate/reserve');
}, } catch {
onError: () => { // Trigger failed at network level
setReserveFailed(true); }
queryClient.invalidateQueries({ queryKey: ['health-scores'] }); 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) => 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' });
@@ -381,7 +414,6 @@ 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}
@@ -391,9 +423,9 @@ export function DashboardPage() {
<IconHeartbeat size={20} /> <IconHeartbeat size={20} />
</ThemeIcon> </ThemeIcon>
} }
isRefreshing={recalcOperatingMutation.isPending} isRefreshing={operatingRefreshing}
onRefresh={() => recalcOperatingMutation.mutate()} onRefresh={handleRefreshOperating}
lastFailed={operatingFailed || !!healthScores?.operating_last_failed} lastFailed={!!healthScores?.operating_last_failed}
/> />
<HealthScoreCard <HealthScoreCard
score={healthScores?.reserve || null} score={healthScores?.reserve || null}
@@ -403,9 +435,9 @@ export function DashboardPage() {
<IconHeartbeat size={20} /> <IconHeartbeat size={20} />
</ThemeIcon> </ThemeIcon>
} }
isRefreshing={recalcReserveMutation.isPending} isRefreshing={reserveRefreshing}
onRefresh={() => recalcReserveMutation.mutate()} onRefresh={handleRefreshReserve}
lastFailed={reserveFailed || !!healthScores?.reserve_last_failed} lastFailed={!!healthScores?.reserve_last_failed}
/> />
</SimpleGrid> </SimpleGrid>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } 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, useMutation } from '@tanstack/react-query'; import { useQuery } 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,6 +107,9 @@ 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 ──
@@ -181,14 +184,29 @@ function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean
// ── Recommendations Display Component ── // ── Recommendations Display Component ──
function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIResponse; lastUpdated?: string }) { function RecommendationsDisplay({
aiResult,
lastUpdated,
lastFailed,
}: {
aiResult: AIResponse;
lastUpdated?: string;
lastFailed?: boolean;
}) {
return ( return (
<Stack> <Stack>
{/* Last Updated timestamp */} {/* Last Updated timestamp + failure message */}
{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 */}
@@ -327,9 +345,8 @@ function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIRespons
// ── 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>({
@@ -349,50 +366,86 @@ export function InvestmentPlanningPage() {
}, },
}); });
// Load saved recommendation on mount // Load saved recommendation — polls every 3s when processing
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;
// Populate AI results from saved recommendation on load // Poll every 3 seconds while processing
useEffect(() => { if (rec?.status === 'processing') return 3000;
if (savedRec && !aiResult) { // Also poll if we just triggered (status may not be 'processing' yet)
setAiResult({ if (isTriggering) return 3000;
recommendations: savedRec.recommendations, return false;
overall_assessment: savedRec.overall_assessment,
risk_notes: savedRec.risk_notes,
});
setLastUpdated(savedRec.created_at);
}
}, [savedRec]); // eslint-disable-line react-hooks/exhaustive-deps
// 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()); // Derive display state from saved recommendation
if (data.recommendations.length > 0) { 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(() => {
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<string | null>(null);
useEffect(() => {
const [prevStatus, setPrevStatus] = prevStatusRef;
if (prevStatus === 'processing' && savedRec?.status === 'complete') {
notifications.show({ notifications.show({
message: `Generated ${data.recommendations.length} investment recommendations`, message: `Generated ${savedRec.recommendations.length} investment recommendations`,
color: 'green', color: 'green',
}); });
} }
}, if (prevStatus === 'processing' && savedRec?.status === 'error') {
onError: (err: any) => {
notifications.show({ notifications.show({
message: err.response?.data?.message || 'Failed to get AI recommendations', message: savedRec.error_message || 'AI recommendation analysis failed',
color: 'red', 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) { if (snapshotLoading) {
return ( return (
@@ -645,8 +698,8 @@ export function InvestmentPlanningPage() {
</Group> </Group>
<Button <Button
leftSection={<IconSparkles size={16} />} leftSection={<IconSparkles size={16} />}
onClick={() => aiMutation.mutate()} onClick={handleTriggerAI}
loading={aiMutation.isPending} loading={isProcessing}
variant="gradient" variant="gradient"
gradient={{ from: 'grape', to: 'violet' }} gradient={{ from: 'grape', to: 'violet' }}
> >
@@ -654,8 +707,8 @@ export function InvestmentPlanningPage() {
</Button> </Button>
</Group> </Group>
{/* Loading State */} {/* Processing State */}
{aiMutation.isPending && ( {isProcessing && (
<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" />
@@ -663,19 +716,32 @@ 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">
This may take a few minutes for complex tenant data You can navigate away results will appear when ready
</Text> </Text>
</Stack> </Stack>
</Center> </Center>
)} )}
{/* Results */} {/* Error State (no cached data) */}
{aiResult && !aiMutation.isPending && ( {hasError && !isProcessing && (
<RecommendationsDisplay aiResult={aiResult} lastUpdated={lastUpdated || undefined} /> <Alert color="red" variant="light" title="Analysis Failed" mb="md">
<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 && !aiMutation.isPending && ( {!aiResult && !isProcessing && !hasError && (
<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} />

View File

@@ -6,9 +6,11 @@ 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>
@@ -66,7 +68,10 @@ 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 disabled /> <Switch
checked={colorScheme === 'dark'}
onChange={toggleColorScheme}
/>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<div> <div>
@@ -76,7 +81,7 @@ export function UserPreferencesPage() {
<Switch disabled /> <Switch disabled />
</Group> </Group>
<Divider /> <Divider />
<Text size="xs" c="dimmed" ta="center">Display preferences coming in a future release</Text> <Text size="xs" c="dimmed" ta="center">More display preferences coming in a future release</Text>
</Stack> </Stack>
</Card> </Card>

View File

@@ -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.2 (beta)</Badge> <Badge variant="light">2026.3.7 (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>

View File

@@ -0,0 +1,26 @@
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',
},
),
);

View File

@@ -23,21 +23,8 @@ server {
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
} }
# AI recommendation endpoint needs a longer timeout (up to 3 minutes) # AI endpoints now return immediately (async processing in background)
location /api/investment-planning/recommendations { # No special timeout needed — kept for documentation purposes
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 / {

View File

@@ -74,20 +74,8 @@ server {
proxy_send_timeout 15s; proxy_send_timeout 15s;
} }
# AI endpoints — longer timeouts (LLM calls can take minutes) # AI endpoints now return immediately (async processing in background)
location /api/investment-planning/recommendations { # No special timeout overrides needed
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 / {

View File

@@ -40,20 +40,8 @@ server {
proxy_send_timeout 15s; proxy_send_timeout 15s;
} }
# AI endpoints → longer timeouts # AI endpoints now return immediately (async processing in background)
location /api/investment-planning/recommendations { # No special timeout overrides needed
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 / {

View File

@@ -60,37 +60,8 @@ server {
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
} }
# AI recommendation endpoint needs a longer timeout (up to 3 minutes) # AI endpoints now return immediately (async processing in background)
location /api/investment-planning/recommendations { # No special timeout overrides needed
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 / {

150
scripts/reset-password.sh Executable file
View File

@@ -0,0 +1,150 @@
#!/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 ""