9 Commits

Author SHA1 Message Date
9d137a40d3 fix: enforce read-only restrictions for viewer role across 5 pages
Audit and fix viewer (read-only) user permissions:
- Dashboard: hide health score refresh buttons
- Accounts: hide investment edit icons
- Invoices: hide Apply Late Fees and Generate Invoices buttons
- Capital Planning: disable drag-and-drop, hide grip handles and edit buttons
- Investment Planning: hide AI Recommendations refresh button

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ async function bootstrap() {
const config = new DocumentBuilder()
.setTitle('HOA LedgerIQ API')
.setDescription('API for the HOA LedgerIQ')
.setVersion('2026.3.2')
.setVersion('2026.3.7')
.addBearerAuth()
.build();
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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
@@ -9,6 +9,8 @@ import { HealthScoresService } from './health-scores.service';
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class HealthScoresController {
private readonly logger = new Logger(HealthScoresController.name);
constructor(private service: HealthScoresService) {}
@Get('latest')
@@ -19,32 +21,56 @@ export class HealthScoresController {
}
@Post('calculate')
@ApiOperation({ summary: 'Trigger both health score recalculations (used by scheduler)' })
@ApiOperation({ summary: 'Trigger both health score recalculations (async — returns immediately)' })
@AllowViewer()
async calculate(@Req() req: any) {
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, 'reserve'),
]);
return { operating, reserve };
]).catch((err) => {
this.logger.error(`Background health score calculation failed: ${err.message}`);
});
return {
status: 'processing',
message: 'Health score calculations started. Results will appear when ready.',
};
}
@Post('calculate/operating')
@ApiOperation({ summary: 'Recalculate operating fund health score only' })
@ApiOperation({ summary: 'Trigger operating fund health score recalculation (async)' })
@AllowViewer()
async calculateOperating(@Req() req: any) {
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')
@ApiOperation({ summary: 'Recalculate reserve fund health score only' })
@ApiOperation({ summary: 'Trigger reserve fund health score recalculation (async)' })
@AllowViewer()
async calculateReserve(@Req() req: any) {
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-Length': Buffer.byteLength(bodyString, 'utf-8'),
},
timeout: 120000,
timeout: 600000, // 10 minute timeout
};
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('timeout', () => {
req.destroy();
reject(new Error('Request timed out after 120s'));
reject(new Error('Request timed out after 600s'));
});
req.write(bodyString);

View File

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

View File

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

View File

