2 Commits

Author SHA1 Message Date
2b83defbc3 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 11:53:54 -05:00
1e31595d7f 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-06 19:08:56 -05:00
37 changed files with 298 additions and 966 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "hoa-ledgeriq-backend",
"version": "2026.03.10",
"version": "2026.3.2-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hoa-ledgeriq-backend",
"version": "2026.03.10",
"version": "2026.3.2-beta",
"dependencies": {
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
@@ -16,12 +16,10 @@
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^10.0.2",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^8.1.0",
"ioredis": "^5.4.2",
"newrelic": "latest",
"passport": "^0.7.0",
@@ -1793,17 +1791,6 @@
}
}
},
"node_modules/@nestjs/throttler": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
"license": "MIT",
"peerDependencies": {
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
}
},
"node_modules/@nestjs/typeorm": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz",
@@ -5290,15 +5277,6 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-backend",
"version": "2026.3.11",
"version": "2026.3.2-beta",
"description": "HOA LedgerIQ - Backend API",
"private": true,
"scripts": {
@@ -25,14 +25,11 @@
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^10.0.2",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^8.1.0",
"ioredis": "^5.4.2",
"newrelic": "latest",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
@@ -40,6 +37,7 @@
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20",
"newrelic": "latest",
"uuid": "^9.0.1"
},
"devDependencies": {

View File

@@ -2,7 +2,6 @@ import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware';
@@ -53,10 +52,6 @@ import { ScheduleModule } from '@nestjs/schedule';
},
}),
}),
ThrottlerModule.forRoot([{
ttl: 60000, // 1-minute window
limit: 100, // 100 requests per minute (global default)
}]),
DatabaseModule,
AuthModule,
OrganizationsModule,

View File

