8 Commits

Author SHA1 Message Date
36d486d78c Add Chat Widget for support
added support chat widget to index.html
2026-03-09 13:31:17 -04:00
4759374883 feat: add dark mode with persistent user preference
Add dark mode support using Mantine's built-in color scheme system,
persisted via a new Zustand preferences store. Includes a quick toggle
in the app header and an enabled switch in User Preferences. Also
removes the "AI Health Scores" title from the dashboard to reclaim
vertical space.

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ async function bootstrap() {
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('HOA LedgerIQ API') .setTitle('HOA LedgerIQ API')
.setDescription('API for the HOA LedgerIQ') .setDescription('API for the HOA LedgerIQ')
.setVersion('2026.3.2') .setVersion('2026.3.7')
.addBearerAuth() .addBearerAuth()
.build(); .build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);

View File

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

View File

@@ -1115,7 +1115,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'), 'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
}, },
timeout: 120000, timeout: 600000, // 10 minute timeout
}; };
const req = https.request(options, (res) => { const req = https.request(options, (res) => {
@@ -1129,7 +1129,7 @@ Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toF
req.on('error', (err) => reject(err)); req.on('error', (err) => reject(err));
req.on('timeout', () => { req.on('timeout', () => {
req.destroy(); req.destroy();
reject(new Error('Request timed out after 120s')); reject(new Error('Request timed out after 600s'));
}); });
req.write(bodyString); req.write(bodyString);

View File

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

View File

@@ -65,6 +65,9 @@ export interface SavedRecommendation {
risk_notes: string[]; risk_notes: string[];
response_time_ms: number; response_time_ms: number;
created_at: string; created_at: string;
status: 'processing' | 'complete' | 'error';
last_failed: boolean;
error_message?: string;
} }
@Injectable() @Injectable()
@@ -196,14 +199,33 @@ export class InvestmentPlanningService {
return rates.cd; return rates.cd;
} }
/**
* Ensure the status/error_message columns exist (for tenants created before this migration).
*/
private async ensureStatusColumn(): Promise<void> {
try {
await this.tenant.query(
`ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'complete'`,
);
await this.tenant.query(
`ALTER TABLE ai_recommendations ADD COLUMN IF NOT EXISTS error_message TEXT`,
);
} catch {
// Ignore — column may already exist or table may not exist
}
}
/** /**
* Get the latest saved AI recommendation for this tenant. * Get the latest saved AI recommendation for this tenant.
* Returns status and last_failed flag for UI state management.
*/ */
async getSavedRecommendation(): Promise<SavedRecommendation | null> { async getSavedRecommendation(): Promise<SavedRecommendation | null> {
try { try {
await this.ensureStatusColumn();
const rows = await this.tenant.query( const rows = await this.tenant.query(
`SELECT id, recommendations_json, overall_assessment, risk_notes, `SELECT id, recommendations_json, overall_assessment, risk_notes,
response_time_ms, created_at response_time_ms, status, error_message, created_at
FROM ai_recommendations FROM ai_recommendations
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1`, LIMIT 1`,
@@ -212,6 +234,64 @@ export class InvestmentPlanningService {
if (!rows || rows.length === 0) return null; if (!rows || rows.length === 0) return null;
const row = rows[0]; const row = rows[0];
const status = row.status || 'complete';
// If still processing, return processing status
if (status === 'processing') {
return {
id: row.id,
recommendations: [],
overall_assessment: '',
risk_notes: [],
response_time_ms: 0,
created_at: row.created_at,
status: 'processing',
last_failed: false,
};
}
// If latest attempt failed, return the last successful result with last_failed flag
if (status === 'error') {
const lastGood = await this.tenant.query(
`SELECT id, recommendations_json, overall_assessment, risk_notes,
response_time_ms, created_at
FROM ai_recommendations
WHERE status = 'complete'
ORDER BY created_at DESC
LIMIT 1`,
);
if (lastGood?.length) {
const goodRow = lastGood[0];
const recData = goodRow.recommendations_json || {};
return {
id: goodRow.id,
recommendations: recData.recommendations || [],
overall_assessment: goodRow.overall_assessment || recData.overall_assessment || '',
risk_notes: goodRow.risk_notes || recData.risk_notes || [],
response_time_ms: goodRow.response_time_ms || 0,
created_at: goodRow.created_at,
status: 'complete',
last_failed: true,
error_message: row.error_message,
};
}
// No previous good result — return error state
return {
id: row.id,
recommendations: [],
overall_assessment: row.error_message || 'AI analysis failed. Please try again.',
risk_notes: [],
response_time_ms: 0,
created_at: row.created_at,
status: 'error',
last_failed: true,
error_message: row.error_message,
};
}
// Complete — return the data normally
const recData = row.recommendations_json || {}; const recData = row.recommendations_json || {};
return { return {
id: row.id, id: row.id,
@@ -220,6 +300,8 @@ export class InvestmentPlanningService {
risk_notes: row.risk_notes || recData.risk_notes || [], risk_notes: row.risk_notes || recData.risk_notes || [],
response_time_ms: row.response_time_ms || 0, response_time_ms: row.response_time_ms || 0,
created_at: row.created_at, created_at: row.created_at,
status: 'complete',
last_failed: false,
}; };
} catch (err: any) { } catch (err: any) {
// Table might not exist yet (pre-migration tenants) // Table might not exist yet (pre-migration tenants)
@@ -228,15 +310,153 @@ export class InvestmentPlanningService {
} }
} }
/**
* Save a 'processing' placeholder record and return its ID.
*/
private async saveProcessingRecord(userId?: string): Promise<string> {
await this.ensureStatusColumn();
const rows = await this.tenant.query(
`INSERT INTO ai_recommendations
(recommendations_json, overall_assessment, risk_notes, requested_by, status)
VALUES ('{}', '', '[]', $1, 'processing')
RETURNING id`,
[userId || null],
);
return rows[0].id;
}
/**
* Update a processing record with completed results.
*/
private async updateRecommendationComplete(
jobId: string,
aiResponse: AIResponse,
userId: string | undefined,
elapsed: number,
): Promise<void> {
try {
await this.tenant.query(
`UPDATE ai_recommendations
SET recommendations_json = $1,
overall_assessment = $2,
risk_notes = $3,
response_time_ms = $4,
status = 'complete'
WHERE id = $5`,
[
JSON.stringify(aiResponse),
aiResponse.overall_assessment || '',
JSON.stringify(aiResponse.risk_notes || []),
elapsed,
jobId,
],
);
} catch (err: any) {
this.logger.warn(`Could not update recommendation ${jobId}: ${err.message}`);
}
}
/**
* Update a processing record with error status.
*/
private async updateRecommendationError(jobId: string, errorMessage: string): Promise<void> {
try {
await this.tenant.query(
`UPDATE ai_recommendations
SET status = 'error',
error_message = $1
WHERE id = $2`,
[errorMessage, jobId],
);
} catch (err: any) {
this.logger.warn(`Could not update recommendation error ${jobId}: ${err.message}`);
}
}
/**
* Trigger AI recommendations asynchronously.
* Saves a 'processing' record, starts the AI work in the background, and returns immediately.
* The TenantService instance remains alive via closure reference for the duration of the background work.
*/
async triggerAIRecommendations(userId?: string, orgId?: string): Promise<{ status: string; message: string }> {
const jobId = await this.saveProcessingRecord(userId);
this.logger.log(`AI recommendation triggered (job ${jobId}), starting background processing...`);
// Fire-and-forget — the Promise keeps this service instance (and TenantService) alive
this.runBackgroundRecommendations(jobId, userId, orgId).catch((err) => {
this.logger.error(`Background AI recommendation failed (job ${jobId}): ${err.message}`);
});
return {
status: 'processing',
message: 'AI analysis has been started. You can navigate away safely — results will appear when ready.',
};
}
/**
* Run the full AI recommendation pipeline in the background.
*/
private async runBackgroundRecommendations(jobId: string, userId?: string, orgId?: string): Promise<void> {
try {
const startTime = Date.now();
const [snapshot, allRates, monthlyForecast] = await Promise.all([
this.getFinancialSnapshot(),
this.getMarketRates(),
this.getMonthlyForecast(),
]);
this.debug('background_snapshot_summary', {
job_id: jobId,
operating_cash: snapshot.summary.operating_cash,
reserve_cash: snapshot.summary.reserve_cash,
total_all: snapshot.summary.total_all,
investment_accounts: snapshot.investment_accounts.length,
});
const messages = this.buildPromptMessages(snapshot, allRates, monthlyForecast);
const aiResponse = await this.callAI(messages);
const elapsed = Date.now() - startTime;
this.debug('background_final_response', {
job_id: jobId,
recommendation_count: aiResponse.recommendations.length,
has_assessment: !!aiResponse.overall_assessment,
elapsed_ms: elapsed,
});
// Check if the AI returned a graceful error (empty recommendations with error message)
const isGracefulError = aiResponse.recommendations.length === 0 &&
(aiResponse.overall_assessment?.includes('Unable to generate') ||
aiResponse.overall_assessment?.includes('invalid response'));
if (isGracefulError) {
await this.updateRecommendationError(jobId, aiResponse.overall_assessment);
} else {
await this.updateRecommendationComplete(jobId, aiResponse, userId, elapsed);
}
// Log AI usage (fire-and-forget)
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
this.logger.log(`Background AI recommendation completed (job ${jobId}) in ${elapsed}ms`);
} catch (err: any) {
this.logger.error(`Background AI recommendation error (job ${jobId}): ${err.message}`);
await this.updateRecommendationError(jobId, err.message);
}
}
/** /**
* Save AI recommendation result to tenant schema. * Save AI recommendation result to tenant schema.
* @deprecated Use triggerAIRecommendations() for async flow instead
*/ */
private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> { private async saveRecommendation(aiResponse: AIResponse, userId: string | undefined, elapsed: number): Promise<void> {
try { try {
await this.ensureStatusColumn();
await this.tenant.query( await this.tenant.query(
`INSERT INTO ai_recommendations `INSERT INTO ai_recommendations
(recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms) (recommendations_json, overall_assessment, risk_notes, requested_by, response_time_ms, status)
VALUES ($1, $2, $3, $4, $5)`, VALUES ($1, $2, $3, $4, $5, 'complete')`,
[ [
JSON.stringify(aiResponse), JSON.stringify(aiResponse),
aiResponse.overall_assessment || '', aiResponse.overall_assessment || '',
@@ -873,7 +1093,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'), 'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
}, },
timeout: 300000, // 5 minute timeout timeout: 600000, // 10 minute timeout
}; };
const req = https.request(options, (res) => { const req = https.request(options, (res) => {
@@ -887,7 +1107,7 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
req.on('error', (err) => reject(err)); req.on('error', (err) => reject(err));
req.on('timeout', () => { req.on('timeout', () => {
req.destroy(); req.destroy();
reject(new Error(`Request timed out after 300s`)); reject(new Error(`Request timed out after 600s`));
}); });
req.write(bodyString); req.write(bodyString);

View File

@@ -9,5 +9,20 @@
<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.hoaledger.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");
</script>
</body> </body>
</html> </html>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core'; import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button, ActionIcon, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { import {
IconLogout, IconLogout,
@@ -9,9 +9,12 @@ import {
IconUserCog, IconUserCog,
IconUsersGroup, IconUsersGroup,
IconEyeOff, IconEyeOff,
IconSun,
IconMoon,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { AppTour } from '../onboarding/AppTour'; import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard'; import { OnboardingWizard } from '../onboarding/OnboardingWizard';
@@ -20,6 +23,7 @@ import logoSrc from '../../assets/logo.svg';
export function AppLayout() { export function AppLayout() {
const [opened, { toggle, close }] = useDisclosure(); const [opened, { toggle, close }] = useDisclosure();
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore(); const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
const { colorScheme, toggleColorScheme } = usePreferencesStore();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const isImpersonating = !!impersonationOriginal; const isImpersonating = !!impersonationOriginal;
@@ -108,6 +112,16 @@ export function AppLayout() {
{currentOrg && ( {currentOrg && (
<Text size="sm" c="dimmed">{currentOrg.name}</Text> <Text size="sm" c="dimmed">{currentOrg.name}</Text>
)} )}
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
<ActionIcon
variant="default"
size="lg"
onClick={toggleColorScheme}
aria-label="Toggle color scheme"
>
{colorScheme === 'dark' ? <IconSun size={18} /> : <IconMoon size={18} />}
</ActionIcon>
</Tooltip>
<Menu shadow="md" width={220}> <Menu shadow="md" width={220}>
<Menu.Target> <Menu.Target>
<UnstyledButton> <UnstyledButton>

View File

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

View File

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

View File

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

View File

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

View File

@@ -117,7 +117,7 @@ export function SettingsPage() {
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text> <Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.3.2 (beta)</Badge> <Badge variant="light">2026.3.7 (Beta)</Badge>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Text size="sm" c="dimmed">API</Text> <Text size="sm" c="dimmed">API</Text>

View File

@@ -0,0 +1,26 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type ColorScheme = 'light' | 'dark';
interface PreferencesState {
colorScheme: ColorScheme;
toggleColorScheme: () => void;
setColorScheme: (scheme: ColorScheme) => void;
}
export const usePreferencesStore = create<PreferencesState>()(
persist(
(set) => ({
colorScheme: 'light',
toggleColorScheme: () =>
set((state) => ({
colorScheme: state.colorScheme === 'light' ? 'dark' : 'light',
})),
setColorScheme: (scheme) => set({ colorScheme: scheme }),
}),
{
name: 'ledgeriq-preferences',
},
),
);

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# reset-password.sh — Reset a user's password in HOA LedgerIQ
#
# Usage:
# ./scripts/reset-password.sh <email> <new-password>
#
# Examples:
# ./scripts/reset-password.sh admin@hoaledgeriq.com MyNewPassword123
# ./scripts/reset-password.sh admin@sunrisevalley.org SecurePass!
# ---------------------------------------------------------------------------
set -euo pipefail
# ---- Defaults ----
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DB_USER="${POSTGRES_USER:-hoafinance}"
DB_NAME="${POSTGRES_DB:-hoafinance}"
COMPOSE_CMD="docker compose"
# If running with the SSL override, detect it
if [ -f "$PROJECT_DIR/docker-compose.ssl.yml" ] && \
docker compose -f "$PROJECT_DIR/docker-compose.yml" \
-f "$PROJECT_DIR/docker-compose.ssl.yml" ps --quiet 2>/dev/null | head -1 | grep -q .; then
COMPOSE_CMD="docker compose -f $PROJECT_DIR/docker-compose.yml -f $PROJECT_DIR/docker-compose.ssl.yml"
fi
# ---- Colors ----
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
die() { err "$@"; exit 1; }
# ---- Helpers ----
ensure_containers_running() {
if ! $COMPOSE_CMD ps postgres 2>/dev/null | grep -q "running\|Up"; then
die "PostgreSQL container is not running. Start it with: docker compose up -d postgres"
fi
if ! $COMPOSE_CMD ps backend 2>/dev/null | grep -q "running\|Up"; then
die "Backend container is not running. Start it with: docker compose up -d backend"
fi
}
# ---- CLI ----
usage() {
cat <<EOF
HOA LedgerIQ Password Reset
Usage:
$(basename "$0") <email> <new-password>
Examples:
$(basename "$0") admin@hoaledgeriq.com MyNewPassword123
$(basename "$0") admin@sunrisevalley.org SecurePass!
This script:
1. Verifies the user exists in the database
2. Generates a bcrypt hash using bcryptjs (same library the app uses)
3. Updates the password in the database
4. Verifies the new hash works
EOF
exit 0
}
# Parse args
case "${1:-}" in
-h|--help|help|"") usage ;;
esac
[ $# -lt 2 ] && die "Usage: $(basename "$0") <email> <new-password>"
EMAIL="$1"
NEW_PASSWORD="$2"
# Load .env if present
if [ -f "$PROJECT_DIR/.env" ]; then
set -a
# shellcheck disable=SC1091
source "$PROJECT_DIR/.env"
set +a
DB_USER="${POSTGRES_USER:-hoafinance}"
DB_NAME="${POSTGRES_DB:-hoafinance}"
fi
# Ensure containers are running
info "Checking containers ..."
ensure_containers_running
# Verify user exists
info "Looking up user: ${EMAIL} ..."
USER_RECORD=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" \
-t -A -c "SELECT id, email, first_name, last_name, is_superadmin FROM shared.users WHERE email = '${EMAIL}';" 2>/dev/null)
if [ -z "$USER_RECORD" ]; then
die "No user found with email: ${EMAIL}"
fi
# Parse user info for display
IFS='|' read -r USER_ID USER_EMAIL FIRST_NAME LAST_NAME IS_SUPER <<< "$USER_RECORD"
info "Found user: ${FIRST_NAME} ${LAST_NAME} (${USER_EMAIL})"
if [ "$IS_SUPER" = "t" ]; then
warn "This is a superadmin account"
fi
# Generate bcrypt hash using bcryptjs inside the backend container
info "Generating bcrypt hash ..."
HASH=$($COMPOSE_CMD exec -T backend node -e "
const bcrypt = require('bcryptjs');
bcrypt.hash(process.argv[1], 12).then(h => process.stdout.write(h));
" "$NEW_PASSWORD" 2>/dev/null)
if [ -z "$HASH" ] || [ ${#HASH} -lt 50 ]; then
die "Failed to generate bcrypt hash. Is the backend container running?"
fi
# Update the password using a heredoc to avoid shell escaping issues with $ in hashes
info "Updating password ..."
UPDATE_RESULT=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -t -A <<EOSQL
UPDATE shared.users SET password_hash = '${HASH}', updated_at = NOW() WHERE email = '${EMAIL}';
EOSQL
)
if [[ "$UPDATE_RESULT" != *"UPDATE 1"* ]]; then
die "Password update failed. Result: ${UPDATE_RESULT}"
fi
# Verify the new hash works
info "Verifying new password ..."
VERIFY=$($COMPOSE_CMD exec -T backend node -e "
const bcrypt = require('bcryptjs');
bcrypt.compare(process.argv[1], process.argv[2]).then(r => process.stdout.write(String(r)));
" "$NEW_PASSWORD" "$HASH" 2>/dev/null)
if [ "$VERIFY" != "true" ]; then
die "Verification failed — the hash does not match the password. Something went wrong."
fi
echo ""
ok "Password reset successful!"
echo ""
info " User: ${FIRST_NAME} ${LAST_NAME} (${USER_EMAIL})"
info " Login: ${EMAIL}"
echo ""