@@ -153,6 +153,14 @@ export class OrganizationsService {
existing.role = data.role;
return this.userOrgRepository.save(existing);
}
// Update password for existing user being added to a new org
if (data.password) {
const passwordHash = await bcrypt.hash(data.password, 12);
await dataSource.query(
`UPDATE shared.users SET password_hash = $1 WHERE id = $2`,
[passwordHash, userId],
);
}
} else {
// Create new user
const passwordHash = await bcrypt.hash(data.password, 12);

View File

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

View File

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

View File

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

View File

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

View File

@@ -587,7 +587,7 @@ export function AccountsPage() {
{investments.filter(i => i.is_active).length > 0 && (
<>
<Divider label="Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={investments.filter(i => i.is_active)} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -605,7 +605,7 @@ export function AccountsPage() {
{operatingInvestments.length > 0 && (
<>
<Divider label="Operating Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={operatingInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -623,7 +623,7 @@ export function AccountsPage() {
{reserveInvestments.length > 0 && (
<>
<Divider label="Reserve Investment Accounts" labelPosition="center" my="xs" />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} />
<InvestmentMiniTable investments={reserveInvestments} onEdit={handleEditInvestment} isReadOnly={isReadOnly} />
</>
)}
</Stack>
@@ -1087,9 +1087,11 @@ function AccountTable({
function InvestmentMiniTable({
investments,
onEdit,
isReadOnly = false,
}: {
investments: Investment[];
onEdit: (inv: Investment) => void;
isReadOnly?: boolean;
}) {
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
const totalValue = investments.reduce(
@@ -1132,7 +1134,7 @@ function InvestmentMiniTable({
<Table.Th ta="right">Maturity Value</Table.Th>
<Table.Th>Maturity Date</Table.Th>
<Table.Th ta="right">Days Remaining</Table.Th>
<Table.Th></Table.Th>
{!isReadOnly && <Table.Th></Table.Th>}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -1182,13 +1184,15 @@ function InvestmentMiniTable({
'-'
)}
</Table.Td>
<Table.Td>
<Tooltip label="Edit investment">
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
{!isReadOnly && (
<Table.Td>
<Tooltip label="Edit investment">
<ActionIcon variant="subtle" onClick={() => onEdit(inv)}>
<IconEdit size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>

View File

@@ -72,9 +72,10 @@ interface KanbanCardProps {
project: Project;
onEdit: (p: Project) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
isReadOnly?: boolean;
}
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
function KanbanCard({ project, onEdit, onDragStart, isReadOnly }: KanbanCardProps) {
const plannedLabel = formatPlannedDate(project.planned_date);
// For projects in the Future bucket with a specific year, show the year
const currentYear = new Date().getFullYear();
@@ -86,21 +87,23 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
padding="sm"
radius="md"
withBorder
draggable
onDragStart={(e) => onDragStart(e, project)}
style={{ cursor: 'grab', userSelect: 'none' }}
draggable={!isReadOnly}
onDragStart={!isReadOnly ? (e) => onDragStart(e, project) : undefined}
style={{ cursor: isReadOnly ? 'default' : 'grab', userSelect: 'none' }}
mb="xs"
>
<Group justify="space-between" wrap="nowrap" mb={4}>
<Group gap={6} wrap="nowrap" style={{ overflow: 'hidden' }}>
<IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />
{!isReadOnly && <IconGripVertical size={14} style={{ flexShrink: 0, color: 'var(--mantine-color-dimmed)' }} />}
<Text fw={600} size="sm" truncate>
{project.name}
</Text>
</Group>
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
<IconEdit size={14} />
</ActionIcon>
{!isReadOnly && (
<ActionIcon variant="subtle" size="sm" onClick={() => onEdit(project)}>
<IconEdit size={14} />
</ActionIcon>
)}
</Group>
<Group gap={6} mb={6}>
@@ -148,11 +151,12 @@ interface KanbanColumnProps {
isDragOver: boolean;
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
onDragLeave: () => void;
isReadOnly?: boolean;
}
function KanbanColumn({
year, projects, onEdit, onDragStart, onDrop,
isDragOver, onDragOverHandler, onDragLeave,
isDragOver, onDragOverHandler, onDragLeave, isReadOnly,
}: KanbanColumnProps) {
const totalEst = projects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
const isFuture = year === FUTURE_YEAR;
@@ -178,9 +182,9 @@ function KanbanColumn({
border: isDragOver ? '2px dashed var(--mantine-color-blue-4)' : undefined,
transition: 'background-color 150ms ease, border 150ms ease',
}}
onDragOver={(e) => onDragOverHandler(e, year)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, year)}
onDragOver={!isReadOnly ? (e) => onDragOverHandler(e, year) : undefined}
onDragLeave={!isReadOnly ? onDragLeave : undefined}
onDrop={!isReadOnly ? (e) => onDrop(e, year) : undefined}
>
<Group justify="space-between" mb="sm">
<Title order={5}>{yearLabel(year)}</Title>
@@ -199,7 +203,7 @@ function KanbanColumn({
<Box style={{ flex: 1, minHeight: 60 }}>
{projects.length === 0 ? (
<Text size="xs" c="dimmed" ta="center" py="lg">
Drop projects here
{isReadOnly ? 'No projects' : 'Drop projects here'}
</Text>
) : useWideLayout ? (
<div style={{
@@ -208,12 +212,12 @@ function KanbanColumn({
gap: 'var(--mantine-spacing-xs)',
}}>
{projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
))}
</div>
) : (
projects.map((p) => (
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} />
<KanbanCard key={p.id} project={p} onEdit={onEdit} onDragStart={onDragStart} isReadOnly={isReadOnly} />
))
)}
</Box>
@@ -595,6 +599,7 @@ export function CapitalProjectsPage() {
isDragOver={dragOverYear === year}
onDragOverHandler={handleDragOver}
onDragLeave={handleDragLeave}
isReadOnly={isReadOnly}
/>
);
})}

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import { notifications } from '@mantine/notifications';
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
interface Invoice {
id: string; invoice_number: string; unit_number: string; unit_id: string;
@@ -64,6 +65,7 @@ export function InvoicesPage() {
const [preview, setPreview] = useState<Preview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'],
@@ -124,10 +126,12 @@ export function InvoicesPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Invoices</Title>
<Group>
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
</Group>
{!isReadOnly && (
<Group>
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Invoices</Button>
</Group>
)}
</Group>
<Group>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>

View File

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

View File

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

View File

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

View File

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

View File

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