@@ -330,8 +330,6 @@ export class TenantSchemaService {
risk_notes JSONB,
requested_by UUID,
response_time_ms INTEGER,
status VARCHAR(20) DEFAULT 'complete',
error_message TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)`,

View File

@@ -13,8 +13,8 @@ export interface TenantRequest extends Request {
@Injectable()
export class TenantMiddleware implements NestMiddleware {
// In-memory cache for org info to avoid DB hit per request
private orgCache = new Map<string, { status: string; schemaName: string; cachedAt: number }>();
// In-memory cache for org status to avoid DB hit per request
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>();
private static readonly CACHE_TTL = 60_000; // 60 seconds
constructor(
@@ -30,25 +30,23 @@ export class TenantMiddleware implements NestMiddleware {
const token = authHeader.substring(7);
const secret = this.configService.get<string>('JWT_SECRET');
const decoded = jwt.verify(token, secret!) as any;
if (decoded?.orgId) {
// Look up org info (status + schema) from orgId with caching
const orgInfo = await this.getOrgInfo(decoded.orgId);
if (orgInfo) {
if (['suspended', 'archived'].includes(orgInfo.status)) {
if (decoded?.orgSchema) {
// Check if the org is still active (catches post-JWT suspension)
if (decoded.orgId) {
const status = await this.getOrgStatus(decoded.orgId);
if (status && ['suspended', 'archived'].includes(status)) {
res.status(403).json({
statusCode: 403,
message: `This organization has been ${orgInfo.status}. Please contact your administrator.`,
message: `This organization has been ${status}. Please contact your administrator.`,
});
return;
}
req.tenantSchema = orgInfo.schemaName;
}
req.tenantSchema = decoded.orgSchema;
req.orgId = decoded.orgId;
req.userId = decoded.sub;
req.userRole = decoded.role;
} else if (decoded?.sub) {
// Superadmin or user without org — still set userId
req.userId = decoded.sub;
}
} catch {
// Token invalid or expired - let Passport handle the auth error
@@ -57,23 +55,19 @@ export class TenantMiddleware implements NestMiddleware {
next();
}
private async getOrgInfo(orgId: string): Promise<{ status: string; schemaName: string } | null> {
const cached = this.orgCache.get(orgId);
private async getOrgStatus(orgId: string): Promise<string | null> {
const cached = this.orgStatusCache.get(orgId);
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
return { status: cached.status, schemaName: cached.schemaName };
return cached.status;
}
try {
const result = await this.dataSource.query(
`SELECT status, schema_name as "schemaName" FROM shared.organizations WHERE id = $1`,
`SELECT status FROM shared.organizations WHERE id = $1`,
[orgId],
);
if (result.length > 0) {
this.orgCache.set(orgId, {
status: result[0].status,
schemaName: result[0].schemaName,
cachedAt: Date.now(),
});
return { status: result[0].status, schemaName: result[0].schemaName };
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() });
return result[0].status;
}
} catch {
// Non-critical — don't block requests on cache miss errors

View File

@@ -3,7 +3,6 @@ import * as os from 'node:os';
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module';
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
@@ -42,24 +41,6 @@ async function bootstrap() {
app.setGlobalPrefix('api');
// Security headers — Helmet sets CSP, X-Frame-Options, X-Content-Type-Options,
// Referrer-Policy, Permissions-Policy, and removes X-Powered-By
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://chat.hoaledgeriq.com'],
connectSrc: ["'self'", 'https://chat.hoaledgeriq.com', 'wss://chat.hoaledgeriq.com'],
imgSrc: ["'self'", 'data:', 'https://chat.hoaledgeriq.com'],
styleSrc: ["'self'", "'unsafe-inline'"],
frameSrc: ["'self'", 'https://chat.hoaledgeriq.com'],
fontSrc: ["'self'", 'data:'],
},
},
}),
);
// Request logging — only in development (too noisy / slow for prod)
if (!isProduction) {
app.use((req: any, _res: any, next: any) => {
@@ -82,17 +63,15 @@ async function bootstrap() {
credentials: true,
});
// Swagger docs — disabled in production to avoid exposing API surface
if (!isProduction) {
const config = new DocumentBuilder()
.setTitle('HOA LedgerIQ API')
.setDescription('API for the HOA LedgerIQ')
.setVersion('2026.3.11')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
}
// Swagger docs — available in all environments
const config = new DocumentBuilder()
.setTitle('HOA LedgerIQ API')
.setDescription('API for the HOA LedgerIQ')
.setVersion('2026.3.2')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
await app.listen(3000);
console.log(`Backend worker ${process.pid} listening on port 3000`);

View File

@@ -9,7 +9,6 @@ import {
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
@@ -24,14 +23,12 @@ export class AuthController {
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
async register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
@ApiOperation({ summary: 'Login with email and password' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
@UseGuards(AuthGuard('local'))
async login(@Request() req: any, @Body() _dto: LoginDto) {
const ip = req.headers['x-forwarded-for'] || req.ip;

View File

@@ -118,6 +118,7 @@ export class AuthService {
sub: user.id,
email: user.email,
orgId: membership.organizationId,
orgSchema: membership.organization.schemaName,
role: membership.role,
};
@@ -176,6 +177,7 @@ export class AuthService {
if (defaultOrg) {
payload.orgId = defaultOrg.organizationId;
payload.orgSchema = defaultOrg.organization?.schemaName;
payload.role = defaultOrg.role;
}
@@ -193,6 +195,7 @@ export class AuthService {
organizations: orgs.map((uo) => ({
id: uo.organizationId,
name: uo.organization?.name,
schemaName: uo.organization?.schemaName,
status: uo.organization?.status,
role: uo.role,
})),

View File

@@ -18,6 +18,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
sub: payload.sub,
email: payload.email,
orgId: payload.orgId,
orgSchema: payload.orgSchema,
role: payload.role,
isSuperadmin: payload.isSuperadmin || false,
impersonatedBy: payload.impersonatedBy || null,

View File

@@ -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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
@@ -9,68 +9,42 @@ import { HealthScoresService } from './health-scores.service';
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class HealthScoresController {
private readonly logger = new Logger(HealthScoresController.name);
constructor(private service: HealthScoresService) {}
@Get('latest')
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
getLatest(@Req() req: any) {
const schema = req.tenantSchema;
const schema = req.user?.orgSchema;
return this.service.getLatestScores(schema);
}
@Post('calculate')
@ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
@ApiOperation({ summary: 'Trigger both health score recalculations (used by scheduler)' })
@AllowViewer()
async calculate(@Req() req: any) {
const schema = req.tenantSchema;
// Fire-and-forget — background processing saves results to DB
Promise.all([
const schema = req.user?.orgSchema;
const [operating, reserve] = await Promise.all([
this.service.calculateScore(schema, 'operating'),
this.service.calculateScore(schema, 'reserve'),
]).catch((err) => {
this.logger.error(`Background health score calculation failed: ${err.message}`);
});
return {
status: 'processing',
message: 'Health score calculations started. Results will appear when ready.',
};
]);
return { operating, reserve };
}
@Post('calculate/operating')
@ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
@ApiOperation({ summary: 'Recalculate operating fund health score only' })
@AllowViewer()
async calculateOperating(@Req() req: any) {
const schema = req.tenantSchema;
// 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.',
};
const schema = req.user?.orgSchema;
const operating = await this.service.calculateScore(schema, 'operating');
return { operating };
}
@Post('calculate/reserve')
@ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
@ApiOperation({ summary: 'Recalculate reserve fund health score only' })
@AllowViewer()
async calculateReserve(@Req() req: any) {
const schema = req.tenantSchema;
// 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.',
};
const schema = req.user?.orgSchema;
const reserve = await this.service.calculateScore(schema, 'reserve');
return { reserve };
}
}

View File

@@ -220,14 +220,6 @@ export class HealthScoresService {
missing.push(`No budget found for ${year}. Upload or create an annual budget.`);
}
// Should have reserve components (warn but don't block)
const components = await qr.query(
`SELECT COUNT(*) as cnt FROM reserve_components`,
);
if (parseInt(components[0].cnt) === 0) {
missing.push('No reserve components found. Add reserve components (roof, parking, pool, etc.) with replacement costs for an accurate funded-ratio calculation.');
}
// Should have capital projects (warn but don't block)
const projects = await qr.query(
`SELECT COUNT(*) as cnt FROM projects WHERE is_active = true`,
@@ -566,12 +558,10 @@ export class HealthScoresService {
FROM reserve_components
ORDER BY remaining_life_years ASC NULLS LAST
`),
// Capital projects (include component-level fields for funded ratio when reserve_components is empty)
// Capital projects
qr.query(`
SELECT name, estimated_cost, actual_cost, target_year, target_month, fund_source,
status, priority, current_fund_balance, funded_percentage,
category, useful_life_years, remaining_life_years, condition_rating,
annual_contribution
SELECT name, estimated_cost, target_year, target_month, fund_source,
status, priority, current_fund_balance, funded_percentage
FROM projects
WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress')
ORDER BY target_year, target_month NULLS LAST
@@ -606,19 +596,11 @@ export class HealthScoresService {
const totalReserveFund = reserveCash + totalInvestments;
// Use reserve_components for funded ratio when available; fall back to
// reserve-funded projects (which carry the same estimated_cost / lifecycle
// fields that users actually populate on the Projects page).
const reserveProjects = projects.filter((p: any) => p.fund_source === 'reserve');
const useComponentsTable = reserveComponents.length > 0;
const totalReplacementCost = reserveComponents
.reduce((s: number, c: any) => s + parseFloat(c.replacement_cost || '0'), 0);
const totalReplacementCost = useComponentsTable
? reserveComponents.reduce((s: number, c: any) => s + parseFloat(c.replacement_cost || '0'), 0)
: reserveProjects.reduce((s: number, p: any) => s + parseFloat(p.estimated_cost || '0'), 0);
const totalComponentFunded = useComponentsTable
? reserveComponents.reduce((s: number, c: any) => s + parseFloat(c.current_fund_balance || '0'), 0)
: reserveProjects.reduce((s: number, p: any) => s + parseFloat(p.current_fund_balance || '0'), 0);
const totalComponentFunded = reserveComponents
.reduce((s: number, c: any) => s + parseFloat(c.current_fund_balance || '0'), 0);
const percentFunded = totalReplacementCost > 0 ? (totalReserveFund / totalReplacementCost) * 100 : 0;
@@ -633,14 +615,10 @@ export class HealthScoresService {
.filter((b: any) => b.account_type === 'expense')
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
// Components needing replacement within 5 years — use whichever source has data
const urgentComponents = useComponentsTable
? reserveComponents.filter(
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
)
: reserveProjects.filter(
(p: any) => p.remaining_life_years !== null && parseFloat(p.remaining_life_years) <= 5,
);
// Components needing replacement within 5 years
const urgentComponents = reserveComponents.filter(
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
);
// ── Build 12-month forward reserve cash flow projection ──
@@ -771,7 +749,6 @@ export class HealthScoresService {
accounts,
investments,
reserveComponents,
reserveProjects,
projects,
budgets,
assessments,
@@ -982,15 +959,13 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
`- ${i.name} | ${i.investment_type} @ ${i.institution} | $${parseFloat(i.current_value || i.principal || '0').toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`,
).join('\n');
// Build component lines from reserve_components if available, otherwise from reserve-funded projects
const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects;
const componentLines = componentSource.length === 0
? 'No reserve components or reserve projects tracked.'
: componentSource.map((c: any) => {
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
const componentLines = data.reserveComponents.length === 0
? 'No reserve components tracked.'
: data.reserveComponents.map((c: any) => {
const cost = parseFloat(c.replacement_cost || '0');
const funded = parseFloat(c.current_fund_balance || '0');
const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0';
return `- ${c.name} [${c.category || 'N/A'}] | Life: ${c.useful_life_years || '?'}yr, Remaining: ${c.remaining_life_years || '?'}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`;
return `- ${c.name} [${c.category}] | Life: ${c.useful_life_years}yr, Remaining: ${c.remaining_life_years}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`;
}).join('\n');
const projectLines = data.projects.length === 0
@@ -1006,7 +981,7 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
const urgentLines = data.urgentComponents.length === 0
? 'None — no components due within 5 years.'
: data.urgentComponents.map((c: any) => {
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
const cost = parseFloat(c.replacement_cost || '0');
const funded = parseFloat(c.current_fund_balance || '0');
const gap = cost - funded;
return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`;
@@ -1022,8 +997,8 @@ Reserve Cash (bank accounts): $${data.reserveCash.toFixed(2)}
Reserve Investments: $${data.totalInvestments.toFixed(2)}
Total Reserve Fund: $${data.totalReserveFund.toFixed(2)}
Total Replacement Cost (all components): ${data.totalReplacementCost > 0 ? '$' + data.totalReplacementCost.toFixed(2) : '$0.00 (no reserve components entered — funded ratio cannot be calculated)'}
Percent Funded: ${data.totalReplacementCost > 0 ? data.percentFunded.toFixed(1) + '%' : 'N/A — no reserve components with replacement costs have been entered. Do NOT report a 0% funded ratio; instead note that funded ratio is unavailable due to missing component data.'}
Total Replacement Cost (all components): $${data.totalReplacementCost.toFixed(2)}
Percent Funded: ${data.percentFunded.toFixed(1)}%
Annual Reserve Contribution (budgeted income): $${data.annualReserveContribution.toFixed(2)}
Annual Reserve Expenses (budgeted): $${data.annualReserveExpenses.toFixed(2)}
@@ -1140,7 +1115,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
},
timeout: 600000, // 10 minute timeout
timeout: 120000,
};
const req = https.request(options, (res) => {
@@ -1154,7 +1129,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out after 600s'));
reject(new Error('Request timed out after 120s'));
});
req.write(bodyString);

