Compare commits
19 Commits
feature/in
...
claude/ecs
| Author | SHA1 | Date | |
|---|---|---|---|
| 61a4f27af4 | |||
| 508a86d16c | |||
| 16e1ada261 | |||
| 6bd080f8c4 | |||
| be3a5191c5 | |||
| b0282b7f8b | |||
| ac72905ecb | |||
| 7d4df25d16 | |||
| 538828b91a | |||
| 14160854b9 | |||
| 36d486d78c | |||
| 3bf6b8c6c9 | |||
| 4759374883 | |||
| cb6e34d5ce | |||
| 2b72951e66 | |||
| 69dad7cc74 | |||
| efa5aca35f | |||
| c429dcc033 | |||
| 9146118df1 |
26
backend/package-lock.json
generated
26
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.2-beta",
|
"version": "2026.03.10",
|
||||||
"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.03.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
@@ -16,10 +16,12 @@
|
|||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
"@nestjs/schedule": "^6.1.1",
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
"newrelic": "latest",
|
"newrelic": "latest",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
@@ -1791,6 +1793,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/@nestjs/typeorm": {
|
||||||
"version": "10.0.2",
|
"version": "10.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz",
|
||||||
@@ -5277,6 +5290,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/html-entities": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.2-beta",
|
"version": "2026.3.11",
|
||||||
"description": "HOA LedgerIQ - Backend API",
|
"description": "HOA LedgerIQ - Backend API",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -25,11 +25,14 @@
|
|||||||
"@nestjs/platform-express": "^10.4.15",
|
"@nestjs/platform-express": "^10.4.15",
|
||||||
"@nestjs/schedule": "^6.1.1",
|
"@nestjs/schedule": "^6.1.1",
|
||||||
"@nestjs/swagger": "^7.4.2",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
|
"newrelic": "latest",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
@@ -37,7 +40,6 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
"newrelic": "latest",
|
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
|
|||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { TenantMiddleware } from './database/tenant.middleware';
|
import { TenantMiddleware } from './database/tenant.middleware';
|
||||||
@@ -52,6 +53,10 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
ThrottlerModule.forRoot([{
|
||||||
|
ttl: 60000, // 1-minute window
|
||||||
|
limit: 100, // 100 requests per minute (global default)
|
||||||
|
}]),
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
|
|||||||
@@ -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()
|
||||||
)`,
|
)`,
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export interface TenantRequest extends Request {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenantMiddleware implements NestMiddleware {
|
export class TenantMiddleware implements NestMiddleware {
|
||||||
// In-memory cache for org status to avoid DB hit per request
|
// In-memory cache for org info to avoid DB hit per request
|
||||||
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>();
|
private orgCache = new Map<string, { status: string; schemaName: string; cachedAt: number }>();
|
||||||
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -30,23 +30,25 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
const secret = this.configService.get<string>('JWT_SECRET');
|
const secret = this.configService.get<string>('JWT_SECRET');
|
||||||
const decoded = jwt.verify(token, secret!) as any;
|
const decoded = jwt.verify(token, secret!) as any;
|
||||||
if (decoded?.orgSchema) {
|
if (decoded?.orgId) {
|
||||||
// Check if the org is still active (catches post-JWT suspension)
|
// Look up org info (status + schema) from orgId with caching
|
||||||
if (decoded.orgId) {
|
const orgInfo = await this.getOrgInfo(decoded.orgId);
|
||||||
const status = await this.getOrgStatus(decoded.orgId);
|
if (orgInfo) {
|
||||||
if (status && ['suspended', 'archived'].includes(status)) {
|
if (['suspended', 'archived'].includes(orgInfo.status)) {
|
||||||
res.status(403).json({
|
res.status(403).json({
|
||||||
statusCode: 403,
|
statusCode: 403,
|
||||||
message: `This organization has been ${status}. Please contact your administrator.`,
|
message: `This organization has been ${orgInfo.status}. Please contact your administrator.`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
req.tenantSchema = orgInfo.schemaName;
|
||||||
}
|
}
|
||||||
|
|
||||||
req.tenantSchema = decoded.orgSchema;
|
|
||||||
req.orgId = decoded.orgId;
|
req.orgId = decoded.orgId;
|
||||||
req.userId = decoded.sub;
|
req.userId = decoded.sub;
|
||||||
req.userRole = decoded.role;
|
req.userRole = decoded.role;
|
||||||
|
} else if (decoded?.sub) {
|
||||||
|
// Superadmin or user without org — still set userId
|
||||||
|
req.userId = decoded.sub;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Token invalid or expired - let Passport handle the auth error
|
// Token invalid or expired - let Passport handle the auth error
|
||||||
@@ -55,19 +57,23 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getOrgStatus(orgId: string): Promise<string | null> {
|
private async getOrgInfo(orgId: string): Promise<{ status: string; schemaName: string } | null> {
|
||||||
const cached = this.orgStatusCache.get(orgId);
|
const cached = this.orgCache.get(orgId);
|
||||||
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
||||||
return cached.status;
|
return { status: cached.status, schemaName: cached.schemaName };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await this.dataSource.query(
|
const result = await this.dataSource.query(
|
||||||
`SELECT status FROM shared.organizations WHERE id = $1`,
|
`SELECT status, schema_name as "schemaName" FROM shared.organizations WHERE id = $1`,
|
||||||
[orgId],
|
[orgId],
|
||||||
);
|
);
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() });
|
this.orgCache.set(orgId, {
|
||||||
return result[0].status;
|
status: result[0].status,
|
||||||
|
schemaName: result[0].schemaName,
|
||||||
|
cachedAt: Date.now(),
|
||||||
|
});
|
||||||
|
return { status: result[0].status, schemaName: result[0].schemaName };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical — don't block requests on cache miss errors
|
// Non-critical — don't block requests on cache miss errors
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as os from 'node:os';
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import helmet from 'helmet';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
|
const cluster = _cluster as any; // Cast to 'any' bypasses the missing property errors
|
||||||
@@ -41,6 +42,24 @@ async function bootstrap() {
|
|||||||
|
|
||||||
app.setGlobalPrefix('api');
|
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)
|
// Request logging — only in development (too noisy / slow for prod)
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
app.use((req: any, _res: any, next: any) => {
|
app.use((req: any, _res: any, next: any) => {
|
||||||
@@ -63,15 +82,17 @@ async function bootstrap() {
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Swagger docs — available in all environments
|
// Swagger docs — disabled in production to avoid exposing API surface
|
||||||
|
if (!isProduction) {
|
||||||
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.11')
|
||||||
.addBearerAuth()
|
.addBearerAuth()
|
||||||
.build();
|
.build();
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const document = SwaggerModule.createDocument(app, config);
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
}
|
||||||
|
|
||||||
await app.listen(3000);
|
await app.listen(3000);
|
||||||
console.log(`Backend worker ${process.pid} listening on port 3000`);
|
console.log(`Backend worker ${process.pid} listening on port 3000`);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
@@ -23,12 +24,14 @@ export class AuthController {
|
|||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
@ApiOperation({ summary: 'Register a new user' })
|
@ApiOperation({ summary: 'Register a new user' })
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
async register(@Body() dto: RegisterDto) {
|
async register(@Body() dto: RegisterDto) {
|
||||||
return this.authService.register(dto);
|
return this.authService.register(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@ApiOperation({ summary: 'Login with email and password' })
|
@ApiOperation({ summary: 'Login with email and password' })
|
||||||
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
||||||
@UseGuards(AuthGuard('local'))
|
@UseGuards(AuthGuard('local'))
|
||||||
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
||||||
const ip = req.headers['x-forwarded-for'] || req.ip;
|
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||||
|
|||||||
@@ -118,7 +118,6 @@ export class AuthService {
|
|||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
orgId: membership.organizationId,
|
orgId: membership.organizationId,
|
||||||
orgSchema: membership.organization.schemaName,
|
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -177,7 +176,6 @@ export class AuthService {
|
|||||||
|
|
||||||
if (defaultOrg) {
|
if (defaultOrg) {
|
||||||
payload.orgId = defaultOrg.organizationId;
|
payload.orgId = defaultOrg.organizationId;
|
||||||
payload.orgSchema = defaultOrg.organization?.schemaName;
|
|
||||||
payload.role = defaultOrg.role;
|
payload.role = defaultOrg.role;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +193,6 @@ export class AuthService {
|
|||||||
organizations: orgs.map((uo) => ({
|
organizations: orgs.map((uo) => ({
|
||||||
id: uo.organizationId,
|
id: uo.organizationId,
|
||||||
name: uo.organization?.name,
|
name: uo.organization?.name,
|
||||||
schemaName: uo.organization?.schemaName,
|
|
||||||
status: uo.organization?.status,
|
status: uo.organization?.status,
|
||||||
role: uo.role,
|
role: uo.role,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
sub: payload.sub,
|
sub: payload.sub,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
orgId: payload.orgId,
|
orgId: payload.orgId,
|
||||||
orgSchema: payload.orgSchema,
|
|
||||||
role: payload.role,
|
role: payload.role,
|
||||||
isSuperadmin: payload.isSuperadmin || false,
|
isSuperadmin: payload.isSuperadmin || false,
|
||||||
impersonatedBy: payload.impersonatedBy || null,
|
impersonatedBy: payload.impersonatedBy || null,
|
||||||
|
|||||||
@@ -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,42 +9,68 @@ 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')
|
||||||
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
|
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
|
||||||
getLatest(@Req() req: any) {
|
getLatest(@Req() req: any) {
|
||||||
const schema = req.user?.orgSchema;
|
const schema = req.tenantSchema;
|
||||||
return this.service.getLatestScores(schema);
|
return this.service.getLatestScores(schema);
|
||||||
}
|
}
|
||||||
|
|
||||||
@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.tenantSchema;
|
||||||
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.tenantSchema;
|
||||||
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.tenantSchema;
|
||||||
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.',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -9,5 +9,34 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.2-beta",
|
"version": "2026.03.10",
|
||||||
"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.03.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^7.15.3",
|
"@mantine/core": "^7.15.3",
|
||||||
"@mantine/dates": "^7.15.3",
|
"@mantine/dates": "^7.15.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.2-beta",
|
"version": "2026.3.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } 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>
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -120,11 +120,6 @@ export function SelectOrgPage() {
|
|||||||
<Text fw={500}>{org.name}</Text>
|
<Text fw={500}>{org.name}</Text>
|
||||||
<Group gap={4}>
|
<Group gap={4}>
|
||||||
<Badge size="sm" variant="light">{org.role}</Badge>
|
<Badge size="sm" variant="light">{org.role}</Badge>
|
||||||
{org.schemaName && (
|
|
||||||
<Badge size="xs" variant="dot" color="gray">
|
|
||||||
{org.schemaName}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import {
|
|||||||
Select, Loader, Center, Badge, Card, Alert,
|
Select, Loader, Center, Badge, Card, Alert,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle } from '@tabler/icons-react';
|
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle, IconPencil, IconX } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
interface BudgetLine {
|
interface BudgetLine {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -95,9 +96,20 @@ function parseCSV(text: string): Record<string, string>[] {
|
|||||||
export function BudgetsPage() {
|
export function BudgetsPage() {
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||||
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isReadOnly = useIsReadOnly();
|
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[]>({
|
const { isLoading } = useQuery<BudgetLine[]>({
|
||||||
queryKey: ['budgets', year],
|
queryKey: ['budgets', year],
|
||||||
@@ -106,25 +118,27 @@ export function BudgetsPage() {
|
|||||||
// Hydrate each line: ensure numbers and compute annual_total
|
// Hydrate each line: ensure numbers and compute annual_total
|
||||||
const hydrated = (data as any[]).map(hydrateBudgetLine);
|
const hydrated = (data as any[]).map(hydrateBudgetLine);
|
||||||
setBudgetData(hydrated);
|
setBudgetData(hydrated);
|
||||||
|
setIsEditing(false); // Reset to view mode when year changes or data reloads
|
||||||
return hydrated;
|
return hydrated;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const lines = budgetData
|
const payload = budgetData
|
||||||
.filter((b) => months.some((m) => (b as any)[m] > 0))
|
.filter((b) => months.some((m) => (b as any)[m] > 0))
|
||||||
.map((b) => ({
|
.map((b) => ({
|
||||||
account_id: b.account_id,
|
accountId: b.account_id,
|
||||||
fund_type: b.fund_type,
|
fundType: b.fund_type,
|
||||||
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
|
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
|
||||||
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
|
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
|
||||||
sep: b.sep, oct: b.oct, nov: b.nov, dec_amt: b.dec_amt,
|
sep: b.sep, oct: b.oct, nov: b.nov, dec: b.dec_amt,
|
||||||
}));
|
}));
|
||||||
return api.put(`/budgets/${year}`, { lines });
|
return api.put(`/budgets/${year}`, payload);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
|
||||||
|
setIsEditing(false);
|
||||||
notifications.show({ message: 'Budget saved', color: 'green' });
|
notifications.show({ message: 'Budget saved', color: 'green' });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -221,6 +235,12 @@ export function BudgetsPage() {
|
|||||||
event.target.value = '';
|
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 updateCell = (idx: number, month: string, value: number) => {
|
||||||
const updated = [...budgetData];
|
const updated = [...budgetData];
|
||||||
(updated[idx] as any)[month] = value || 0;
|
(updated[idx] as any)[month] = value || 0;
|
||||||
@@ -275,9 +295,35 @@ export function BudgetsPage() {
|
|||||||
accept=".csv,.txt"
|
accept=".csv,.txt"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
|
{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
|
Save Budget
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>)}
|
</>)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -317,8 +363,8 @@ export function BudgetsPage() {
|
|||||||
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
|
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
|
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
|
||||||
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
|
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
|
||||||
{monthLabels.map((m) => (
|
{monthLabels.map((m) => (
|
||||||
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
|
||||||
))}
|
))}
|
||||||
@@ -337,7 +383,7 @@ export function BudgetsPage() {
|
|||||||
const lines = budgetData.filter((b) => b.account_type === type);
|
const lines = budgetData.filter((b) => b.account_type === type);
|
||||||
if (lines.length === 0) return null;
|
if (lines.length === 0) return null;
|
||||||
|
|
||||||
const sectionBg = type === 'income' ? '#e6f9e6' : '#fde8e8';
|
const sectionBg = type === 'income' ? incomeSectionBg : expenseSectionBg;
|
||||||
const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -368,9 +414,9 @@ export function BudgetsPage() {
|
|||||||
style={{
|
style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
left: 0,
|
left: 0,
|
||||||
background: 'white',
|
background: stickyBg,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
borderRight: '1px solid #e9ecef',
|
borderRight: `1px solid ${stickyBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
||||||
@@ -379,9 +425,9 @@ export function BudgetsPage() {
|
|||||||
style={{
|
style={{
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
left: 120,
|
left: 120,
|
||||||
background: 'white',
|
background: stickyBg,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
borderRight: '1px solid #e9ecef',
|
borderRight: `1px solid ${stickyBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
@@ -391,6 +437,7 @@ export function BudgetsPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
{months.map((m) => (
|
{months.map((m) => (
|
||||||
<Table.Td key={m} p={2}>
|
<Table.Td key={m} p={2}>
|
||||||
|
{cellsEditable ? (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={(line as any)[m] || 0}
|
value={(line as any)[m] || 0}
|
||||||
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
|
||||||
@@ -398,9 +445,13 @@ export function BudgetsPage() {
|
|||||||
hideControls
|
hideControls
|
||||||
decimalScale={2}
|
decimalScale={2}
|
||||||
min={0}
|
min={0}
|
||||||
disabled={isReadOnly}
|
|
||||||
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" ta="right" ff="monospace">
|
||||||
|
{fmt((line as any)[m] || 0)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
))}
|
))}
|
||||||
<Table.Td ta="right" fw={500} ff="monospace">
|
<Table.Td ta="right" fw={500} ff="monospace">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
IconArrowLeft, IconArrowRight, IconCalendar,
|
IconArrowLeft, IconArrowRight, IconCalendar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
import {
|
import {
|
||||||
AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
AreaChart, Area, XAxis, YAxis, CartesianGrid,
|
||||||
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
|
Tooltip as RechartsTooltip, ResponsiveContainer, Legend,
|
||||||
@@ -79,6 +80,7 @@ export function CashFlowForecastPage() {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const currentYear = now.getFullYear();
|
const currentYear = now.getFullYear();
|
||||||
const currentMonth = now.getMonth() + 1;
|
const currentMonth = now.getMonth() + 1;
|
||||||
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
|
|
||||||
// Filter: All, Operating, Reserve
|
// Filter: All, Operating, Reserve
|
||||||
const [fundFilter, setFundFilter] = useState<string>('all');
|
const [fundFilter, setFundFilter] = useState<string>('all');
|
||||||
@@ -418,10 +420,10 @@ export function CashFlowForecastPage() {
|
|||||||
<tr
|
<tr
|
||||||
key={d.month}
|
key={d.month}
|
||||||
style={{
|
style={{
|
||||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
borderBottom: `1px solid ${isDark ? 'var(--mantine-color-dark-4)' : 'var(--mantine-color-gray-2)'}`,
|
||||||
backgroundColor: d.is_forecast
|
backgroundColor: d.is_forecast
|
||||||
? 'var(--mantine-color-orange-0)'
|
? (isDark ? 'var(--mantine-color-orange-9)' : 'var(--mantine-color-orange-0)')
|
||||||
: i % 2 === 0 ? 'transparent' : 'var(--mantine-color-gray-0)',
|
: i % 2 === 0 ? 'transparent' : (isDark ? 'var(--mantine-color-dark-5)' : 'var(--mantine-color-gray-0)'),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>
|
<td style={{ padding: '6px 12px', fontWeight: 500 }}>{d.month}</td>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
|
|
||||||
interface ActualLine {
|
interface ActualLine {
|
||||||
@@ -66,6 +67,11 @@ export function MonthlyActualsPage() {
|
|||||||
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
const [savedJEId, setSavedJEId] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
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 yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 2 + i;
|
const y = new Date().getFullYear() - 2 + i;
|
||||||
@@ -178,16 +184,16 @@ export function MonthlyActualsPage() {
|
|||||||
<Table.Tr key={line.account_id}>
|
<Table.Tr key={line.account_id}>
|
||||||
<Table.Td
|
<Table.Td
|
||||||
style={{
|
style={{
|
||||||
position: 'sticky', left: 0, background: 'white', zIndex: 1,
|
position: 'sticky', left: 0, background: stickyBg, zIndex: 1,
|
||||||
borderRight: '1px solid #e9ecef',
|
borderRight: `1px solid ${stickyBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td
|
<Table.Td
|
||||||
style={{
|
style={{
|
||||||
position: 'sticky', left: 120, background: 'white', zIndex: 1,
|
position: 'sticky', left: 120, background: stickyBg, zIndex: 1,
|
||||||
borderRight: '1px solid #e9ecef',
|
borderRight: `1px solid ${stickyBorder}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
@@ -292,10 +298,10 @@ export function MonthlyActualsPage() {
|
|||||||
<Table striped highlightOnHover style={{ minWidth: 700 }}>
|
<Table striped highlightOnHover style={{ minWidth: 700 }}>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>
|
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>
|
||||||
Acct #
|
Acct #
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>
|
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>
|
||||||
Account Name
|
Account Name
|
||||||
</Table.Th>
|
</Table.Th>
|
||||||
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
|
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
|
||||||
@@ -304,8 +310,8 @@ export function MonthlyActualsPage() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{renderSection('Income', incomeLines, '#e6f9e6', totals.incomeBudget, totals.incomeActual)}
|
{renderSection('Income', incomeLines, incomeBg, totals.incomeBudget, totals.incomeActual)}
|
||||||
{renderSection('Expenses', expenseLines, '#fde8e8', totals.expenseBudget, totals.expenseActual)}
|
{renderSection('Expenses', expenseLines, expenseBg, totals.expenseBudget, totals.expenseActual)}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
interface BudgetVsActualLine {
|
interface BudgetVsActualLine {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -46,6 +47,9 @@ const monthFilterOptions = [
|
|||||||
export function BudgetVsActualPage() {
|
export function BudgetVsActualPage() {
|
||||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||||
const [month, setMonth] = useState('');
|
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 yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||||
const y = new Date().getFullYear() - 2 + i;
|
const y = new Date().getFullYear() - 2 + i;
|
||||||
@@ -92,7 +96,7 @@ export function BudgetVsActualPage() {
|
|||||||
|
|
||||||
const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => (
|
const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => (
|
||||||
<>
|
<>
|
||||||
<Table.Tr style={{ background: isExpense ? '#fde8e8' : '#e6f9e6' }}>
|
<Table.Tr style={{ background: isExpense ? expenseBg : incomeBg }}>
|
||||||
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
|
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
{sectionLines.map((line) => {
|
{sectionLines.map((line) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
|
IconTrendingUp, IconTrendingDown, IconAlertTriangle, IconChartBar,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
interface BudgetVsActualItem {
|
interface BudgetVsActualItem {
|
||||||
account_id: string;
|
account_id: string;
|
||||||
@@ -48,6 +49,9 @@ export function QuarterlyReportPage() {
|
|||||||
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
|
const currentQuarter = Math.ceil((now.getMonth() + 1) / 3);
|
||||||
const defaultQuarter = currentQuarter;
|
const defaultQuarter = currentQuarter;
|
||||||
const defaultYear = now.getFullYear();
|
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 [year, setYear] = useState(String(defaultYear));
|
||||||
const [quarter, setQuarter] = useState(String(defaultQuarter));
|
const [quarter, setQuarter] = useState(String(defaultQuarter));
|
||||||
@@ -207,7 +211,7 @@ export function QuarterlyReportPage() {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{incomeItems.length > 0 && (
|
{incomeItems.length > 0 && (
|
||||||
<Table.Tr style={{ background: '#e6f9e6' }}>
|
<Table.Tr style={{ background: incomeBg }}>
|
||||||
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
|
<Table.Td colSpan={8} fw={700}>Income</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
@@ -215,7 +219,7 @@ export function QuarterlyReportPage() {
|
|||||||
<BVARow key={item.account_id} item={item} isExpense={false} />
|
<BVARow key={item.account_id} item={item} isExpense={false} />
|
||||||
))}
|
))}
|
||||||
{incomeItems.length > 0 && (
|
{incomeItems.length > 0 && (
|
||||||
<Table.Tr style={{ background: '#e6f9e6' }}>
|
<Table.Tr style={{ background: incomeBg }}>
|
||||||
<Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
|
<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_budget, 0))}</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(incomeItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
||||||
@@ -226,7 +230,7 @@ export function QuarterlyReportPage() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
{expenseItems.length > 0 && (
|
{expenseItems.length > 0 && (
|
||||||
<Table.Tr style={{ background: '#fde8e8' }}>
|
<Table.Tr style={{ background: expenseBg }}>
|
||||||
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
|
<Table.Td colSpan={8} fw={700}>Expenses</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
@@ -234,7 +238,7 @@ export function QuarterlyReportPage() {
|
|||||||
<BVARow key={item.account_id} item={item} isExpense={true} />
|
<BVARow key={item.account_id} item={item} isExpense={true} />
|
||||||
))}
|
))}
|
||||||
{expenseItems.length > 0 && (
|
{expenseItems.length > 0 && (
|
||||||
<Table.Tr style={{ background: '#fde8e8' }}>
|
<Table.Tr style={{ background: expenseBg }}>
|
||||||
<Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
|
<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_budget, 0))}</Table.Td>
|
||||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
<Table.Td ta="right" fw={700} ff="monospace">{fmt(expenseItems.reduce((s, i) => s + i.quarter_actual, 0))}</Table.Td>
|
||||||
|
|||||||
@@ -38,10 +38,6 @@ export function SettingsPage() {
|
|||||||
<Text size="sm" c="dimmed">Your Role</Text>
|
<Text size="sm" c="dimmed">Your Role</Text>
|
||||||
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
|
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
|
||||||
</Group>
|
</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>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -117,7 +113,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.03.10</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>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ interface Organization {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
schemaName?: string;
|
|
||||||
status?: string;
|
status?: string;
|
||||||
settings?: Record<string, any>;
|
settings?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|||||||
26
frontend/src/stores/preferencesStore.ts
Normal file
26
frontend/src/stores/preferencesStore.ts
Normal 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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -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 / {
|
||||||
|
|||||||
@@ -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 / {
|
||||||
|
|||||||
@@ -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 / {
|
||||||
|
|||||||
@@ -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
150
scripts/reset-password.sh
Executable 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 ""
|
||||||
Reference in New Issue
Block a user