View File

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

View File

@@ -65,9 +65,6 @@ export interface SavedRecommendation {
risk_notes: string[];
response_time_ms: number;
created_at: string;
status: 'processing' | 'complete' | 'error';
last_failed: boolean;
error_message?: string;
}
@Injectable()
@@ -199,33 +196,14 @@ export class InvestmentPlanningService {
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.
* Returns status and last_failed flag for UI state management.
*/
async getSavedRecommendation(): Promise<SavedRecommendation | null> {
try {
await this.ensureStatusColumn();
const rows = await this.tenant.query(
`SELECT id, recommendations_json, overall_assessment, risk_notes,
response_time_ms, status, error_message, created_at
response_time_ms, created_at
FROM ai_recommendations
ORDER BY created_at DESC
LIMIT 1`,
@@ -234,64 +212,6 @@ export class InvestmentPlanningService {
if (!rows || rows.length === 0) return null;
const row = rows[0];
const status = row.status || 'complete';
// If still processing, return processing status
if (status === 'processing') {
return {
id: row.id,
recommendations: [],
overall_assessment: '',
risk_notes: [],
response_time_ms: 0,
created_at: row.created_at,
status: 'processing',
last_failed: false,
};
}
// If latest attempt failed, return the last successful result with last_failed flag
if (status === 'error') {
const lastGood = await this.tenant.query(
`SELECT id, recommendations_json, overall_assessment, risk_notes,
response_time_ms, created_at
FROM ai_recommendations
WHERE status = 'complete'
ORDER BY created_at DESC
LIMIT 1`,
);
if (lastGood?.length) {
const goodRow = lastGood[0];
const recData = goodRow.recommendations_json || {};
return {
id: goodRow.id,
recommendations: recData.recommendations || [],
overall_assessment: goodRow.overall_assessment || recData.overall_assessment || '',
risk_notes: goodRow.risk_notes || recData.risk_notes || [],
response_time_ms: goodRow.response_time_ms || 0,
created_at: goodRow.created_at,
status: 'complete',
last_failed: true,
error_message: row.error_message,
};
}
// No previous good result — return error state
return {
id: row.id,
recommendations: [],
overall_assessment: row.error_message || 'AI analysis failed. Please try again.',
risk_notes: [],
response_time_ms: 0,
created_at: row.created_at,
status: 'error',
last_failed: true,
error_message: row.error_message,
};
}
// Complete — return the data normally
const recData = row.recommendations_json || {};
return {
id: row.id,
@@ -300,8 +220,6 @@ export class InvestmentPlanningService {
risk_notes: row.risk_notes || recData.risk_notes || [],
response_time_ms: row.response_time_ms || 0,
created_at: row.created_at,
status: 'complete',
last_failed: false,
};
} catch (err: any) {
// Table might not exist yet (pre-migration tenants)
@@ -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.
* @deprecated Use triggerAIRecommendations() for async flow instead
*/
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
try {
await this.ensureStatusColumn();
await this.tenant.query(
`INSERT INTO ai_recommendations
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms, status)
VALUES ($1, $2, $3, $4, $5, 'complete')`,
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms)
VALUES ($1, $2, $3, $4, $5)`,
[
JSON.stringify(aiResponse),
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-Length': Buffer.byteLength(bodyString, 'utf-8'),
},
timeout: 600000, // 10 minute timeout
timeout: 300000, // 5 minute timeout
};
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('timeout', () => {
req.destroy();
reject(new Error(`Request timed out after 600s`));
reject(new Error(`Request timed out after 300s`));
});
req.write(bodyString);

View File

@@ -13,16 +13,6 @@ export class JournalEntriesService {
async findAll(filters: { from?: string; to?: string; accountId?: string; type?: string }) {
let sql = `
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(
'id', jel.id, 'account_id', jel.account_id,
'debit', jel.debit, 'credit', jel.credit, 'memo', jel.memo,

View File

@@ -153,14 +153,6 @@ export class OrganizationsService {
existing.role = data.role;
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 {
// Create new user
const passwordHash = await bcrypt.hash(data.password, 12);

View File

@@ -9,34 +9,5 @@
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
(function(d,t) {
var BASE_URL="https://chat.hoaledgeriq.com";
var g=d.createElement(t),s=d.getElementsByTagName(t)[0];
g.src=BASE_URL+"/packs/js/sdk.js";
g.async=true;
s.parentNode.insertBefore(g,s);
g.onload=function(){
window.chatwootSDK.run({
websiteToken:'K6VXvTtKXvaCMvre4yK85SPb',
baseUrl:BASE_URL
})
}
})(document,"script");
window.addEventListener('chatwoot:ready', function() {
try {
var raw = localStorage.getItem('ledgeriq-auth');
if (!raw) return;
var auth = JSON.parse(raw);
var user = auth && auth.state && auth.state.user;
if (user && window.$chatwoot) {
window.$chatwoot.setUser(user.id, {
name: (user.firstName || '') + ' ' + (user.lastName || ''),
email: user.email
});
}
} catch (e) {}
});
</script>
</body>
</html>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
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 {
IconLogout,
@@ -9,12 +9,9 @@ import {
IconUserCog,
IconUsersGroup,
IconEyeOff,
IconSun,
IconMoon,
} from '@tabler/icons-react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { Sidebar } from './Sidebar';
import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
@@ -23,7 +20,6 @@ import logoSrc from '../../assets/logo.svg';
export function AppLayout() {
const [opened, { toggle, close }] = useDisclosure();
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
const navigate = useNavigate();
const location = useLocation();
const isImpersonating = !!impersonationOriginal;
@@ -112,16 +108,6 @@ export function AppLayout() {
{currentOrg && (
<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.Target>
<UnstyledButton>

View File

@@ -10,7 +10,6 @@ import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import { App } from './App';
import { theme } from './theme/theme';
import { usePreferencesStore } from './stores/preferencesStore';
const queryClient = new QueryClient({
defaultOptions: {
@@ -22,11 +21,9 @@ const queryClient = new QueryClient({
},
});
function Root() {
const colorScheme = usePreferencesStore((s) => s.colorScheme);
return (
<MantineProvider theme={theme} forceColorScheme={colorScheme}>
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<MantineProvider theme={theme}>
<Notifications position="top-right" />
<ModalsProvider>
<QueryClientProvider client={queryClient}>
@@ -36,11 +33,5 @@ function Root() {
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
);
}
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Root />
</React.StrictMode>,
);

View File

@@ -120,6 +120,11 @@ export function SelectOrgPage() {
<Text fw={500}>{org.name}</Text>
<Group gap={4}>
<Badge size="sm" variant="light">{org.role}</Badge>
{org.schemaName && (
<Badge size="xs" variant="dot" color="gray">
{org.schemaName}
</Badge>
)}
</Group>
</div>
</Group>

View File

@@ -4,11 +4,10 @@ import {
Select, Loader, Center, Badge, Card, Alert,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle, IconPencil, IconX } from '@tabler/icons-react';
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetLine {
account_id: string;
@@ -96,20 +95,9 @@ function parseCSV(text: string): Record<string, string>[] {
export function BudgetsPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
const [isEditing, setIsEditing] = useState(false);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = useIsReadOnly();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
// Budget exists when there is data loaded for the selected year
const hasBudget = budgetData.length > 0;
// Cells are editable only when editing an existing budget or creating a new one (no data yet)
const cellsEditable = !isReadOnly && (isEditing || !hasBudget);
const { isLoading } = useQuery<BudgetLine[]>({
queryKey: ['budgets', year],
@@ -118,27 +106,25 @@ export function BudgetsPage() {
// Hydrate each line: ensure numbers and compute annual_total
const hydrated = (data as any[]).map(hydrateBudgetLine);
setBudgetData(hydrated);
setIsEditing(false); // Reset to view mode when year changes or data reloads
return hydrated;
},
});
const saveMutation = useMutation({
mutationFn: async () => {
const payload = budgetData
const lines = budgetData
.filter((b) => months.some((m) => (b as any)[m] > 0))
.map((b) => ({
accountId: b.account_id,
fundType: b.fund_type,
account_id: b.account_id,
fund_type: b.fund_type,
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
sep: b.sep, oct: b.oct, nov: b.nov, dec: b.dec_amt,
sep: b.sep, oct: b.oct, nov: b.nov, dec_amt: b.dec_amt,
}));
return api.put(`/budgets/${year}`, payload);
return api.put(`/budgets/${year}`, { lines });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
setIsEditing(false);
notifications.show({ message: 'Budget saved', color: 'green' });
},
onError: (err: any) => {
@@ -235,12 +221,6 @@ export function BudgetsPage() {
event.target.value = '';
};
const handleCancelEdit = () => {
setIsEditing(false);
// Re-fetch to discard unsaved changes
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
};
const updateCell = (idx: number, month: string, value: number) => {
const updated = [...budgetData];
(updated[idx] as any)[month] = value || 0;
@@ -295,35 +275,9 @@ export function BudgetsPage() {
accept=".csv,.txt"
onChange={handleFileChange}
/>
{hasBudget && !isEditing ? (
<Button
variant="outline"
leftSection={<IconPencil size={16} />}
onClick={() => setIsEditing(true)}
>
Edit Budget
</Button>
) : (
<>
{isEditing && (
<Button
variant="outline"
color="gray"
leftSection={<IconX size={16} />}
onClick={handleCancelEdit}
>
Cancel
</Button>
)}
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
>
Save Budget
</Button>
</>
)}
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
Save Budget
</Button>
</>)}
</Group>
</Group>
@@ -363,8 +317,8 @@ export function BudgetsPage() {
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
{monthLabels.map((m) => (
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
))}
@@ -383,7 +337,7 @@ export function BudgetsPage() {
const lines = budgetData.filter((b) => b.account_type === type);
if (lines.length === 0) return null;
const sectionBg = type === 'income' ? incomeSectionBg : expenseSectionBg;
const sectionBg = type === 'income' ? '#e6f9e6' : '#fde8e8';
const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
return [
@@ -414,9 +368,9 @@ export function BudgetsPage() {
style={{
position: 'sticky',
left: 0,
background: stickyBg,
background: 'white',
zIndex: 1,
borderRight: `1px solid ${stickyBorder}`,
borderRight: '1px solid #e9ecef',
}}
>
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
@@ -425,9 +379,9 @@ export function BudgetsPage() {
style={{
position: 'sticky',
left: 120,
background: stickyBg,
background: 'white',
zIndex: 1,
borderRight: `1px solid ${stickyBorder}`,
borderRight: '1px solid #e9ecef',
}}
>
<Group gap={6} wrap="nowrap">
@@ -437,21 +391,16 @@ export function BudgetsPage() {
</Table.Td>
{months.map((m) => (
<Table.Td key={m} p={2}>
{cellsEditable ? (
<NumberInput
value={(line as any)[m] || 0}
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
size="xs"
hideControls
decimalScale={2}
min={0}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
) : (
<Text size="sm" ta="right" ff="monospace">
{fmt((line as any)[m] || 0)}
</Text>
)}
<NumberInput
value={(line as any)[m] || 0}
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
size="xs"
hideControls
decimalScale={2}
min={0}
disabled={isReadOnly}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
</Table.Td>
))}
<Table.Td ta="right" fw={500} ff="monospace">

View File

@@ -8,7 +8,6 @@ import {
IconArrowLeft, IconArrowRight, IconCalendar,
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { usePreferencesStore } from '../../stores/preferencesStore';
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid,
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
@@ -80,7 +79,6 @@ export function CashFlowForecastPage() {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1;
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
// Filter: All, Operating, Reserve
const [fundFilter, setFundFilter] = useState<string>('all');
@@ -420,10 +418,10 @@ export function CashFlowForecastPage() {
<tr
key={d.month}
style={{
borderBottom: `1px solid ${isDark ? 'var(--mantine-color-dark-4)' : 'var(--mantine-color-gray-2)'}`,
borderBottom: '1px solid var(--mantine-color-gray-2)',
backgroundColor: d.is_forecast
? (isDark ? 'var(--mantine-color-orange-9)' : 'var(--mantine-color-orange-0)')
: i % 2 === 0 ? 'transparent' : (isDark ? 'var(--mantine-color-dark-5)' : 'var(--mantine-color-gray-0)'),
? 'var(--mantine-color-orange-0)'
: i % 2 === 0 ? 'transparent' : 'var(--mantine-color-gray-0)',
}}
>
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>

View File

@@ -16,8 +16,8 @@ import {
IconRefresh,
IconInfoCircle,
} from '@tabler/icons-react';
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore';
import api from '../../services/api';
@@ -313,9 +313,9 @@ export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg);
const queryClient = useQueryClient();
// Track whether a refresh is in progress (per score type) for async polling
const [operatingRefreshing, setOperatingRefreshing] = useState(false);
const [reserveRefreshing, setReserveRefreshing] = useState(false);
// Track whether last refresh attempt failed (per score type)
const [operatingFailed, setOperatingFailed] = useState(false);
const [reserveFailed, setReserveFailed] = useState(false);
const { data, isLoading } = useQuery<DashboardData>({
queryKey: ['dashboard'],
@@ -327,66 +327,33 @@ export function DashboardPage() {
queryKey: ['health-scores'],
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
enabled: !!currentOrg,
// Poll every 3 seconds while a refresh is in progress
refetchInterval: (operatingRefreshing || reserveRefreshing) ? 3000 : false,
});
// Async refresh handlers — trigger the backend and poll for results
const handleRefreshOperating = useCallback(async () => {
const prevId = healthScores?.operating?.id;
setOperatingRefreshing(true);
try {
await api.post('/health-scores/calculate/operating');
} catch {
// Trigger failed at network level — polling will pick up any backend-saved error
}
// Start polling — watch for the health score to change (new id or updated timestamp)
const pollUntilDone = () => {
const checkInterval = setInterval(async () => {
try {
const { data: latest } = await api.get('/health-scores/latest');
const newScore = latest?.operating;
if (newScore && newScore.id !== prevId) {
setOperatingRefreshing(false);
queryClient.setQueryData(['health-scores'], latest);
clearInterval(checkInterval);
}
} catch {
// Keep polling
}
}, 3000);
// Safety timeout — stop polling after 11 minutes
setTimeout(() => { clearInterval(checkInterval); setOperatingRefreshing(false); }, 660000);
};
pollUntilDone();
}, [healthScores?.operating?.id, queryClient]);
// Separate mutations for each score type
const recalcOperatingMutation = useMutation({
mutationFn: () => api.post('/health-scores/calculate/operating'),
onSuccess: () => {
setOperatingFailed(false);
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
},
onError: () => {
setOperatingFailed(true);
// Still refresh to get whatever the backend saved (could be cached data)
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
},
});
const handleRefreshReserve = useCallback(async () => {
const prevId = healthScores?.reserve?.id;
setReserveRefreshing(true);
try {
await api.post('/health-scores/calculate/reserve');
} catch {
// Trigger failed at network level
}
const pollUntilDone = () => {
const checkInterval = setInterval(async () => {
try {
const { data: latest } = await api.get('/health-scores/latest');
const newScore = latest?.reserve;
if (newScore && newScore.id !== prevId) {
setReserveRefreshing(false);
queryClient.setQueryData(['health-scores'], latest);
clearInterval(checkInterval);
}
} catch {
// Keep polling
}
}, 3000);
setTimeout(() => { clearInterval(checkInterval); setReserveRefreshing(false); }, 660000);
};
pollUntilDone();
}, [healthScores?.reserve?.id, queryClient]);
const recalcReserveMutation = useMutation({
mutationFn: () => api.post('/health-scores/calculate/reserve'),
onSuccess: () => {
setReserveFailed(false);
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
},
onError: () => {
setReserveFailed(true);
queryClient.invalidateQueries({ queryKey: ['health-scores'] });
},
});
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
@@ -414,6 +381,7 @@ export function DashboardPage() {
<Center h={200}><Loader /></Center>
) : (
<>
<Text size="sm" fw={600} c="dimmed">AI Health Scores</Text>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<HealthScoreCard
score={healthScores?.operating || null}
@@ -423,9 +391,9 @@ export function DashboardPage() {
<IconHeartbeat size={20} />
</ThemeIcon>
}
isRefreshing={operatingRefreshing}
onRefresh={handleRefreshOperating}
lastFailed={!!healthScores?.operating_last_failed}
isRefreshing={recalcOperatingMutation.isPending}
onRefresh={() => recalcOperatingMutation.mutate()}
lastFailed={operatingFailed || !!healthScores?.operating_last_failed}
/>
<HealthScoreCard
score={healthScores?.reserve || null}
@@ -435,9 +403,9 @@ export function DashboardPage() {
<IconHeartbeat size={20} />
</ThemeIcon>
}
isRefreshing={reserveRefreshing}
onRefresh={handleRefreshReserve}
lastFailed={!!healthScores?.reserve_last_failed}
isRefreshing={recalcReserveMutation.isPending}
onRefresh={() => recalcReserveMutation.mutate()}
lastFailed={reserveFailed || !!healthScores?.reserve_last_failed}
/>
</SimpleGrid>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect } from 'react';
import {
Title,
Text,
@@ -33,7 +33,7 @@ import {
IconChevronDown,
IconChevronUp,
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import api from '../../services/api';
@@ -107,9 +107,6 @@ interface SavedRecommendation {
risk_notes: string[];
response_time_ms: number;
created_at: string;
status: 'processing' | 'complete' | 'error';
last_failed: boolean;
error_message?: string;
}
// ── Helpers ──
@@ -184,29 +181,14 @@ function RateTable({ rates, showTerm }: { rates: MarketRate[]; showTerm: boolean
// ── Recommendations Display Component ──
function RecommendationsDisplay({
aiResult,
lastUpdated,
lastFailed,
}: {
aiResult: AIResponse;
lastUpdated?: string;
lastFailed?: boolean;
}) {
function RecommendationsDisplay({ aiResult, lastUpdated }: { aiResult: AIResponse; lastUpdated?: string }) {
return (
<Stack>
{/* Last Updated timestamp + failure message */}
{/* Last Updated timestamp */}
{lastUpdated && (
<Stack gap={0} align="flex-end">
<Text size="xs" c="dimmed" ta="right">
Last updated: {new Date(lastUpdated).toLocaleString()}
</Text>
{lastFailed && (
<Text size="10px" c="orange" fw={500} style={{ opacity: 0.85 }}>
last analysis failed showing cached data
</Text>
)}
</Stack>
<Text size="xs" c="dimmed" ta="right">
Last updated: {new Date(lastUpdated).toLocaleString()}
</Text>
)}
{/* Overall Assessment */}
@@ -345,8 +327,9 @@ function RecommendationsDisplay({
// ── Main Component ──
export function InvestmentPlanningPage() {
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const [ratesExpanded, setRatesExpanded] = useState(true);
const [isTriggering, setIsTriggering] = useState(false);
// Load financial snapshot on mount
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>({
queryKey: ['investment-planning-saved-recommendation'],
queryFn: async () => {
const { data } = await api.get('/investment-planning/saved-recommendation');
return data;
},
refetchInterval: (query) => {
const rec = query.state.data;
// Poll every 3 seconds while processing
if (rec?.status === 'processing') return 3000;
// Also poll if we just triggered (status may not be 'processing' yet)
if (isTriggering) return 3000;
return false;
},
});
// Derive display state from saved recommendation
const isProcessing = savedRec?.status === 'processing' || isTriggering;
const lastFailed = savedRec?.last_failed || false;
const hasResults = savedRec && savedRec.status === 'complete' && savedRec.recommendations.length > 0;
const hasError = savedRec?.status === 'error' && !savedRec?.recommendations?.length;
// Clear triggering flag once backend confirms processing or completes
// Populate AI results from saved recommendation on load
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({
message: `Generated ${savedRec.recommendations.length} investment recommendations`,
color: 'green',
if (savedRec && !aiResult) {
setAiResult({
recommendations: savedRec.recommendations,
overall_assessment: savedRec.overall_assessment,
risk_notes: savedRec.risk_notes,
});
setLastUpdated(savedRec.created_at);
}
if (prevStatus === 'processing' && savedRec?.status === 'error') {
notifications.show({
message: savedRec.error_message || 'AI recommendation analysis failed',
color: 'red',
});
}
setPrevStatus(savedRec?.status || null);
}, [savedRec?.status]); // eslint-disable-line react-hooks/exhaustive-deps
}, [savedRec]); // 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,
// AI recommendation (on-demand)
const aiMutation = useMutation({
mutationFn: async () => {
const { data } = await api.post('/investment-planning/recommendations', {}, { timeout: 300000 });
return data as AIResponse;
},
onSuccess: (data) => {
setAiResult(data);
setLastUpdated(new Date().toISOString());
if (data.recommendations.length > 0) {
notifications.show({
message: `Generated ${data.recommendations.length} investment recommendations`,
color: 'green',
});
}
: (lastFailed && savedRec?.recommendations?.length)
? {
recommendations: savedRec!.recommendations,
overall_assessment: savedRec!.overall_assessment,
risk_notes: savedRec!.risk_notes,
}
: null;
},
onError: (err: any) => {
notifications.show({
message: err.response?.data?.message || 'Failed to get AI recommendations',
color: 'red',
});
},
});
if (snapshotLoading) {
return (
@@ -698,8 +645,8 @@ export function InvestmentPlanningPage() {
</Group>
<Button
leftSection={<IconSparkles size={16} />}
onClick={handleTriggerAI}
loading={isProcessing}
onClick={() => aiMutation.mutate()}
loading={aiMutation.isPending}
variant="gradient"
gradient={{ from: 'grape', to: 'violet' }}
>
@@ -707,8 +654,8 @@ export function InvestmentPlanningPage() {
</Button>
</Group>
{/* Processing State */}
{isProcessing && (
{/* Loading State */}
{aiMutation.isPending && (
<Center py="xl">
<Stack align="center" gap="sm">
<Loader size="lg" type="dots" />
@@ -716,32 +663,19 @@ export function InvestmentPlanningPage() {
Analyzing your financial data and market rates...
</Text>
<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>
</Stack>
</Center>
)}
{/* Error State (no cached data) */}
{hasError && !isProcessing && (
<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}
/>
{/* Results */}
{aiResult && !aiMutation.isPending && (
<RecommendationsDisplay aiResult={aiResult} lastUpdated={lastUpdated || undefined} />
)}
{/* Empty State */}
{!aiResult && !isProcessing && !hasError && (
{!aiResult && !aiMutation.isPending && (
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
<IconSparkles size={28} />

View File

@@ -10,7 +10,6 @@ import {
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
interface ActualLine {
@@ -67,11 +66,6 @@ export function MonthlyActualsPage() {
const [savedJEId, setSavedJEId] = useState<string | null>(null);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i;
@@ -184,16 +178,16 @@ export function MonthlyActualsPage() {
<Table.Tr key={line.account_id}>
<Table.Td
style={{
position: 'sticky', left: 0, background: stickyBg, zIndex: 1,
borderRight: `1px solid ${stickyBorder}`,
position: 'sticky', left: 0, background: 'white', zIndex: 1,
borderRight: '1px solid #e9ecef',
}}
>
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
</Table.Td>
<Table.Td
style={{
position: 'sticky', left: 120, background: stickyBg, zIndex: 1,
borderRight: `1px solid ${stickyBorder}`,
position: 'sticky', left: 120, background: 'white', zIndex: 1,
borderRight: '1px solid #e9ecef',
}}
>
<Group gap={6} wrap="nowrap">
@@ -298,10 +292,10 @@ export function MonthlyActualsPage() {
<Table striped highlightOnHover style={{ minWidth: 700 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>
Acct #
</Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>
Account Name
</Table.Th>
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
@@ -310,8 +304,8 @@ export function MonthlyActualsPage() {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{renderSection('Income', incomeLines, incomeBg, totals.incomeBudget, totals.incomeActual)}
{renderSection('Expenses', expenseLines, expenseBg, totals.expenseBudget, totals.expenseActual)}
{renderSection('Income', incomeLines, '#e6f9e6', totals.incomeBudget, totals.incomeActual)}
{renderSection('Expenses', expenseLines, '#fde8e8', totals.expenseBudget, totals.expenseActual)}
</Table.Tbody>
</Table>
</div>

View File

@@ -6,11 +6,9 @@ import {
IconUser, IconPalette, IconClock, IconBell, IconEye,
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
export function UserPreferencesPage() {
const { user, currentOrg } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
return (
<Stack>
@@ -68,10 +66,7 @@ export function UserPreferencesPage() {
<Text size="sm">Dark Mode</Text>
<Text size="xs" c="dimmed">Switch to dark color theme</Text>
</div>
<Switch
checked={colorScheme === 'dark'}
onChange={toggleColorScheme}
/>
<Switch disabled />
</Group>
<Group justify="space-between">
<div>
@@ -81,7 +76,7 @@ export function UserPreferencesPage() {
<Switch disabled />
</Group>
<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>
</Card>

View File

@@ -5,7 +5,6 @@ import {
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import api from '../../services/api';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetVsActualLine {
account_id: string;
@@ -47,9 +46,6 @@ const monthFilterOptions = [
export function BudgetVsActualPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [month, setMonth] = useState('');
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i;
@@ -96,7 +92,7 @@ export function BudgetVsActualPage() {
const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => (
<>
<Table.Tr style={{ background: isExpense ? expenseBg : incomeBg }}>
<Table.Tr style={{ background: isExpense ? '#fde8e8' : '#e6f9e6' }}>
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
</Table.Tr>
{sectionLines.map((line) => {

View File

@@ -8,7 +8,6 @@ import {
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
} from '@tabler/icons-react';
import api from '../../services/api';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetVsActualItem {
account_id: string;
@@ -49,9 +48,6 @@ export function QuarterlyReportPage() {
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
const defaultQuarter = currentQuarter;
const defaultYear = now.getFullYear();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const [year, setYear] = useState(String(defaultYear));
const [quarter, setQuarter] = useState(String(defaultQuarter));
@@ -211,7 +207,7 @@ export function QuarterlyReportPage() {
</Table.Thead>
<Table.Tbody>
{incomeItems.length > 0 && (
<Table.Tr style={{ background: incomeBg }}>
<Table.Tr style={{ background: '#e6f9e6' }}>
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
</Table.Tr>
)}
@@ -219,7 +215,7 @@ export function QuarterlyReportPage() {
<BVARow key={item.account_id} item={item} isExpense={false} />
))}
{incomeItems.length > 0 && (
<Table.Tr style={{ background: incomeBg }}>
<Table.Tr style={{ background: '#e6f9e6' }}>
<Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
@@ -230,7 +226,7 @@ export function QuarterlyReportPage() {
</Table.Tr>
)}
{expenseItems.length > 0 && (
<Table.Tr style={{ background: expenseBg }}>
<Table.Tr style={{ background: '#fde8e8' }}>
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
</Table.Tr>
)}
@@ -238,7 +234,7 @@ export function QuarterlyReportPage() {
<BVARow key={item.account_id} item={item} isExpense={true} />
))}
{expenseItems.length > 0 && (
<Table.Tr style={{ background: expenseBg }}>
<Table.Tr style={{ background: '#fde8e8' }}>
<Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_budget, 0))}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>

View File

@@ -38,6 +38,10 @@ export function SettingsPage() {
<Text size="sm" c="dimmed">Your Role</Text>
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Schema</Text>
<Text size="sm" ff="monospace" c="dimmed">{currentOrg?.schemaName || 'N/A'}</Text>
</Group>
</Stack>
</Card>
@@ -113,7 +117,7 @@ export function SettingsPage() {
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.03.10</Badge>
<Badge variant="light">2026.3.2 (beta)</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">API</Text>

View File

@@ -5,6 +5,7 @@ interface Organization {
id: string;
name: string;
role: string;
schemaName?: string;
status?: string;
settings?: Record<string, any>;
}

View File

@@ -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',
},
),
);

View File

@@ -23,8 +23,21 @@ server {
proxy_cache_bypass $http_upgrade;
}
# AI endpoints now return immediately (async processing in background)
# No special timeout needed — kept for documentation purposes
# AI recommendation endpoint needs a longer timeout (up to 3 minutes)
location /api/investment-planning/recommendations {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 180s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
}
# Everything else -> Vite dev server (frontend)
location / {

View File

@@ -74,8 +74,20 @@ server {
proxy_send_timeout 15s;
}
# AI endpoints now return immediately (async processing in background)
# No special timeout overrides needed
# AI endpoints — longer timeouts (LLM calls can take minutes)
location /api/investment-planning/recommendations {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 300s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
}
location /api/health-scores/calculate {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 180s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
}
# --- Frontend → React SPA served by nginx (port 3001) ---
location / {

View File

@@ -40,8 +40,20 @@ server {
proxy_send_timeout 15s;
}
# AI endpoints now return immediately (async processing in background)
# No special timeout overrides needed
# AI endpoints → longer timeouts
location /api/investment-planning/recommendations {
proxy_pass http://backend;
proxy_read_timeout 180s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
}
location /api/health-scores/calculate {
proxy_pass http://backend;
proxy_read_timeout 180s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
}
# --- Static frontend → built React assets ---
location / {

View File

@@ -60,8 +60,37 @@ server {
proxy_cache_bypass $http_upgrade;
}
# AI endpoints now return immediately (async processing in background)
# No special timeout overrides needed
# AI recommendation endpoint needs a longer timeout (up to 3 minutes)
location /api/investment-planning/recommendations {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 180s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
}
# AI health-score endpoint also needs a longer timeout
location /api/health-scores/calculate {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 180s;
proxy_connect_timeout 10s;
proxy_send_timeout 30s;
}
# Everything else -> Vite dev server (frontend)
location / {

View File

@@ -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 ""