Compare commits
12 Commits
e0c956859b
...
d9bb9363dd
| Author | SHA1 | Date | |
|---|---|---|---|
| d9bb9363dd | |||
| e156cf7c87 | |||
| 76ab63a200 | |||
| a32d4cc179 | |||
| 0bd30a0eb8 | |||
| 0626b8d496 | |||
| 25663fc79e | |||
| fe4989bbcc | |||
| 36271585d9 | |||
| 18c7989983 | |||
| c28d7aeffc | |||
| f7e9c98bd9 |
@@ -5,3 +5,10 @@ DATABASE_URL=postgresql://hoafinance:change_me@postgres:5432/hoafinance
|
|||||||
REDIS_URL=redis://redis:6379
|
REDIS_URL=redis://redis:6379
|
||||||
JWT_SECRET=change_me_to_random_string
|
JWT_SECRET=change_me_to_random_string
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# AI Investment Advisor (OpenAI-compatible API)
|
||||||
|
AI_API_URL=https://integrate.api.nvidia.com/v1
|
||||||
|
AI_API_KEY=your_nvidia_api_key_here
|
||||||
|
AI_MODEL=qwen/qwen3.5-397b-a17b
|
||||||
|
# Set to 'true' to enable detailed AI prompt/response logging
|
||||||
|
AI_DEBUG=false
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { AssessmentGroupsModule } from './modules/assessment-groups/assessment-g
|
|||||||
import { ProjectsModule } from './modules/projects/projects.module';
|
import { ProjectsModule } from './modules/projects/projects.module';
|
||||||
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
|
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
|
||||||
import { AttachmentsModule } from './modules/attachments/attachments.module';
|
import { AttachmentsModule } from './modules/attachments/attachments.module';
|
||||||
|
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -60,6 +61,7 @@ import { AttachmentsModule } from './modules/attachments/attachments.module';
|
|||||||
ProjectsModule,
|
ProjectsModule,
|
||||||
MonthlyActualsModule,
|
MonthlyActualsModule,
|
||||||
AttachmentsModule,
|
AttachmentsModule,
|
||||||
|
InvestmentPlanningModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import * as jwt from 'jsonwebtoken';
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
@@ -12,9 +13,16 @@ export interface TenantRequest extends Request {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenantMiddleware implements NestMiddleware {
|
export class TenantMiddleware implements NestMiddleware {
|
||||||
constructor(private configService: ConfigService) {}
|
// In-memory cache for org status to avoid DB hit per request
|
||||||
|
private orgStatusCache = new Map<string, { status: string; cachedAt: number }>();
|
||||||
|
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
||||||
|
|
||||||
use(req: TenantRequest, _res: Response, next: NextFunction) {
|
constructor(
|
||||||
|
private configService: ConfigService,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async use(req: TenantRequest, res: Response, next: NextFunction) {
|
||||||
// Try to extract tenant info from Authorization header JWT
|
// Try to extract tenant info from Authorization header JWT
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
@@ -23,6 +31,18 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
const secret = this.configService.get<string>('JWT_SECRET');
|
const secret = this.configService.get<string>('JWT_SECRET');
|
||||||
const decoded = jwt.verify(token, secret!) as any;
|
const decoded = jwt.verify(token, secret!) as any;
|
||||||
if (decoded?.orgSchema) {
|
if (decoded?.orgSchema) {
|
||||||
|
// Check if the org is still active (catches post-JWT suspension)
|
||||||
|
if (decoded.orgId) {
|
||||||
|
const status = await this.getOrgStatus(decoded.orgId);
|
||||||
|
if (status && ['suspended', 'archived'].includes(status)) {
|
||||||
|
res.status(403).json({
|
||||||
|
statusCode: 403,
|
||||||
|
message: `This organization has been ${status}. Please contact your administrator.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
req.tenantSchema = decoded.orgSchema;
|
req.tenantSchema = decoded.orgSchema;
|
||||||
req.orgId = decoded.orgId;
|
req.orgId = decoded.orgId;
|
||||||
req.userId = decoded.sub;
|
req.userId = decoded.sub;
|
||||||
@@ -34,4 +54,24 @@ export class TenantMiddleware implements NestMiddleware {
|
|||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getOrgStatus(orgId: string): Promise<string | null> {
|
||||||
|
const cached = this.orgStatusCache.get(orgId);
|
||||||
|
if (cached && Date.now() - cached.cachedAt < TenantMiddleware.CACHE_TTL) {
|
||||||
|
return cached.status;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await this.dataSource.query(
|
||||||
|
`SELECT status FROM shared.organizations WHERE id = $1`,
|
||||||
|
[orgId],
|
||||||
|
);
|
||||||
|
if (result.length > 0) {
|
||||||
|
this.orgStatusCache.set(orgId, { status: result[0].status, cachedAt: Date.now() });
|
||||||
|
return result[0].status;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical — don't block requests on cache miss errors
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
325
backend/src/modules/auth/admin-analytics.service.ts
Normal file
325
backend/src/modules/auth/admin-analytics.service.ts
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminAnalyticsService {
|
||||||
|
private readonly logger = new Logger(AdminAnalyticsService.name);
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-wide metrics for the admin dashboard.
|
||||||
|
*/
|
||||||
|
async getPlatformMetrics() {
|
||||||
|
const [
|
||||||
|
userStats,
|
||||||
|
orgStats,
|
||||||
|
planBreakdown,
|
||||||
|
statusBreakdown,
|
||||||
|
newTenantsPerMonth,
|
||||||
|
newUsersPerMonth,
|
||||||
|
aiStats,
|
||||||
|
activeUsers30d,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_users,
|
||||||
|
COUNT(*) FILTER (WHERE is_superadmin = true) as superadmin_count,
|
||||||
|
COUNT(*) FILTER (WHERE is_platform_owner = true) as platform_owner_count
|
||||||
|
FROM shared.users
|
||||||
|
`),
|
||||||
|
this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_organizations,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'active') as active_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'archived') as archived_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'suspended') as suspended_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'trial') as trial_count
|
||||||
|
FROM shared.organizations
|
||||||
|
`),
|
||||||
|
this.dataSource.query(`
|
||||||
|
SELECT plan_level, COUNT(*) as count
|
||||||
|
FROM shared.organizations
|
||||||
|
WHERE status != 'archived'
|
||||||
|
GROUP BY plan_level
|
||||||
|
ORDER BY count DESC
|
||||||
|
`),
|
||||||
|
this.dataSource.query(`
|
||||||
|
SELECT status, COUNT(*) as count
|
||||||
|
FROM shared.organizations
|
||||||
|
GROUP BY status
|
||||||
|
ORDER BY count DESC
|
||||||
|
`),
|
||||||
|
this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('month', created_at) as month,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM shared.organizations
|
||||||
|
WHERE created_at > NOW() - INTERVAL '6 months'
|
||||||
|
GROUP BY DATE_TRUNC('month', created_at)
|
||||||
|
ORDER BY month DESC
|
||||||
|
`),
|
||||||
|
this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
DATE_TRUNC('month', created_at) as month,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM shared.users
|
||||||
|
WHERE created_at > NOW() - INTERVAL '6 months'
|
||||||
|
GROUP BY DATE_TRUNC('month', created_at)
|
||||||
|
ORDER BY month DESC
|
||||||
|
`),
|
||||||
|
this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_requests,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'success') as successful,
|
||||||
|
ROUND(AVG(response_time_ms)) as avg_response_ms
|
||||||
|
FROM shared.ai_recommendation_log
|
||||||
|
WHERE requested_at > NOW() - INTERVAL '30 days'
|
||||||
|
`),
|
||||||
|
this.dataSource.query(`
|
||||||
|
SELECT COUNT(DISTINCT user_id) as count
|
||||||
|
FROM shared.login_history
|
||||||
|
WHERE logged_in_at > NOW() - INTERVAL '30 days'
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers: parseInt(userStats[0]?.total_users || '0'),
|
||||||
|
superadminCount: parseInt(userStats[0]?.superadmin_count || '0'),
|
||||||
|
platformOwnerCount: parseInt(userStats[0]?.platform_owner_count || '0'),
|
||||||
|
activeUsers30d: parseInt(activeUsers30d[0]?.count || '0'),
|
||||||
|
totalOrganizations: parseInt(orgStats[0]?.total_organizations || '0'),
|
||||||
|
activeOrganizations: parseInt(orgStats[0]?.active_count || '0'),
|
||||||
|
archivedOrganizations: parseInt(orgStats[0]?.archived_count || '0'),
|
||||||
|
suspendedOrganizations: parseInt(orgStats[0]?.suspended_count || '0'),
|
||||||
|
trialOrganizations: parseInt(orgStats[0]?.trial_count || '0'),
|
||||||
|
planBreakdown: planBreakdown.map((r: any) => ({
|
||||||
|
plan: r.plan_level,
|
||||||
|
count: parseInt(r.count),
|
||||||
|
})),
|
||||||
|
statusBreakdown: statusBreakdown.map((r: any) => ({
|
||||||
|
status: r.status,
|
||||||
|
count: parseInt(r.count),
|
||||||
|
})),
|
||||||
|
newTenantsPerMonth: newTenantsPerMonth.map((r: any) => ({
|
||||||
|
month: r.month,
|
||||||
|
count: parseInt(r.count),
|
||||||
|
})),
|
||||||
|
newUsersPerMonth: newUsersPerMonth.map((r: any) => ({
|
||||||
|
month: r.month,
|
||||||
|
count: parseInt(r.count),
|
||||||
|
})),
|
||||||
|
aiRequestsLast30d: parseInt(aiStats[0]?.total_requests || '0'),
|
||||||
|
aiSuccessfulLast30d: parseInt(aiStats[0]?.successful || '0'),
|
||||||
|
aiAvgResponseMs: parseInt(aiStats[0]?.avg_response_ms || '0'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detailed analytics for a specific tenant/organization.
|
||||||
|
*/
|
||||||
|
async getTenantDetail(orgId: string) {
|
||||||
|
const [orgInfo, loginStats, weeklyLogins, monthlyLogins, aiCount, memberCount] = await Promise.all([
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT o.*, (SELECT MAX(logged_in_at) FROM shared.login_history WHERE organization_id = o.id) as last_login
|
||||||
|
FROM shared.organizations o WHERE o.id = $1`,
|
||||||
|
[orgId],
|
||||||
|
),
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT
|
||||||
|
COUNT(*) FILTER (WHERE logged_in_at > NOW() - INTERVAL '7 days') as logins_this_week,
|
||||||
|
COUNT(*) FILTER (WHERE logged_in_at > NOW() - INTERVAL '30 days') as logins_this_month,
|
||||||
|
COUNT(DISTINCT user_id) FILTER (WHERE logged_in_at > NOW() - INTERVAL '30 days') as active_users_30d
|
||||||
|
FROM shared.login_history WHERE organization_id = $1`,
|
||||||
|
[orgId],
|
||||||
|
),
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT
|
||||||
|
DATE_TRUNC('week', logged_in_at) as week,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM shared.login_history
|
||||||
|
WHERE organization_id = $1 AND logged_in_at > NOW() - INTERVAL '4 weeks'
|
||||||
|
GROUP BY DATE_TRUNC('week', logged_in_at)
|
||||||
|
ORDER BY week DESC`,
|
||||||
|
[orgId],
|
||||||
|
),
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT
|
||||||
|
DATE_TRUNC('month', logged_in_at) as month,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM shared.login_history
|
||||||
|
WHERE organization_id = $1 AND logged_in_at > NOW() - INTERVAL '6 months'
|
||||||
|
GROUP BY DATE_TRUNC('month', logged_in_at)
|
||||||
|
ORDER BY month DESC`,
|
||||||
|
[orgId],
|
||||||
|
),
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM shared.ai_recommendation_log
|
||||||
|
WHERE organization_id = $1 AND requested_at > NOW() - INTERVAL '30 days'`,
|
||||||
|
[orgId],
|
||||||
|
),
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT COUNT(*) as count FROM shared.user_organizations WHERE organization_id = $1 AND is_active = true`,
|
||||||
|
[orgId],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const org = orgInfo[0];
|
||||||
|
if (!org) return null;
|
||||||
|
|
||||||
|
// Cross-schema queries for tenant financial data
|
||||||
|
let cashOnHand = 0;
|
||||||
|
let hasBudget = false;
|
||||||
|
let recentTransactions = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cashResult = await this.dataSource.query(`
|
||||||
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||||
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
|
FROM "${org.schema_name}".accounts a
|
||||||
|
JOIN "${org.schema_name}".journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN "${org.schema_name}".journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
WHERE a.account_type = 'asset' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
) sub
|
||||||
|
`);
|
||||||
|
cashOnHand = parseFloat(cashResult[0]?.total || '0');
|
||||||
|
|
||||||
|
const budgetResult = await this.dataSource.query(
|
||||||
|
`SELECT COUNT(*) as count FROM "${org.schema_name}".budgets WHERE fiscal_year = $1`,
|
||||||
|
[new Date().getFullYear()],
|
||||||
|
);
|
||||||
|
hasBudget = parseInt(budgetResult[0]?.count || '0') > 0;
|
||||||
|
|
||||||
|
const txnResult = await this.dataSource.query(`
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM "${org.schema_name}".journal_entries
|
||||||
|
WHERE is_posted = true AND entry_date > NOW() - INTERVAL '30 days'
|
||||||
|
`);
|
||||||
|
recentTransactions = parseInt(txnResult[0]?.count || '0');
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to query tenant schema ${org.schema_name}: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
organization: org,
|
||||||
|
lastLogin: org.last_login,
|
||||||
|
loginsThisWeek: parseInt(loginStats[0]?.logins_this_week || '0'),
|
||||||
|
loginsThisMonth: parseInt(loginStats[0]?.logins_this_month || '0'),
|
||||||
|
activeUsers30d: parseInt(loginStats[0]?.active_users_30d || '0'),
|
||||||
|
weeklyLogins: weeklyLogins.map((r: any) => ({
|
||||||
|
week: r.week,
|
||||||
|
count: parseInt(r.count),
|
||||||
|
})),
|
||||||
|
monthlyLogins: monthlyLogins.map((r: any) => ({
|
||||||
|
month: r.month,
|
||||||
|
count: parseInt(r.count),
|
||||||
|
})),
|
||||||
|
aiRecommendations30d: parseInt(aiCount[0]?.count || '0'),
|
||||||
|
memberCount: parseInt(memberCount[0]?.count || '0'),
|
||||||
|
cashOnHand,
|
||||||
|
hasBudget,
|
||||||
|
recentTransactions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All tenants with health scores for the Health & Adoption tab.
|
||||||
|
*
|
||||||
|
* Health Score (0-100):
|
||||||
|
* Active users 30d ≥ 1 → 25pts
|
||||||
|
* Has current-year budget → 25pts
|
||||||
|
* Journal entries 30d ≥ 1 → 25pts
|
||||||
|
* 2+ active members → 15pts
|
||||||
|
* AI usage 30d ≥ 1 → 10pts
|
||||||
|
*/
|
||||||
|
async getAllTenantsHealth() {
|
||||||
|
const orgs = await this.dataSource.query(`
|
||||||
|
SELECT
|
||||||
|
o.id, o.name, o.schema_name, o.status, o.plan_level, o.created_at,
|
||||||
|
o.payment_date, o.renewal_date,
|
||||||
|
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id AND is_active = true) as member_count,
|
||||||
|
(SELECT MAX(lh.logged_in_at) FROM shared.login_history lh WHERE lh.organization_id = o.id) as last_login,
|
||||||
|
(SELECT COUNT(DISTINCT lh.user_id) FROM shared.login_history lh WHERE lh.organization_id = o.id AND lh.logged_in_at > NOW() - INTERVAL '30 days') as active_users_30d,
|
||||||
|
(SELECT COUNT(*) FROM shared.ai_recommendation_log ar WHERE ar.organization_id = o.id AND ar.requested_at > NOW() - INTERVAL '30 days') as ai_usage_30d
|
||||||
|
FROM shared.organizations o
|
||||||
|
WHERE o.status != 'archived'
|
||||||
|
ORDER BY o.name
|
||||||
|
`);
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const org of orgs) {
|
||||||
|
let cashOnHand = 0;
|
||||||
|
let hasBudget = false;
|
||||||
|
let journalEntries30d = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cashResult = await this.dataSource.query(`
|
||||||
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||||
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
|
FROM "${org.schema_name}".accounts a
|
||||||
|
JOIN "${org.schema_name}".journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN "${org.schema_name}".journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
WHERE a.account_type = 'asset' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
) sub
|
||||||
|
`);
|
||||||
|
cashOnHand = parseFloat(cashResult[0]?.total || '0');
|
||||||
|
|
||||||
|
const budgetResult = await this.dataSource.query(
|
||||||
|
`SELECT COUNT(*) as count FROM "${org.schema_name}".budgets WHERE fiscal_year = $1`,
|
||||||
|
[currentYear],
|
||||||
|
);
|
||||||
|
hasBudget = parseInt(budgetResult[0]?.count || '0') > 0;
|
||||||
|
|
||||||
|
const jeResult = await this.dataSource.query(`
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM "${org.schema_name}".journal_entries
|
||||||
|
WHERE is_posted = true AND entry_date > NOW() - INTERVAL '30 days'
|
||||||
|
`);
|
||||||
|
journalEntries30d = parseInt(jeResult[0]?.count || '0');
|
||||||
|
} catch (err) {
|
||||||
|
// Schema may not exist yet (new tenant)
|
||||||
|
this.logger.warn(`Health check skip for ${org.schema_name}: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate health score
|
||||||
|
const activeUsers = parseInt(org.active_users_30d) || 0;
|
||||||
|
const memberCount = parseInt(org.member_count) || 0;
|
||||||
|
const aiUsage = parseInt(org.ai_usage_30d) || 0;
|
||||||
|
|
||||||
|
let healthScore = 0;
|
||||||
|
if (activeUsers >= 1) healthScore += 25;
|
||||||
|
if (hasBudget) healthScore += 25;
|
||||||
|
if (journalEntries30d >= 1) healthScore += 25;
|
||||||
|
if (memberCount >= 2) healthScore += 15;
|
||||||
|
if (aiUsage >= 1) healthScore += 10;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
id: org.id,
|
||||||
|
name: org.name,
|
||||||
|
schemaName: org.schema_name,
|
||||||
|
status: org.status,
|
||||||
|
planLevel: org.plan_level,
|
||||||
|
createdAt: org.created_at,
|
||||||
|
paymentDate: org.payment_date,
|
||||||
|
renewalDate: org.renewal_date,
|
||||||
|
memberCount,
|
||||||
|
lastLogin: org.last_login,
|
||||||
|
activeUsers30d: activeUsers,
|
||||||
|
aiUsage30d: aiUsage,
|
||||||
|
cashOnHand,
|
||||||
|
hasBudget,
|
||||||
|
journalEntries30d,
|
||||||
|
healthScore,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards, Req, ForbiddenException, BadRequestException } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards, Req, ForbiddenException, BadRequestException } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { OrganizationsService } from '../organizations/organizations.service';
|
import { OrganizationsService } from '../organizations/organizations.service';
|
||||||
|
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
@ApiTags('admin')
|
@ApiTags('admin')
|
||||||
@@ -11,8 +13,10 @@ import * as bcrypt from 'bcryptjs';
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
constructor(
|
constructor(
|
||||||
|
private authService: AuthService,
|
||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private orgService: OrganizationsService,
|
private orgService: OrganizationsService,
|
||||||
|
private analyticsService: AdminAnalyticsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async requireSuperadmin(req: any) {
|
private async requireSuperadmin(req: any) {
|
||||||
@@ -22,25 +26,93 @@ export class AdminController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Platform Metrics ──
|
||||||
|
|
||||||
|
@Get('metrics')
|
||||||
|
async getPlatformMetrics(@Req() req: any) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
return this.analyticsService.getPlatformMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Users ──
|
||||||
|
|
||||||
@Get('users')
|
@Get('users')
|
||||||
async listUsers(@Req() req: any) {
|
async listUsers(@Req() req: any) {
|
||||||
await this.requireSuperadmin(req);
|
await this.requireSuperadmin(req);
|
||||||
const users = await this.usersService.findAllUsers();
|
const users = await this.usersService.findAllUsers();
|
||||||
return users.map(u => ({
|
return users.map(u => ({
|
||||||
id: u.id, email: u.email, firstName: u.firstName, lastName: u.lastName,
|
id: u.id, email: u.email, firstName: u.firstName, lastName: u.lastName,
|
||||||
isSuperadmin: u.isSuperadmin, lastLoginAt: u.lastLoginAt, createdAt: u.createdAt,
|
isSuperadmin: u.isSuperadmin, isPlatformOwner: u.isPlatformOwner || false,
|
||||||
|
lastLoginAt: u.lastLoginAt, createdAt: u.createdAt,
|
||||||
organizations: u.userOrganizations?.map(uo => ({
|
organizations: u.userOrganizations?.map(uo => ({
|
||||||
id: uo.organizationId, name: uo.organization?.name, role: uo.role,
|
id: uo.organizationId, name: uo.organization?.name, role: uo.role,
|
||||||
})) || [],
|
})) || [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Organizations ──
|
||||||
|
|
||||||
@Get('organizations')
|
@Get('organizations')
|
||||||
async listOrganizations(@Req() req: any) {
|
async listOrganizations(@Req() req: any) {
|
||||||
await this.requireSuperadmin(req);
|
await this.requireSuperadmin(req);
|
||||||
return this.usersService.findAllOrganizations();
|
return this.usersService.findAllOrganizations();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('organizations/:id/detail')
|
||||||
|
async getTenantDetail(@Req() req: any, @Param('id') id: string) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const detail = await this.analyticsService.getTenantDetail(id);
|
||||||
|
if (!detail) {
|
||||||
|
throw new BadRequestException('Organization not found');
|
||||||
|
}
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('organizations/:id/subscription')
|
||||||
|
async updateSubscription(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { paymentDate?: string; confirmationNumber?: string; renewalDate?: string },
|
||||||
|
) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const org = await this.orgService.updateSubscription(id, body);
|
||||||
|
return { success: true, organization: org };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('organizations/:id/status')
|
||||||
|
async updateOrgStatus(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { status: string },
|
||||||
|
) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const validStatuses = ['active', 'suspended', 'trial', 'archived'];
|
||||||
|
if (!validStatuses.includes(body.status)) {
|
||||||
|
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
||||||
|
}
|
||||||
|
const org = await this.orgService.updateStatus(id, body.status);
|
||||||
|
return { success: true, organization: org };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plan Level ──
|
||||||
|
|
||||||
|
@Put('organizations/:id/plan')
|
||||||
|
async updateOrgPlan(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { planLevel: string },
|
||||||
|
) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const validPlans = ['standard', 'premium', 'enterprise'];
|
||||||
|
if (!validPlans.includes(body.planLevel)) {
|
||||||
|
throw new BadRequestException(`Invalid plan. Must be one of: ${validPlans.join(', ')}`);
|
||||||
|
}
|
||||||
|
const org = await this.orgService.updatePlanLevel(id, body.planLevel);
|
||||||
|
return { success: true, organization: org };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Superadmin Toggle ──
|
||||||
|
|
||||||
@Post('users/:id/superadmin')
|
@Post('users/:id/superadmin')
|
||||||
async toggleSuperadmin(@Req() req: any, @Param('id') id: string, @Body() body: { isSuperadmin: boolean }) {
|
async toggleSuperadmin(@Req() req: any, @Param('id') id: string, @Body() body: { isSuperadmin: boolean }) {
|
||||||
await this.requireSuperadmin(req);
|
await this.requireSuperadmin(req);
|
||||||
@@ -48,6 +120,25 @@ export class AdminController {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── User Impersonation ──
|
||||||
|
|
||||||
|
@Post('impersonate/:userId')
|
||||||
|
async impersonateUser(@Req() req: any, @Param('userId') userId: string) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const adminUserId = req.user.userId || req.user.sub;
|
||||||
|
return this.authService.impersonateUser(adminUserId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tenant Health ──
|
||||||
|
|
||||||
|
@Get('tenants-health')
|
||||||
|
async getTenantsHealth(@Req() req: any) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
return this.analyticsService.getAllTenantsHealth();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create Tenant ──
|
||||||
|
|
||||||
@Post('tenants')
|
@Post('tenants')
|
||||||
async createTenant(@Req() req: any, @Body() body: {
|
async createTenant(@Req() req: any, @Body() body: {
|
||||||
orgName: string;
|
orgName: string;
|
||||||
@@ -105,19 +196,4 @@ export class AdminController {
|
|||||||
|
|
||||||
return { success: true, organization: org };
|
return { success: true, organization: org };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('organizations/:id/status')
|
|
||||||
async updateOrgStatus(
|
|
||||||
@Req() req: any,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() body: { status: string },
|
|
||||||
) {
|
|
||||||
await this.requireSuperadmin(req);
|
|
||||||
const validStatuses = ['active', 'suspended', 'trial', 'archived'];
|
|
||||||
if (!validStatuses.includes(body.status)) {
|
|
||||||
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
|
||||||
}
|
|
||||||
const org = await this.orgService.updateStatus(id, body.status);
|
|
||||||
return { success: true, organization: org };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: 'Login with email and password' })
|
@ApiOperation({ summary: 'Login with email and password' })
|
||||||
@UseGuards(AuthGuard('local'))
|
@UseGuards(AuthGuard('local'))
|
||||||
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
async login(@Request() req: any, @Body() _dto: LoginDto) {
|
||||||
return this.authService.login(req.user);
|
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||||
|
const ua = req.headers['user-agent'];
|
||||||
|
return this.authService.login(req.user, ip, ua);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('profile')
|
@Get('profile')
|
||||||
@@ -45,6 +47,8 @@ export class AuthController {
|
|||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
async switchOrg(@Request() req: any, @Body() dto: SwitchOrgDto) {
|
||||||
return this.authService.switchOrganization(req.user.sub, dto.organizationId);
|
const ip = req.headers['x-forwarded-for'] || req.ip;
|
||||||
|
const ua = req.headers['user-agent'];
|
||||||
|
return this.authService.switchOrganization(req.user.sub, dto.organizationId, ip, ua);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
|||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { LocalStrategy } from './strategies/local.strategy';
|
import { LocalStrategy } from './strategies/local.strategy';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
@@ -25,7 +26,7 @@ import { OrganizationsModule } from '../organizations/organizations.module';
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController, AdminController],
|
controllers: [AuthController, AdminController],
|
||||||
providers: [AuthService, JwtStrategy, LocalStrategy],
|
providers: [AuthService, AdminAnalyticsService, JwtStrategy, LocalStrategy],
|
||||||
exports: [AuthService],
|
exports: [AuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
@@ -14,6 +17,7 @@ export class AuthService {
|
|||||||
constructor(
|
constructor(
|
||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private jwtService: JwtService,
|
private jwtService: JwtService,
|
||||||
|
private dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(dto: RegisterDto) {
|
async register(dto: RegisterDto) {
|
||||||
@@ -47,7 +51,7 @@ export class AuthService {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(user: User) {
|
async login(user: User, ipAddress?: string, userAgent?: string) {
|
||||||
await this.usersService.updateLastLogin(user.id);
|
await this.usersService.updateLastLogin(user.id);
|
||||||
const fullUser = await this.usersService.findByIdWithOrgs(user.id);
|
const fullUser = await this.usersService.findByIdWithOrgs(user.id);
|
||||||
const u = fullUser || user;
|
const u = fullUser || user;
|
||||||
@@ -65,6 +69,9 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record login in history (org_id is null at initial login)
|
||||||
|
this.recordLoginHistory(user.id, null, ipAddress, userAgent).catch(() => {});
|
||||||
|
|
||||||
return this.generateTokenResponse(u);
|
return this.generateTokenResponse(u);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +93,7 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async switchOrganization(userId: string, organizationId: string) {
|
async switchOrganization(userId: string, organizationId: string, ipAddress?: string, userAgent?: string) {
|
||||||
const user = await this.usersService.findByIdWithOrgs(userId);
|
const user = await this.usersService.findByIdWithOrgs(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('User not found');
|
throw new UnauthorizedException('User not found');
|
||||||
@@ -99,6 +106,14 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Not a member of this organization');
|
throw new UnauthorizedException('Not a member of this organization');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block access to suspended/archived organizations
|
||||||
|
const orgStatus = membership.organization?.status;
|
||||||
|
if (orgStatus && ['suspended', 'archived'].includes(orgStatus)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`This organization has been ${orgStatus}. Please contact your administrator.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -107,6 +122,9 @@ export class AuthService {
|
|||||||
role: membership.role,
|
role: membership.role,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Record org switch in login history
|
||||||
|
this.recordLoginHistory(userId, organizationId, ipAddress, userAgent).catch(() => {});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: this.jwtService.sign(payload),
|
accessToken: this.jwtService.sign(payload),
|
||||||
organization: {
|
organization: {
|
||||||
@@ -117,8 +135,29 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private generateTokenResponse(user: User) {
|
private async recordLoginHistory(
|
||||||
const orgs = user.userOrganizations || [];
|
userId: string,
|
||||||
|
organizationId: string | null,
|
||||||
|
ipAddress?: string,
|
||||||
|
userAgent?: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.login_history (user_id, organization_id, ip_address, user_agent)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[userId, organizationId, ipAddress || null, userAgent || null],
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Non-critical — don't let login history failure block auth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateTokenResponse(user: User, impersonatedBy?: string) {
|
||||||
|
const allOrgs = user.userOrganizations || [];
|
||||||
|
// Filter out suspended/archived organizations
|
||||||
|
const orgs = allOrgs.filter(
|
||||||
|
(uo) => !uo.organization?.status || !['suspended', 'archived'].includes(uo.organization.status),
|
||||||
|
);
|
||||||
const defaultOrg = orgs[0];
|
const defaultOrg = orgs[0];
|
||||||
|
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
@@ -127,6 +166,10 @@ export class AuthService {
|
|||||||
isSuperadmin: user.isSuperadmin || false,
|
isSuperadmin: user.isSuperadmin || false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (impersonatedBy) {
|
||||||
|
payload.impersonatedBy = impersonatedBy;
|
||||||
|
}
|
||||||
|
|
||||||
if (defaultOrg) {
|
if (defaultOrg) {
|
||||||
payload.orgId = defaultOrg.organizationId;
|
payload.orgId = defaultOrg.organizationId;
|
||||||
payload.orgSchema = defaultOrg.organization?.schemaName;
|
payload.orgSchema = defaultOrg.organization?.schemaName;
|
||||||
@@ -141,13 +184,26 @@ export class AuthService {
|
|||||||
firstName: user.firstName,
|
firstName: user.firstName,
|
||||||
lastName: user.lastName,
|
lastName: user.lastName,
|
||||||
isSuperadmin: user.isSuperadmin || false,
|
isSuperadmin: user.isSuperadmin || false,
|
||||||
|
isPlatformOwner: user.isPlatformOwner || false,
|
||||||
},
|
},
|
||||||
organizations: orgs.map((uo) => ({
|
organizations: orgs.map((uo) => ({
|
||||||
id: uo.organizationId,
|
id: uo.organizationId,
|
||||||
name: uo.organization?.name,
|
name: uo.organization?.name,
|
||||||
schemaName: uo.organization?.schemaName,
|
schemaName: uo.organization?.schemaName,
|
||||||
|
status: uo.organization?.status,
|
||||||
role: uo.role,
|
role: uo.role,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async impersonateUser(adminUserId: string, targetUserId: string) {
|
||||||
|
const targetUser = await this.usersService.findByIdWithOrgs(targetUserId);
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
if (targetUser.isSuperadmin) {
|
||||||
|
throw new ForbiddenException('Cannot impersonate another superadmin');
|
||||||
|
}
|
||||||
|
return this.generateTokenResponse(targetUser, adminUserId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
orgSchema: payload.orgSchema,
|
orgSchema: payload.orgSchema,
|
||||||
role: payload.role,
|
role: payload.role,
|
||||||
isSuperadmin: payload.isSuperadmin || false,
|
isSuperadmin: payload.isSuperadmin || false,
|
||||||
|
impersonatedBy: payload.impersonatedBy || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { InvestmentPlanningService } from './investment-planning.service';
|
||||||
|
|
||||||
|
@ApiTags('investment-planning')
|
||||||
|
@Controller('investment-planning')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class InvestmentPlanningController {
|
||||||
|
constructor(private service: InvestmentPlanningService) {}
|
||||||
|
|
||||||
|
@Get('snapshot')
|
||||||
|
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
|
||||||
|
getSnapshot() {
|
||||||
|
return this.service.getFinancialSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('cd-rates')
|
||||||
|
@ApiOperation({ summary: 'Get latest CD rates from market data' })
|
||||||
|
getCdRates() {
|
||||||
|
return this.service.getCdRates();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('recommendations')
|
||||||
|
@ApiOperation({ summary: 'Get AI-powered investment recommendations' })
|
||||||
|
getRecommendations(@Req() req: any) {
|
||||||
|
return this.service.getAIRecommendations(req.user?.sub, req.user?.orgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { InvestmentPlanningController } from './investment-planning.controller';
|
||||||
|
import { InvestmentPlanningService } from './investment-planning.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [InvestmentPlanningController],
|
||||||
|
providers: [InvestmentPlanningService],
|
||||||
|
})
|
||||||
|
export class InvestmentPlanningModule {}
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { TenantService } from '../../database/tenant.service';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
// ── Interfaces ──
|
||||||
|
|
||||||
|
export interface AccountBalance {
|
||||||
|
id: string;
|
||||||
|
account_number: string;
|
||||||
|
name: string;
|
||||||
|
account_type: string;
|
||||||
|
fund_type: string;
|
||||||
|
interest_rate: string | null;
|
||||||
|
balance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvestmentAccount {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
institution: string;
|
||||||
|
investment_type: string;
|
||||||
|
fund_type: string;
|
||||||
|
principal: string;
|
||||||
|
interest_rate: string;
|
||||||
|
maturity_date: string | null;
|
||||||
|
purchase_date: string | null;
|
||||||
|
current_value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CdRate {
|
||||||
|
bank_name: string;
|
||||||
|
apy: string;
|
||||||
|
min_deposit: string | null;
|
||||||
|
term: string;
|
||||||
|
term_months: number | null;
|
||||||
|
fetched_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recommendation {
|
||||||
|
type: 'cd_ladder' | 'new_investment' | 'reallocation' | 'maturity_action' | 'liquidity_warning' | 'general';
|
||||||
|
priority: 'high' | 'medium' | 'low';
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
details: string;
|
||||||
|
fund_type: 'operating' | 'reserve' | 'both';
|
||||||
|
suggested_amount?: number;
|
||||||
|
suggested_term?: string;
|
||||||
|
suggested_rate?: number;
|
||||||
|
bank_name?: string;
|
||||||
|
rationale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIResponse {
|
||||||
|
recommendations: Recommendation[];
|
||||||
|
overall_assessment: string;
|
||||||
|
risk_notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InvestmentPlanningService {
|
||||||
|
private readonly logger = new Logger(InvestmentPlanningService.name);
|
||||||
|
private debugEnabled: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private tenant: TenantService,
|
||||||
|
private configService: ConfigService,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {
|
||||||
|
// Toggle with AI_DEBUG=true in .env for detailed prompt/response logging
|
||||||
|
this.debugEnabled = this.configService.get<string>('AI_DEBUG') === 'true';
|
||||||
|
if (this.debugEnabled) {
|
||||||
|
this.logger.warn('AI DEBUG MODE ENABLED — prompts and responses will be logged');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private debug(label: string, data: any) {
|
||||||
|
if (!this.debugEnabled) return;
|
||||||
|
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
||||||
|
// Truncate very long output to keep logs manageable
|
||||||
|
const truncated = text.length > 5000 ? text.slice(0, 5000) + `\n... [truncated, ${text.length} total chars]` : text;
|
||||||
|
this.logger.log(`[AI_DEBUG] ${label}:\n${truncated}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API Methods ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a comprehensive financial snapshot for the investment planning page.
|
||||||
|
* All financial data is tenant-scoped via TenantService.
|
||||||
|
*/
|
||||||
|
async getFinancialSnapshot() {
|
||||||
|
const [
|
||||||
|
accountBalances,
|
||||||
|
investmentAccounts,
|
||||||
|
budgets,
|
||||||
|
projects,
|
||||||
|
cashFlowContext,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.getAccountBalances(),
|
||||||
|
this.getInvestmentAccounts(),
|
||||||
|
this.getBudgets(),
|
||||||
|
this.getProjects(),
|
||||||
|
this.getCashFlowContext(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Compute summary totals
|
||||||
|
const operatingCash = accountBalances
|
||||||
|
.filter((a) => a.fund_type === 'operating' && a.account_type === 'asset')
|
||||||
|
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
|
||||||
|
|
||||||
|
const reserveCash = accountBalances
|
||||||
|
.filter((a) => a.fund_type === 'reserve' && a.account_type === 'asset')
|
||||||
|
.reduce((sum, a) => sum + parseFloat(a.balance || '0'), 0);
|
||||||
|
|
||||||
|
const operatingInvestments = investmentAccounts
|
||||||
|
.filter((i) => i.fund_type === 'operating')
|
||||||
|
.reduce((sum, i) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||||
|
|
||||||
|
const reserveInvestments = investmentAccounts
|
||||||
|
.filter((i) => i.fund_type === 'reserve')
|
||||||
|
.reduce((sum, i) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
operating_cash: operatingCash,
|
||||||
|
reserve_cash: reserveCash,
|
||||||
|
operating_investments: operatingInvestments,
|
||||||
|
reserve_investments: reserveInvestments,
|
||||||
|
total_operating: operatingCash + operatingInvestments,
|
||||||
|
total_reserve: reserveCash + reserveInvestments,
|
||||||
|
total_all: operatingCash + reserveCash + operatingInvestments + reserveInvestments,
|
||||||
|
},
|
||||||
|
account_balances: accountBalances,
|
||||||
|
investment_accounts: investmentAccounts,
|
||||||
|
budgets,
|
||||||
|
projects,
|
||||||
|
cash_flow_context: cashFlowContext,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch latest CD rates from the shared schema (cross-tenant market data).
|
||||||
|
* Uses DataSource directly since this queries the shared schema, not tenant.
|
||||||
|
*/
|
||||||
|
async getCdRates(): Promise<CdRate[]> {
|
||||||
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
try {
|
||||||
|
await queryRunner.connect();
|
||||||
|
const rates = await queryRunner.query(
|
||||||
|
`SELECT bank_name, apy, min_deposit, term, term_months, fetched_at
|
||||||
|
FROM shared.cd_rates
|
||||||
|
ORDER BY apy DESC
|
||||||
|
LIMIT 25`,
|
||||||
|
);
|
||||||
|
return rates;
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orchestrate the AI recommendation flow:
|
||||||
|
* 1. Gather all financial data (tenant-scoped)
|
||||||
|
* 2. Fetch CD rates (shared schema)
|
||||||
|
* 3. Build the prompt with all context
|
||||||
|
* 4. Call the AI API
|
||||||
|
* 5. Parse and return structured recommendations
|
||||||
|
*/
|
||||||
|
async getAIRecommendations(userId?: string, orgId?: string): Promise<AIResponse> {
|
||||||
|
this.debug('getAIRecommendations', 'Starting AI recommendation flow');
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const [snapshot, cdRates, monthlyForecast] = await Promise.all([
|
||||||
|
this.getFinancialSnapshot(),
|
||||||
|
this.getCdRates(),
|
||||||
|
this.getMonthlyForecast(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.debug('snapshot_summary', {
|
||||||
|
operating_cash: snapshot.summary.operating_cash,
|
||||||
|
reserve_cash: snapshot.summary.reserve_cash,
|
||||||
|
total_all: snapshot.summary.total_all,
|
||||||
|
investment_accounts: snapshot.investment_accounts.length,
|
||||||
|
budgets: snapshot.budgets.length,
|
||||||
|
projects: snapshot.projects.length,
|
||||||
|
cd_rates: cdRates.length,
|
||||||
|
forecast_months: monthlyForecast.datapoints.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = this.buildPromptMessages(snapshot, cdRates, monthlyForecast);
|
||||||
|
const aiResponse = await this.callAI(messages);
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
|
||||||
|
this.debug('final_response', {
|
||||||
|
recommendation_count: aiResponse.recommendations.length,
|
||||||
|
has_assessment: !!aiResponse.overall_assessment,
|
||||||
|
risk_notes_count: aiResponse.risk_notes?.length || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log AI usage to shared.ai_recommendation_log (fire-and-forget)
|
||||||
|
this.logAIUsage(userId, orgId, aiResponse, elapsed).catch(() => {});
|
||||||
|
|
||||||
|
return aiResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logAIUsage(userId: string | undefined, orgId: string | undefined, response: AIResponse, elapsed: number) {
|
||||||
|
try {
|
||||||
|
const schema = this.tenant.getSchema();
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.ai_recommendation_log
|
||||||
|
(tenant_schema, organization_id, user_id, recommendation_count, response_time_ms, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[
|
||||||
|
schema || null,
|
||||||
|
orgId || null,
|
||||||
|
userId || null,
|
||||||
|
response.recommendations?.length || 0,
|
||||||
|
elapsed,
|
||||||
|
response.recommendations?.length > 0 ? 'success' : 'empty',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Non-critical — don't let logging failure break recommendations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private: Tenant-Scoped Data Queries ──
|
||||||
|
|
||||||
|
private async getAccountBalances(): Promise<AccountBalance[]> {
|
||||||
|
return this.tenant.query(`
|
||||||
|
SELECT
|
||||||
|
a.id, a.account_number, a.name, a.account_type, a.fund_type,
|
||||||
|
a.interest_rate,
|
||||||
|
CASE
|
||||||
|
WHEN a.account_type IN ('asset', 'expense')
|
||||||
|
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||||
|
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||||
|
END as balance
|
||||||
|
FROM accounts a
|
||||||
|
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
WHERE a.is_active = true
|
||||||
|
AND a.account_type IN ('asset', 'liability', 'equity')
|
||||||
|
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type, a.interest_rate
|
||||||
|
ORDER BY a.account_number
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getInvestmentAccounts(): Promise<InvestmentAccount[]> {
|
||||||
|
return this.tenant.query(`
|
||||||
|
SELECT
|
||||||
|
id, name, institution, investment_type, fund_type,
|
||||||
|
principal, interest_rate, maturity_date, purchase_date, current_value
|
||||||
|
FROM investment_accounts
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY maturity_date NULLS LAST
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBudgets() {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
return this.tenant.query(
|
||||||
|
`SELECT
|
||||||
|
b.fund_type, a.account_type, a.name, a.account_number,
|
||||||
|
(b.jan + b.feb + b.mar + b.apr + b.may + b.jun +
|
||||||
|
b.jul + b.aug + b.sep + b.oct + b.nov + b.dec_amt) as annual_total
|
||||||
|
FROM budgets b
|
||||||
|
JOIN accounts a ON a.id = b.account_id
|
||||||
|
WHERE b.fiscal_year = $1
|
||||||
|
ORDER BY a.account_type, a.account_number`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getProjects() {
|
||||||
|
return this.tenant.query(`
|
||||||
|
SELECT
|
||||||
|
name, estimated_cost, target_year, target_month, fund_source,
|
||||||
|
status, priority, current_fund_balance, funded_percentage
|
||||||
|
FROM projects
|
||||||
|
WHERE is_active = true
|
||||||
|
AND status IN ('planned', 'approved', 'in_progress')
|
||||||
|
ORDER BY target_year, target_month NULLS LAST, priority
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getCashFlowContext() {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
// Current operating cash position
|
||||||
|
const opCashResult = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||||
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
) sub
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Current reserve cash position
|
||||||
|
const resCashResult = await this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||||
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||||
|
AND je.is_posted = true AND je.is_void = false
|
||||||
|
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
) sub
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Annual budget summary by fund_type and account_type
|
||||||
|
const budgetSummary = await this.tenant.query(
|
||||||
|
`SELECT
|
||||||
|
b.fund_type, a.account_type,
|
||||||
|
SUM(b.jan + b.feb + b.mar + b.apr + b.may + b.jun +
|
||||||
|
b.jul + b.aug + b.sep + b.oct + b.nov + b.dec_amt) as annual_total
|
||||||
|
FROM budgets b
|
||||||
|
JOIN accounts a ON a.id = b.account_id
|
||||||
|
WHERE b.fiscal_year = $1
|
||||||
|
GROUP BY b.fund_type, a.account_type`,
|
||||||
|
[year],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assessment income (monthly recurring revenue)
|
||||||
|
const assessmentIncome = await this.tenant.query(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(ag.regular_assessment * (SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active')), 0) as monthly_assessment_income
|
||||||
|
FROM assessment_groups ag
|
||||||
|
WHERE ag.is_active = true
|
||||||
|
`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
current_operating_cash: parseFloat(opCashResult[0]?.total || '0'),
|
||||||
|
current_reserve_cash: parseFloat(resCashResult[0]?.total || '0'),
|
||||||
|
budget_summary: budgetSummary,
|
||||||
|
monthly_assessment_income: parseFloat(assessmentIncome[0]?.monthly_assessment_income || '0'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a 12-month forward cash flow forecast for the AI.
|
||||||
|
* Mirrors the logic from ReportsService.getCashFlowForecast() but streamlined
|
||||||
|
* for AI context. Includes: assessment income schedule (regular + special),
|
||||||
|
* monthly budget income/expenses, investment maturities, and capital project costs.
|
||||||
|
*/
|
||||||
|
private async getMonthlyForecast() {
|
||||||
|
const now = new Date();
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
const currentMonth = now.getMonth() + 1;
|
||||||
|
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||||
|
const monthLabels = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||||
|
const forecastMonths = 12;
|
||||||
|
|
||||||
|
// ── 1) Current cash positions ──
|
||||||
|
const [opCashRows, resCashRows, opInvRows, resInvRows] = await Promise.all([
|
||||||
|
this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||||
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||||
|
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
) sub
|
||||||
|
`),
|
||||||
|
this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||||
|
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||||
|
FROM accounts a
|
||||||
|
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||||
|
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||||
|
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
|
||||||
|
GROUP BY a.id
|
||||||
|
) sub
|
||||||
|
`),
|
||||||
|
this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(current_value), 0) as total
|
||||||
|
FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true
|
||||||
|
`),
|
||||||
|
this.tenant.query(`
|
||||||
|
SELECT COALESCE(SUM(current_value), 0) as total
|
||||||
|
FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let runOpCash = parseFloat(opCashRows[0]?.total || '0');
|
||||||
|
let runResCash = parseFloat(resCashRows[0]?.total || '0');
|
||||||
|
let runOpInv = parseFloat(opInvRows[0]?.total || '0');
|
||||||
|
let runResInv = parseFloat(resInvRows[0]?.total || '0');
|
||||||
|
|
||||||
|
// ── 2) Assessment income schedule (regular + special assessments) ──
|
||||||
|
const assessmentGroups = await this.tenant.query(`
|
||||||
|
SELECT ag.frequency, ag.regular_assessment, ag.special_assessment,
|
||||||
|
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active') as unit_count
|
||||||
|
FROM assessment_groups ag WHERE ag.is_active = true
|
||||||
|
`);
|
||||||
|
|
||||||
|
const getAssessmentIncome = (month: number): { operating: number; reserve: number } => {
|
||||||
|
let operating = 0;
|
||||||
|
let reserve = 0;
|
||||||
|
for (const g of assessmentGroups) {
|
||||||
|
const units = parseInt(g.unit_count) || 0;
|
||||||
|
const regular = parseFloat(g.regular_assessment) || 0;
|
||||||
|
const special = parseFloat(g.special_assessment) || 0;
|
||||||
|
const freq = g.frequency || 'monthly';
|
||||||
|
let applies = false;
|
||||||
|
if (freq === 'monthly') applies = true;
|
||||||
|
else if (freq === 'quarterly') applies = [1,4,7,10].includes(month);
|
||||||
|
else if (freq === 'annual') applies = month === 1;
|
||||||
|
if (applies) {
|
||||||
|
operating += regular * units;
|
||||||
|
reserve += special * units;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { operating, reserve };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 3) Monthly budget data (income & expenses by month) ──
|
||||||
|
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
|
||||||
|
for (const yr of [currentYear, currentYear + 1]) {
|
||||||
|
const budgetRows = await this.tenant.query(
|
||||||
|
`SELECT b.fund_type, a.account_type,
|
||||||
|
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
|
||||||
|
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
|
||||||
|
FROM budgets b
|
||||||
|
JOIN accounts a ON a.id = b.account_id
|
||||||
|
WHERE b.fiscal_year = $1`, [yr],
|
||||||
|
);
|
||||||
|
for (let m = 0; m < 12; m++) {
|
||||||
|
const key = `${yr}-${m + 1}`;
|
||||||
|
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||||
|
for (const row of budgetRows) {
|
||||||
|
const amt = parseFloat(row[monthNames[m]]) || 0;
|
||||||
|
if (amt === 0) continue;
|
||||||
|
const isOp = row.fund_type === 'operating';
|
||||||
|
if (row.account_type === 'income') {
|
||||||
|
if (isOp) budgetsByYearMonth[key].opIncome += amt;
|
||||||
|
else budgetsByYearMonth[key].resIncome += amt;
|
||||||
|
} else if (row.account_type === 'expense') {
|
||||||
|
if (isOp) budgetsByYearMonth[key].opExpense += amt;
|
||||||
|
else budgetsByYearMonth[key].resExpense += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4) Investment maturities ──
|
||||||
|
const maturities = await this.tenant.query(`
|
||||||
|
SELECT fund_type, current_value, maturity_date, interest_rate, purchase_date
|
||||||
|
FROM investment_accounts
|
||||||
|
WHERE is_active = true AND maturity_date IS NOT NULL AND maturity_date > CURRENT_DATE
|
||||||
|
`);
|
||||||
|
const maturityIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||||
|
for (const inv of maturities) {
|
||||||
|
const d = new Date(inv.maturity_date);
|
||||||
|
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||||
|
if (!maturityIndex[key]) maturityIndex[key] = { operating: 0, reserve: 0 };
|
||||||
|
const val = parseFloat(inv.current_value) || 0;
|
||||||
|
const rate = parseFloat(inv.interest_rate) || 0;
|
||||||
|
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
|
||||||
|
const matDate = new Date(inv.maturity_date);
|
||||||
|
const daysHeld = Math.max((matDate.getTime() - purchaseDate.getTime()) / 86400000, 1);
|
||||||
|
const interestEarned = val * (rate / 100) * (daysHeld / 365);
|
||||||
|
const maturityTotal = val + interestEarned;
|
||||||
|
if (inv.fund_type === 'operating') maturityIndex[key].operating += maturityTotal;
|
||||||
|
else maturityIndex[key].reserve += maturityTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5) Capital project expenses ──
|
||||||
|
const projectExpenses = await this.tenant.query(`
|
||||||
|
SELECT estimated_cost, target_year, target_month, fund_source
|
||||||
|
FROM projects
|
||||||
|
WHERE is_active = true AND status IN ('planned', 'in_progress')
|
||||||
|
AND target_year IS NOT NULL AND estimated_cost > 0
|
||||||
|
`);
|
||||||
|
const projectIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||||
|
for (const p of projectExpenses) {
|
||||||
|
const yr = parseInt(p.target_year);
|
||||||
|
const mo = parseInt(p.target_month) || 6;
|
||||||
|
const key = `${yr}-${mo}`;
|
||||||
|
if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 };
|
||||||
|
const cost = parseFloat(p.estimated_cost) || 0;
|
||||||
|
if (p.fund_source === 'operating') projectIndex[key].operating += cost;
|
||||||
|
else projectIndex[key].reserve += cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6) Build 12-month forward datapoints ──
|
||||||
|
const datapoints: any[] = [];
|
||||||
|
for (let i = 0; i < forecastMonths; i++) {
|
||||||
|
const year = currentYear + Math.floor((currentMonth - 1 + i) / 12);
|
||||||
|
const month = ((currentMonth - 1 + i) % 12) + 1;
|
||||||
|
const key = `${year}-${month}`;
|
||||||
|
const label = `${monthLabels[month - 1]} ${year}`;
|
||||||
|
|
||||||
|
const assessments = getAssessmentIncome(month);
|
||||||
|
const budget = budgetsByYearMonth[key] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||||
|
const maturity = maturityIndex[key] || { operating: 0, reserve: 0 };
|
||||||
|
const project = projectIndex[key] || { operating: 0, reserve: 0 };
|
||||||
|
|
||||||
|
// Use budget income if available, else assessment income
|
||||||
|
const opIncomeMonth = budget.opIncome > 0 ? budget.opIncome : assessments.operating;
|
||||||
|
const resIncomeMonth = budget.resIncome > 0 ? budget.resIncome : assessments.reserve;
|
||||||
|
|
||||||
|
// Net change: income - expenses - project costs + maturity returns
|
||||||
|
runOpCash += opIncomeMonth - budget.opExpense - project.operating + maturity.operating;
|
||||||
|
runResCash += resIncomeMonth - budget.resExpense - project.reserve + maturity.reserve;
|
||||||
|
|
||||||
|
// Subtract maturing investment values from investment balances
|
||||||
|
if (maturity.operating > 0) runOpInv = Math.max(0, runOpInv - (maturity.operating * 0.96));
|
||||||
|
if (maturity.reserve > 0) runResInv = Math.max(0, runResInv - (maturity.reserve * 0.96));
|
||||||
|
|
||||||
|
datapoints.push({
|
||||||
|
month: label,
|
||||||
|
operating_cash: Math.round(runOpCash * 100) / 100,
|
||||||
|
operating_investments: Math.round(runOpInv * 100) / 100,
|
||||||
|
reserve_cash: Math.round(runResCash * 100) / 100,
|
||||||
|
reserve_investments: Math.round(runResInv * 100) / 100,
|
||||||
|
// Include drivers for transparency
|
||||||
|
op_income: Math.round(opIncomeMonth * 100) / 100,
|
||||||
|
op_expense: Math.round(budget.opExpense * 100) / 100,
|
||||||
|
res_income: Math.round(resIncomeMonth * 100) / 100,
|
||||||
|
res_expense: Math.round(budget.resExpense * 100) / 100,
|
||||||
|
project_cost_op: Math.round(project.operating * 100) / 100,
|
||||||
|
project_cost_res: Math.round(project.reserve * 100) / 100,
|
||||||
|
maturity_op: Math.round(maturity.operating * 100) / 100,
|
||||||
|
maturity_res: Math.round(maturity.reserve * 100) / 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build assessment schedule summary for the AI
|
||||||
|
const assessmentSchedule = assessmentGroups.map((g: any) => ({
|
||||||
|
frequency: g.frequency || 'monthly',
|
||||||
|
regular_per_unit: parseFloat(g.regular_assessment) || 0,
|
||||||
|
special_per_unit: parseFloat(g.special_assessment) || 0,
|
||||||
|
units: parseInt(g.unit_count) || 0,
|
||||||
|
total_regular: (parseFloat(g.regular_assessment) || 0) * (parseInt(g.unit_count) || 0),
|
||||||
|
total_special: (parseFloat(g.special_assessment) || 0) * (parseInt(g.unit_count) || 0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
datapoints,
|
||||||
|
assessment_schedule: assessmentSchedule,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private: AI Prompt Construction ──
|
||||||
|
|
||||||
|
private buildPromptMessages(snapshot: any, cdRates: CdRate[], monthlyForecast: any) {
|
||||||
|
const { summary, investment_accounts, budgets, projects, cash_flow_context } = snapshot;
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const systemPrompt = `You are a financial advisor specializing in HOA (Homeowners Association) reserve fund management and conservative investment strategy. You provide fiduciary-grade investment recommendations.
|
||||||
|
|
||||||
|
CRITICAL RULES:
|
||||||
|
1. HOAs are legally required to maintain adequate reserves. NEVER recommend depleting reserve funds below safe levels.
|
||||||
|
2. HOA investments must be conservative ONLY: CDs, money market accounts, treasury bills, and high-yield savings. NO stocks, bonds, mutual funds, or speculative instruments.
|
||||||
|
3. Liquidity is paramount: always ensure enough cash to cover at least 3 months of operating expenses AND any capital project expenses due within the next 12 months.
|
||||||
|
4. CD laddering is the preferred strategy for reserve funds — it balances yield with regular liquidity access.
|
||||||
|
5. Operating funds should remain highly liquid (money market or high-yield savings only).
|
||||||
|
6. Respect the separation between operating funds and reserve funds. Never suggest commingling.
|
||||||
|
7. Base your recommendations ONLY on the available CD rates and instruments provided. Do not reference rates or banks not in the provided data.
|
||||||
|
8. CRITICAL: Use the 12-MONTH CASH FLOW FORECAST to understand future liquidity. The forecast includes projected income (regular assessments AND special assessments collected from homeowners), budgeted expenses, investment maturities, and capital project costs. Do NOT flag liquidity shortfalls if the forecast shows sufficient income arriving before the expense is due.
|
||||||
|
|
||||||
|
RESPONSE FORMAT:
|
||||||
|
Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema:
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
{
|
||||||
|
"type": "cd_ladder" | "new_investment" | "reallocation" | "maturity_action" | "liquidity_warning" | "general",
|
||||||
|
"priority": "high" | "medium" | "low",
|
||||||
|
"title": "Short action title (under 60 chars)",
|
||||||
|
"summary": "One sentence summary of the recommendation",
|
||||||
|
"details": "Detailed explanation with specific dollar amounts and timeframes",
|
||||||
|
"fund_type": "operating" | "reserve" | "both",
|
||||||
|
"suggested_amount": 50000.00,
|
||||||
|
"suggested_term": "12 months",
|
||||||
|
"suggested_rate": 4.50,
|
||||||
|
"bank_name": "Bank name from CD rates (if applicable)",
|
||||||
|
"rationale": "Financial reasoning for why this makes sense"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"overall_assessment": "2-3 sentence overview of the HOA's current investment position and opportunities",
|
||||||
|
"risk_notes": ["Array of risk items or concerns to flag for the board"]
|
||||||
|
}
|
||||||
|
|
||||||
|
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible.`;
|
||||||
|
|
||||||
|
// Build the data context for the user prompt
|
||||||
|
const investmentsList = investment_accounts.length === 0
|
||||||
|
? 'No current investments.'
|
||||||
|
: investment_accounts.map((i: any) =>
|
||||||
|
`- ${i.name} | Type: ${i.investment_type} | Fund: ${i.fund_type} | Principal: $${parseFloat(i.principal).toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const budgetLines = budgets.length === 0
|
||||||
|
? 'No budget data available.'
|
||||||
|
: budgets.map((b: any) =>
|
||||||
|
`- ${b.name} (${b.account_number}) | ${b.account_type}/${b.fund_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const projectLines = projects.length === 0
|
||||||
|
? 'No upcoming capital projects.'
|
||||||
|
: projects.map((p: any) =>
|
||||||
|
`- ${p.name} | Cost: $${parseFloat(p.estimated_cost).toFixed(2)} | Target: ${p.target_year || '?'}/${p.target_month || '?'} | Fund: ${p.fund_source} | Status: ${p.status} | Funded: ${parseFloat(p.funded_percentage || '0').toFixed(1)}%`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const budgetSummaryLines = (cash_flow_context.budget_summary || []).length === 0
|
||||||
|
? 'No budget summary available.'
|
||||||
|
: cash_flow_context.budget_summary.map((b: any) =>
|
||||||
|
`- ${b.fund_type} ${b.account_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr (~$${(parseFloat(b.annual_total) / 12).toFixed(2)}/mo)`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
const cdRateLines = cdRates.length === 0
|
||||||
|
? 'No CD rate data available. Rate fetcher may not have been run yet.'
|
||||||
|
: cdRates.map((r: CdRate) =>
|
||||||
|
`- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}% | Term: ${r.term} | Min Deposit: ${r.min_deposit ? '$' + parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
// Format assessment schedule showing regular + special
|
||||||
|
const assessmentScheduleLines = (monthlyForecast.assessment_schedule || []).length === 0
|
||||||
|
? 'No assessment schedule available.'
|
||||||
|
: monthlyForecast.assessment_schedule.map((a: any) =>
|
||||||
|
`- ${a.frequency} collection | ${a.units} units | Regular: $${a.regular_per_unit.toFixed(2)}/unit ($${a.total_regular.toFixed(2)} total) → Operating | Special: $${a.special_per_unit.toFixed(2)}/unit ($${a.total_special.toFixed(2)} total) → Reserve`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
// Format 12-month forecast table
|
||||||
|
const forecastLines = (monthlyForecast.datapoints || []).map((dp: any) => {
|
||||||
|
const drivers: string[] = [];
|
||||||
|
if (dp.op_income > 0) drivers.push(`OpInc:$${dp.op_income.toFixed(0)}`);
|
||||||
|
if (dp.op_expense > 0) drivers.push(`OpExp:$${dp.op_expense.toFixed(0)}`);
|
||||||
|
if (dp.res_income > 0) drivers.push(`ResInc:$${dp.res_income.toFixed(0)}`);
|
||||||
|
if (dp.res_expense > 0) drivers.push(`ResExp:$${dp.res_expense.toFixed(0)}`);
|
||||||
|
if (dp.project_cost_res > 0) drivers.push(`ResProjCost:$${dp.project_cost_res.toFixed(0)}`);
|
||||||
|
if (dp.project_cost_op > 0) drivers.push(`OpProjCost:$${dp.project_cost_op.toFixed(0)}`);
|
||||||
|
if (dp.maturity_op > 0) drivers.push(`OpMaturity:$${dp.maturity_op.toFixed(0)}`);
|
||||||
|
if (dp.maturity_res > 0) drivers.push(`ResMaturity:$${dp.maturity_res.toFixed(0)}`);
|
||||||
|
return `- ${dp.month} | OpCash: $${dp.operating_cash.toFixed(0)} | ResCash: $${dp.reserve_cash.toFixed(0)} | OpInv: $${dp.operating_investments.toFixed(0)} | ResInv: $${dp.reserve_investments.toFixed(0)} | Drivers: ${drivers.join(', ') || 'none'}`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const userPrompt = `Analyze this HOA's financial position and provide investment recommendations.
|
||||||
|
|
||||||
|
TODAY'S DATE: ${today}
|
||||||
|
|
||||||
|
=== CURRENT CASH POSITIONS ===
|
||||||
|
Operating Cash (bank accounts): $${summary.operating_cash.toFixed(2)}
|
||||||
|
Reserve Cash (bank accounts): $${summary.reserve_cash.toFixed(2)}
|
||||||
|
Operating Investments: $${summary.operating_investments.toFixed(2)}
|
||||||
|
Reserve Investments: $${summary.reserve_investments.toFixed(2)}
|
||||||
|
Total Operating Fund: $${summary.total_operating.toFixed(2)}
|
||||||
|
Total Reserve Fund: $${summary.total_reserve.toFixed(2)}
|
||||||
|
Grand Total: $${summary.total_all.toFixed(2)}
|
||||||
|
|
||||||
|
=== CURRENT INVESTMENTS ===
|
||||||
|
${investmentsList}
|
||||||
|
|
||||||
|
=== ASSESSMENT INCOME SCHEDULE ===
|
||||||
|
${assessmentScheduleLines}
|
||||||
|
Note: "Regular" assessments fund Operating. "Special" assessments fund Reserve. Both are collected from homeowners per the frequency above.
|
||||||
|
|
||||||
|
=== ANNUAL BUDGET (${new Date().getFullYear()}) ===
|
||||||
|
${budgetLines}
|
||||||
|
|
||||||
|
=== BUDGET SUMMARY (Annual Totals by Category) ===
|
||||||
|
${budgetSummaryLines}
|
||||||
|
|
||||||
|
=== MONTHLY ASSESSMENT INCOME ===
|
||||||
|
Recurring monthly regular assessment income: $${cash_flow_context.monthly_assessment_income.toFixed(2)}/month (operating fund)
|
||||||
|
|
||||||
|
=== UPCOMING CAPITAL PROJECTS ===
|
||||||
|
${projectLines}
|
||||||
|
|
||||||
|
=== 12-MONTH CASH FLOW FORECAST (Projected) ===
|
||||||
|
This forecast shows month-by-month projected balances factoring in ALL income (regular assessments, special assessments, budgeted income), ALL expenses (budgeted expenses, capital project costs), and investment maturities.
|
||||||
|
${forecastLines}
|
||||||
|
|
||||||
|
=== AVAILABLE CD RATES (Market Data) ===
|
||||||
|
${cdRateLines}
|
||||||
|
|
||||||
|
Based on this complete financial picture INCLUDING the 12-month cash flow forecast, provide your investment recommendations. Consider:
|
||||||
|
1. Is there excess cash that could earn better returns in CDs?
|
||||||
|
2. Are any current investments maturing soon that need reinvestment planning?
|
||||||
|
3. Is the liquidity position adequate for upcoming expenses and projects? USE THE FORECAST to check — if income (including special assessments) arrives before expenses are due, the position may be adequate even if current cash seems low.
|
||||||
|
4. Would a CD ladder strategy improve the yield while maintaining access to funds?
|
||||||
|
5. Are operating and reserve funds properly separated in the investment strategy?`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private: AI API Call ──
|
||||||
|
|
||||||
|
private async callAI(messages: Array<{ role: string; content: string }>): Promise<AIResponse> {
|
||||||
|
const apiUrl = this.configService.get<string>('AI_API_URL') || 'https://integrate.api.nvidia.com/v1';
|
||||||
|
const apiKey = this.configService.get<string>('AI_API_KEY');
|
||||||
|
const model = this.configService.get<string>('AI_MODEL') || 'qwen/qwen3.5-397b-a17b';
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
this.logger.error('AI_API_KEY not configured');
|
||||||
|
return {
|
||||||
|
recommendations: [],
|
||||||
|
overall_assessment: 'AI recommendations are not available. The AI_API_KEY has not been configured in the environment.',
|
||||||
|
risk_notes: ['Configure AI_API_KEY in .env to enable investment recommendations.'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 4096,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyString = JSON.stringify(requestBody);
|
||||||
|
|
||||||
|
this.debug('prompt_system', messages[0]?.content);
|
||||||
|
this.debug('prompt_user', messages[1]?.content);
|
||||||
|
this.debug('request_meta', {
|
||||||
|
url: `${apiUrl}/chat/completions`,
|
||||||
|
model,
|
||||||
|
temperature: 0.3,
|
||||||
|
max_tokens: 4096,
|
||||||
|
body_length_bytes: Buffer.byteLength(bodyString, 'utf-8'),
|
||||||
|
message_count: messages.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.log(`Calling AI API: ${apiUrl} with model ${model} (body: ${Buffer.byteLength(bodyString, 'utf-8')} bytes)`);
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Use Node.js https module instead of native fetch for better
|
||||||
|
// compatibility in Docker Alpine environments
|
||||||
|
const { URL } = await import('url');
|
||||||
|
const https = await import('https');
|
||||||
|
|
||||||
|
const aiResult = await new Promise<any>((resolve, reject) => {
|
||||||
|
const url = new URL(`${apiUrl}/chat/completions`);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || 443,
|
||||||
|
path: url.pathname,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
||||||
|
},
|
||||||
|
timeout: 180000, // 3 minute timeout
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', (chunk) => { data += chunk; });
|
||||||
|
res.on('end', () => {
|
||||||
|
resolve({ status: res.statusCode, body: data });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (err) => reject(err));
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error(`Request timed out after 180s`));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(bodyString);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
this.logger.log(`AI API responded in ${elapsed}ms with status ${aiResult.status}`);
|
||||||
|
this.debug('response_timing', { elapsed_ms: elapsed, status: aiResult.status });
|
||||||
|
|
||||||
|
if (aiResult.status >= 400) {
|
||||||
|
this.logger.error(`AI API error ${aiResult.status}: ${aiResult.body}`);
|
||||||
|
this.debug('response_error_body', aiResult.body);
|
||||||
|
throw new Error(`AI API returned ${aiResult.status}: ${aiResult.body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(aiResult.body);
|
||||||
|
const msg = data.choices?.[0]?.message;
|
||||||
|
// Thinking models (kimi-k2.5) may return content in 'content' or
|
||||||
|
// spend all tokens on 'reasoning_content' with content=null
|
||||||
|
const content = msg?.content || null;
|
||||||
|
|
||||||
|
this.logger.log(`AI response: content=${content ? content.length + ' chars' : 'null'}, reasoning=${msg?.reasoning_content ? 'yes' : 'no'}, finish=${data.choices?.[0]?.finish_reason}`);
|
||||||
|
this.debug('response_raw_content', content);
|
||||||
|
this.debug('response_usage', data.usage);
|
||||||
|
if (msg?.reasoning_content) {
|
||||||
|
this.debug('response_reasoning', msg.reasoning_content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
throw new Error('AI model returned empty content — it may have exhausted tokens on reasoning. Try a non-thinking model or increase max_tokens.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the JSON response — handle potential markdown code fences
|
||||||
|
let cleaned = content.trim();
|
||||||
|
if (cleaned.startsWith('```')) {
|
||||||
|
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle thinking model wrapper: strip <think>...</think> blocks
|
||||||
|
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>\s*/g, '').trim();
|
||||||
|
|
||||||
|
const parsed = JSON.parse(cleaned) as AIResponse;
|
||||||
|
|
||||||
|
// Validate the response structure
|
||||||
|
if (!parsed.recommendations || !Array.isArray(parsed.recommendations)) {
|
||||||
|
this.debug('invalid_response_structure', parsed);
|
||||||
|
throw new Error('Invalid AI response: missing recommendations array');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`AI returned ${parsed.recommendations.length} recommendations`);
|
||||||
|
this.debug('parsed_recommendations', parsed.recommendations.map((r) => ({
|
||||||
|
type: r.type,
|
||||||
|
priority: r.priority,
|
||||||
|
title: r.title,
|
||||||
|
fund_type: r.fund_type,
|
||||||
|
suggested_amount: r.suggested_amount,
|
||||||
|
})));
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
} catch (error: any) {
|
||||||
|
// Log the full error chain for debugging
|
||||||
|
this.logger.error(`AI recommendation failed: ${error.message}`);
|
||||||
|
if (error.cause) {
|
||||||
|
this.logger.error(` → cause: ${error.cause?.message || error.cause}`);
|
||||||
|
this.debug('error_cause', {
|
||||||
|
message: error.cause?.message,
|
||||||
|
code: error.cause?.code,
|
||||||
|
errno: error.cause?.errno,
|
||||||
|
syscall: error.cause?.syscall,
|
||||||
|
hostname: error.cause?.hostname,
|
||||||
|
stack: error.cause?.stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.debug('error_full', {
|
||||||
|
message: error.message,
|
||||||
|
name: error.name,
|
||||||
|
code: error.code,
|
||||||
|
stack: error.stack,
|
||||||
|
});
|
||||||
|
|
||||||
|
// For JSON parse errors, return what we can
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
return {
|
||||||
|
recommendations: [],
|
||||||
|
overall_assessment: 'The AI service returned an invalid response format. Please try again.',
|
||||||
|
risk_notes: [`Response parsing error: ${error.message}`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// For network/timeout errors, return a graceful fallback
|
||||||
|
return {
|
||||||
|
recommendations: [],
|
||||||
|
overall_assessment: 'Unable to generate AI recommendations at this time. Please try again later.',
|
||||||
|
risk_notes: [`AI service error: ${error.message}`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,15 @@ export class Organization {
|
|||||||
@Column({ name: 'plan_level', default: 'standard' })
|
@Column({ name: 'plan_level', default: 'standard' })
|
||||||
planLevel: string;
|
planLevel: string;
|
||||||
|
|
||||||
|
@Column({ name: 'payment_date', type: 'date', nullable: true })
|
||||||
|
paymentDate: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'confirmation_number', type: 'varchar', nullable: true })
|
||||||
|
confirmationNumber: string;
|
||||||
|
|
||||||
|
@Column({ name: 'renewal_date', type: 'date', nullable: true })
|
||||||
|
renewalDate: Date;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,22 @@ export class OrganizationsService {
|
|||||||
return this.orgRepository.save(org);
|
return this.orgRepository.save(org);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updatePlanLevel(id: string, planLevel: string) {
|
||||||
|
const org = await this.orgRepository.findOne({ where: { id } });
|
||||||
|
if (!org) throw new NotFoundException('Organization not found');
|
||||||
|
org.planLevel = planLevel;
|
||||||
|
return this.orgRepository.save(org);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSubscription(id: string, data: { paymentDate?: string; confirmationNumber?: string; renewalDate?: string }) {
|
||||||
|
const org = await this.orgRepository.findOne({ where: { id } });
|
||||||
|
if (!org) throw new NotFoundException('Organization not found');
|
||||||
|
if (data.paymentDate !== undefined) (org as any).paymentDate = data.paymentDate ? new Date(data.paymentDate) : null;
|
||||||
|
if (data.confirmationNumber !== undefined) (org as any).confirmationNumber = data.confirmationNumber || null;
|
||||||
|
if (data.renewalDate !== undefined) (org as any).renewalDate = data.renewalDate ? new Date(data.renewalDate) : null;
|
||||||
|
return this.orgRepository.save(org);
|
||||||
|
}
|
||||||
|
|
||||||
async findByUser(userId: string) {
|
async findByUser(userId: string) {
|
||||||
const memberships = await this.userOrgRepository.find({
|
const memberships = await this.userOrgRepository.find({
|
||||||
where: { userId, isActive: true },
|
where: { userId, isActive: true },
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export class User {
|
|||||||
@Column({ name: 'is_superadmin', default: false })
|
@Column({ name: 'is_superadmin', default: false })
|
||||||
isSuperadmin: boolean;
|
isSuperadmin: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'is_platform_owner', default: false })
|
||||||
|
isPlatformOwner: boolean;
|
||||||
|
|
||||||
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
|
||||||
lastLoginAt: Date;
|
lastLoginAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { User } from './entities/user.entity';
|
import { User } from './entities/user.entity';
|
||||||
@@ -50,13 +50,19 @@ export class UsersService {
|
|||||||
const dataSource = this.usersRepository.manager.connection;
|
const dataSource = this.usersRepository.manager.connection;
|
||||||
return dataSource.query(`
|
return dataSource.query(`
|
||||||
SELECT o.*,
|
SELECT o.*,
|
||||||
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id) as member_count
|
(SELECT COUNT(*) FROM shared.user_organizations WHERE organization_id = o.id) as member_count,
|
||||||
|
(SELECT MAX(lh.logged_in_at) FROM shared.login_history lh WHERE lh.organization_id = o.id) as last_activity
|
||||||
FROM shared.organizations o
|
FROM shared.organizations o
|
||||||
ORDER BY o.created_at DESC
|
ORDER BY o.created_at DESC
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
|
async setSuperadmin(userId: string, isSuperadmin: boolean): Promise<void> {
|
||||||
|
// Protect platform owner from having superadmin removed
|
||||||
|
const user = await this.usersRepository.findOne({ where: { id: userId } });
|
||||||
|
if (user?.isPlatformOwner) {
|
||||||
|
throw new ForbiddenException('Cannot modify platform owner superadmin status');
|
||||||
|
}
|
||||||
await this.usersRepository.update(userId, { isSuperadmin });
|
await this.usersRepository.update(userId, { isSuperadmin });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ CREATE TABLE shared.organizations (
|
|||||||
email VARCHAR(255),
|
email VARCHAR(255),
|
||||||
tax_id VARCHAR(20),
|
tax_id VARCHAR(20),
|
||||||
fiscal_year_start_month INTEGER DEFAULT 1 CHECK (fiscal_year_start_month BETWEEN 1 AND 12),
|
fiscal_year_start_month INTEGER DEFAULT 1 CHECK (fiscal_year_start_month BETWEEN 1 AND 12),
|
||||||
|
payment_date DATE,
|
||||||
|
confirmation_number VARCHAR(100),
|
||||||
|
renewal_date DATE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -45,6 +48,7 @@ CREATE TABLE shared.users (
|
|||||||
oauth_provider_id VARCHAR(255),
|
oauth_provider_id VARCHAR(255),
|
||||||
last_login_at TIMESTAMPTZ,
|
last_login_at TIMESTAMPTZ,
|
||||||
is_superadmin BOOLEAN DEFAULT FALSE,
|
is_superadmin BOOLEAN DEFAULT FALSE,
|
||||||
|
is_platform_owner BOOLEAN DEFAULT FALSE,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
@@ -73,6 +77,41 @@ CREATE TABLE shared.invitations (
|
|||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- CD Rates (cross-tenant market data for investment recommendations)
|
||||||
|
CREATE TABLE shared.cd_rates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
bank_name VARCHAR(255) NOT NULL,
|
||||||
|
apy DECIMAL(6,4) NOT NULL,
|
||||||
|
min_deposit DECIMAL(15,2),
|
||||||
|
term VARCHAR(100) NOT NULL,
|
||||||
|
term_months INTEGER,
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
source_url VARCHAR(500),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Login history (track logins/org-switches for platform analytics)
|
||||||
|
CREATE TABLE shared.login_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||||
|
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
|
||||||
|
logged_in_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
user_agent TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AI recommendation log (track AI usage per tenant)
|
||||||
|
CREATE TABLE shared.ai_recommendation_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_schema VARCHAR(63),
|
||||||
|
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
|
||||||
|
user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL,
|
||||||
|
recommendation_count INTEGER,
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
status VARCHAR(20),
|
||||||
|
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
CREATE INDEX idx_user_orgs_user ON shared.user_organizations(user_id);
|
CREATE INDEX idx_user_orgs_user ON shared.user_organizations(user_id);
|
||||||
CREATE INDEX idx_user_orgs_org ON shared.user_organizations(organization_id);
|
CREATE INDEX idx_user_orgs_org ON shared.user_organizations(organization_id);
|
||||||
@@ -80,3 +119,10 @@ CREATE INDEX idx_users_email ON shared.users(email);
|
|||||||
CREATE INDEX idx_orgs_schema ON shared.organizations(schema_name);
|
CREATE INDEX idx_orgs_schema ON shared.organizations(schema_name);
|
||||||
CREATE INDEX idx_invitations_token ON shared.invitations(token);
|
CREATE INDEX idx_invitations_token ON shared.invitations(token);
|
||||||
CREATE INDEX idx_invitations_email ON shared.invitations(email);
|
CREATE INDEX idx_invitations_email ON shared.invitations(email);
|
||||||
|
CREATE INDEX idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC);
|
||||||
|
CREATE INDEX idx_cd_rates_apy ON shared.cd_rates(apy DESC);
|
||||||
|
CREATE INDEX idx_login_history_org_time ON shared.login_history(organization_id, logged_in_at DESC);
|
||||||
|
CREATE INDEX idx_login_history_user ON shared.login_history(user_id);
|
||||||
|
CREATE INDEX idx_login_history_time ON shared.login_history(logged_in_at DESC);
|
||||||
|
CREATE INDEX idx_ai_rec_log_org ON shared.ai_recommendation_log(organization_id);
|
||||||
|
CREATE INDEX idx_ai_rec_log_time ON shared.ai_recommendation_log(requested_at DESC);
|
||||||
|
|||||||
17
db/migrations/005-cd-rates.sql
Normal file
17
db/migrations/005-cd-rates.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- Migration: Add CD rates table to shared schema
|
||||||
|
-- For existing deployments that already have the shared schema initialized
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shared.cd_rates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
bank_name VARCHAR(255) NOT NULL,
|
||||||
|
apy DECIMAL(6,4) NOT NULL,
|
||||||
|
min_deposit DECIMAL(15,2),
|
||||||
|
term VARCHAR(100) NOT NULL,
|
||||||
|
term_months INTEGER,
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
source_url VARCHAR(500),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cd_rates_fetched ON shared.cd_rates(fetched_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cd_rates_apy ON shared.cd_rates(apy DESC);
|
||||||
52
db/migrations/006-admin-platform.sql
Normal file
52
db/migrations/006-admin-platform.sql
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Migration 006: Platform Administration Features
|
||||||
|
-- Adds: is_platform_owner, subscription fields, login_history, ai_recommendation_log
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. Add is_platform_owner to users
|
||||||
|
ALTER TABLE shared.users
|
||||||
|
ADD COLUMN IF NOT EXISTS is_platform_owner BOOLEAN DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- 2. Add subscription fields to organizations
|
||||||
|
ALTER TABLE shared.organizations
|
||||||
|
ADD COLUMN IF NOT EXISTS payment_date DATE,
|
||||||
|
ADD COLUMN IF NOT EXISTS confirmation_number VARCHAR(100),
|
||||||
|
ADD COLUMN IF NOT EXISTS renewal_date DATE;
|
||||||
|
|
||||||
|
-- 3. Create login_history table
|
||||||
|
CREATE TABLE IF NOT EXISTS shared.login_history (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||||
|
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
|
||||||
|
logged_in_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
user_agent TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_history_org_time
|
||||||
|
ON shared.login_history(organization_id, logged_in_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_history_user
|
||||||
|
ON shared.login_history(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_login_history_time
|
||||||
|
ON shared.login_history(logged_in_at DESC);
|
||||||
|
|
||||||
|
-- 4. Create ai_recommendation_log table
|
||||||
|
CREATE TABLE IF NOT EXISTS shared.ai_recommendation_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_schema VARCHAR(63),
|
||||||
|
organization_id UUID REFERENCES shared.organizations(id) ON DELETE SET NULL,
|
||||||
|
user_id UUID REFERENCES shared.users(id) ON DELETE SET NULL,
|
||||||
|
recommendation_count INTEGER,
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
status VARCHAR(20),
|
||||||
|
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_rec_log_org
|
||||||
|
ON shared.ai_recommendation_log(organization_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_rec_log_time
|
||||||
|
ON shared.ai_recommendation_log(requested_at DESC);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -16,6 +16,31 @@
|
|||||||
-- Enable UUID generation
|
-- Enable UUID generation
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 0. Create platform owner account (admin@hoaledgeriq.com)
|
||||||
|
-- ============================================================
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_platform_owner_id UUID;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO v_platform_owner_id FROM shared.users WHERE email = 'admin@hoaledgeriq.com';
|
||||||
|
IF v_platform_owner_id IS NULL THEN
|
||||||
|
INSERT INTO shared.users (id, email, password_hash, first_name, last_name, is_superadmin, is_platform_owner)
|
||||||
|
VALUES (
|
||||||
|
uuid_generate_v4(),
|
||||||
|
'admin@hoaledgeriq.com',
|
||||||
|
-- bcrypt hash of platform owner password (cost 12)
|
||||||
|
'$2b$12$QRJEJYsjy.24Va.57h13Te7UX7nMTN9hWhW19bwuCAkr1Dm0FWqrm',
|
||||||
|
'Platform',
|
||||||
|
'Admin',
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
) RETURNING id INTO v_platform_owner_id;
|
||||||
|
END IF;
|
||||||
|
-- Platform owner has NO org memberships — admin-only account
|
||||||
|
RAISE NOTICE 'Platform Owner: admin@hoaledgeriq.com (id: %)', v_platform_owner_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 1. Create test user and organization
|
-- 1. Create test user and organization
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@@ -836,7 +861,42 @@ EXECUTE format('INSERT INTO %I.capital_projects (name, description, estimated_co
|
|||||||
(''Perimeter Fence Repair'', ''Replace damaged fence sections and repaint'', 8000, $1 + 4, 8, ''planned'', ''reserve'', 4)
|
(''Perimeter Fence Repair'', ''Replace damaged fence sections and repaint'', 8000, $1 + 4, 8, ''planned'', ''reserve'', 4)
|
||||||
', v_schema) USING v_year;
|
', v_schema) USING v_year;
|
||||||
|
|
||||||
|
-- Add subscription data to the organization
|
||||||
|
UPDATE shared.organizations
|
||||||
|
SET payment_date = (CURRENT_DATE - INTERVAL '15 days')::DATE,
|
||||||
|
confirmation_number = 'PAY-2026-SVH-001',
|
||||||
|
renewal_date = (CURRENT_DATE + INTERVAL '350 days')::DATE
|
||||||
|
WHERE schema_name = v_schema;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 13. Seed login_history for demo analytics
|
||||||
|
-- ============================================================
|
||||||
|
-- Admin user: regular logins over the past 30 days
|
||||||
|
FOR v_month IN 0..29 LOOP
|
||||||
|
INSERT INTO shared.login_history (user_id, organization_id, logged_in_at, ip_address, user_agent)
|
||||||
|
VALUES (
|
||||||
|
v_user_id,
|
||||||
|
v_org_id,
|
||||||
|
NOW() - (v_month || ' days')::INTERVAL - (random() * 8 || ' hours')::INTERVAL,
|
||||||
|
'192.168.1.' || (10 + (random() * 50)::INT),
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Viewer user: occasional logins (every 3-5 days)
|
||||||
|
FOR v_month IN 0..9 LOOP
|
||||||
|
INSERT INTO shared.login_history (user_id, organization_id, logged_in_at, ip_address, user_agent)
|
||||||
|
VALUES (
|
||||||
|
(SELECT id FROM shared.users WHERE email = 'viewer@sunrisevalley.org'),
|
||||||
|
v_org_id,
|
||||||
|
NOW() - ((v_month * 3) || ' days')::INTERVAL - (random() * 12 || ' hours')::INTERVAL,
|
||||||
|
'10.0.0.' || (100 + (random() * 50)::INT),
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)'
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
RAISE NOTICE 'Seed data created successfully for Sunrise Valley HOA!';
|
RAISE NOTICE 'Seed data created successfully for Sunrise Valley HOA!';
|
||||||
|
RAISE NOTICE 'Platform Owner: admin@hoaledgeriq.com (SuperAdmin + Platform Owner)';
|
||||||
RAISE NOTICE 'Admin Login: admin@sunrisevalley.org / password123 (SuperAdmin + President)';
|
RAISE NOTICE 'Admin Login: admin@sunrisevalley.org / password123 (SuperAdmin + President)';
|
||||||
RAISE NOTICE 'Viewer Login: viewer@sunrisevalley.org / password123 (Homeowner)';
|
RAISE NOTICE 'Viewer Login: viewer@sunrisevalley.org / password123 (Homeowner)';
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ services:
|
|||||||
- REDIS_URL=${REDIS_URL}
|
- REDIS_URL=${REDIS_URL}
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- NODE_ENV=${NODE_ENV}
|
- NODE_ENV=${NODE_ENV}
|
||||||
|
- AI_API_URL=${AI_API_URL}
|
||||||
|
- AI_API_KEY=${AI_API_KEY}
|
||||||
|
- AI_MODEL=${AI_MODEL}
|
||||||
|
- AI_DEBUG=${AI_DEBUG:-false}
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/src:/app/src
|
- ./backend/src:/app/src
|
||||||
- ./backend/nest-cli.json:/app/nest-cli.json
|
- ./backend/nest-cli.json:/app/nest-cli.json
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { AdminPage } from './pages/admin/AdminPage';
|
|||||||
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
|
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
|
||||||
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
||||||
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
|
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
|
||||||
|
import { InvestmentPlanningPage } from './pages/investment-planning/InvestmentPlanningPage';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
@@ -54,8 +55,14 @@ function SuperAdminRoute({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function AuthRoute({ children }: { children: React.ReactNode }) {
|
function AuthRoute({ children }: { children: React.ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token);
|
const token = useAuthStore((s) => s.token);
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
|
const organizations = useAuthStore((s) => s.organizations);
|
||||||
if (token && currentOrg) return <Navigate to="/" replace />;
|
if (token && currentOrg) return <Navigate to="/" replace />;
|
||||||
|
// Platform owner / superadmin with no org memberships → admin panel
|
||||||
|
if (token && user?.isSuperadmin && (!organizations || organizations.length === 0)) {
|
||||||
|
return <Navigate to="/admin" replace />;
|
||||||
|
}
|
||||||
if (token && !currentOrg) return <Navigate to="/select-org" replace />;
|
if (token && !currentOrg) return <Navigate to="/select-org" replace />;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
@@ -117,6 +124,7 @@ export function App() {
|
|||||||
<Route path="projects" element={<ProjectsPage />} />
|
<Route path="projects" element={<ProjectsPage />} />
|
||||||
<Route path="investments" element={<InvestmentsPage />} />
|
<Route path="investments" element={<InvestmentsPage />} />
|
||||||
<Route path="capital-projects" element={<CapitalProjectsPage />} />
|
<Route path="capital-projects" element={<CapitalProjectsPage />} />
|
||||||
|
<Route path="investment-planning" element={<InvestmentPlanningPage />} />
|
||||||
<Route path="assessment-groups" element={<AssessmentGroupsPage />} />
|
<Route path="assessment-groups" element={<AssessmentGroupsPage />} />
|
||||||
<Route path="cash-flow" element={<CashFlowForecastPage />} />
|
<Route path="cash-flow" element={<CashFlowForecastPage />} />
|
||||||
<Route path="monthly-actuals" element={<MonthlyActualsPage />} />
|
<Route path="monthly-actuals" element={<MonthlyActualsPage />} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar } from '@mantine/core';
|
import { AppShell, Burger, Group, Text, Menu, UnstyledButton, Avatar, Alert, Button } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import {
|
import {
|
||||||
IconLogout,
|
IconLogout,
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
IconSettings,
|
IconSettings,
|
||||||
IconUserCog,
|
IconUserCog,
|
||||||
IconUsersGroup,
|
IconUsersGroup,
|
||||||
|
IconEyeOff,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { Outlet, useNavigate } from 'react-router-dom';
|
import { Outlet, useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
@@ -15,25 +16,53 @@ 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 } = useAuthStore();
|
const { user, currentOrg, logout, impersonationOriginal, stopImpersonation } = useAuthStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isImpersonating = !!impersonationOriginal;
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStopImpersonation = () => {
|
||||||
|
stopImpersonation();
|
||||||
|
navigate('/admin');
|
||||||
|
};
|
||||||
|
|
||||||
// Tenant admins (president role) can manage org members
|
// Tenant admins (president role) can manage org members
|
||||||
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
|
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 60 }}
|
header={{ height: isImpersonating ? 100 : 60 }}
|
||||||
navbar={{ width: 260, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
navbar={{ width: 260, breakpoint: 'sm', collapsed: { mobile: !opened } }}
|
||||||
padding="md"
|
padding="md"
|
||||||
>
|
>
|
||||||
<AppShell.Header>
|
<AppShell.Header>
|
||||||
<Group h="100%" px="md" justify="space-between">
|
{isImpersonating && (
|
||||||
|
<Group
|
||||||
|
h={40}
|
||||||
|
px="md"
|
||||||
|
justify="center"
|
||||||
|
gap="xs"
|
||||||
|
style={{ backgroundColor: 'var(--mantine-color-orange-6)' }}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={600} c="white">
|
||||||
|
Impersonating {user?.firstName} {user?.lastName} ({user?.email})
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="white"
|
||||||
|
color="orange"
|
||||||
|
leftSection={<IconEyeOff size={14} />}
|
||||||
|
onClick={handleStopImpersonation}
|
||||||
|
>
|
||||||
|
Stop Impersonating
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
<Group h={60} px="md" justify="space-between">
|
||||||
<Group>
|
<Group>
|
||||||
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
|
||||||
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 40 }} />
|
<img src={logoSrc} alt="HOA LedgerIQ" style={{ height: 40 }} />
|
||||||
@@ -46,7 +75,7 @@ export function AppLayout() {
|
|||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Avatar size="sm" radius="xl" color="blue">
|
<Avatar size="sm" radius="xl" color={isImpersonating ? 'orange' : 'blue'}>
|
||||||
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text size="sm">{user?.firstName} {user?.lastName}</Text>
|
<Text size="sm">{user?.firstName} {user?.lastName}</Text>
|
||||||
@@ -55,6 +84,18 @@ export function AppLayout() {
|
|||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
|
{isImpersonating && (
|
||||||
|
<>
|
||||||
|
<Menu.Item
|
||||||
|
color="orange"
|
||||||
|
leftSection={<IconEyeOff size={14} />}
|
||||||
|
onClick={handleStopImpersonation}
|
||||||
|
>
|
||||||
|
Stop Impersonating
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Menu.Label>Account</Menu.Label>
|
<Menu.Label>Account</Menu.Label>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconUserCog size={14} />}
|
leftSection={<IconUserCog size={14} />}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
IconCategory,
|
IconCategory,
|
||||||
IconChartAreaLine,
|
IconChartAreaLine,
|
||||||
IconClipboardCheck,
|
IconClipboardCheck,
|
||||||
|
IconSparkles,
|
||||||
|
IconHeartRateMonitor,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ const navSections = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
|
{ label: 'Projects', icon: IconShieldCheck, path: '/projects' },
|
||||||
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
|
{ label: 'Capital Planning', icon: IconBuildingBank, path: '/capital-projects' },
|
||||||
|
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning' },
|
||||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -85,12 +88,44 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
|
const organizations = useAuthStore((s) => s.organizations);
|
||||||
|
const isAdminOnly = location.pathname.startsWith('/admin') && !currentOrg;
|
||||||
|
|
||||||
const go = (path: string) => {
|
const go = (path: string) => {
|
||||||
navigate(path);
|
navigate(path);
|
||||||
onNavigate?.();
|
onNavigate?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// When on admin route with no org selected, show admin-only sidebar
|
||||||
|
if (isAdminOnly && user?.isSuperadmin) {
|
||||||
|
return (
|
||||||
|
<ScrollArea p="sm">
|
||||||
|
<Text size="xs" c="dimmed" fw={700} tt="uppercase" px="sm" pb={4}>
|
||||||
|
Platform Administration
|
||||||
|
</Text>
|
||||||
|
<NavLink
|
||||||
|
label="Admin Panel"
|
||||||
|
leftSection={<IconCrown size={18} />}
|
||||||
|
active={location.pathname === '/admin'}
|
||||||
|
onClick={() => go('/admin')}
|
||||||
|
color="red"
|
||||||
|
/>
|
||||||
|
{organizations && organizations.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider my="sm" />
|
||||||
|
<NavLink
|
||||||
|
label="Switch to Tenant"
|
||||||
|
leftSection={<IconBuildingBank size={18} />}
|
||||||
|
onClick={() => go('/select-org')}
|
||||||
|
variant="subtle"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea p="sm">
|
<ScrollArea p="sm">
|
||||||
{navSections.map((section, sIdx) => (
|
{navSections.map((section, sIdx) => (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -38,8 +38,11 @@ export function LoginPage() {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.post('/auth/login', values);
|
const { data } = await api.post('/auth/login', values);
|
||||||
setAuth(data.accessToken, data.user, data.organizations);
|
setAuth(data.accessToken, data.user, data.organizations);
|
||||||
|
// Platform owner / superadmin with no orgs → admin panel
|
||||||
|
if (data.user?.isSuperadmin && data.organizations.length === 0) {
|
||||||
|
navigate('/admin');
|
||||||
|
} else if (data.organizations.length >= 1) {
|
||||||
// Always go through org selection to ensure correct JWT with orgSchema
|
// Always go through org selection to ensure correct JWT with orgSchema
|
||||||
if (data.organizations.length >= 1) {
|
|
||||||
navigate('/select-org');
|
navigate('/select-org');
|
||||||
} else {
|
} else {
|
||||||
navigate('/');
|
navigate('/');
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ export function SelectOrgPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filter out suspended/archived organizations (defense in depth)
|
||||||
|
const activeOrganizations = (organizations || []).filter(
|
||||||
|
(org: any) => !org.status || !['suspended', 'archived'].includes(org.status),
|
||||||
|
);
|
||||||
|
|
||||||
const handleSelect = async (org: any) => {
|
const handleSelect = async (org: any) => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('/auth/switch-org', {
|
const { data } = await api.post('/auth/switch-org', {
|
||||||
@@ -90,8 +95,15 @@ export function SelectOrgPage() {
|
|||||||
Choose an HOA to manage or create a new one
|
Choose an HOA to manage or create a new one
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Stack mt={30}>
|
{/* Filter out suspended/archived orgs (defense in depth — backend also filters) */}
|
||||||
{organizations.map((org) => (
|
{organizations.length > activeOrganizations.length && (
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} color="yellow" variant="light" mt="md">
|
||||||
|
Some organizations are currently suspended or archived and are not shown.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack mt={organizations.length > activeOrganizations.length ? 'sm' : 30}>
|
||||||
|
{activeOrganizations.map((org) => (
|
||||||
<Card
|
<Card
|
||||||
key={org.id}
|
key={org.id}
|
||||||
shadow="sm"
|
shadow="sm"
|
||||||
|
|||||||
@@ -0,0 +1,565 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Title,
|
||||||
|
Text,
|
||||||
|
Stack,
|
||||||
|
Card,
|
||||||
|
SimpleGrid,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
Badge,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
Alert,
|
||||||
|
ThemeIcon,
|
||||||
|
Divider,
|
||||||
|
Accordion,
|
||||||
|
Paper,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconBulb,
|
||||||
|
IconCash,
|
||||||
|
IconBuildingBank,
|
||||||
|
IconChartAreaLine,
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconSparkles,
|
||||||
|
IconRefresh,
|
||||||
|
IconCoin,
|
||||||
|
IconPigMoney,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
interface FinancialSummary {
|
||||||
|
operating_cash: number;
|
||||||
|
reserve_cash: number;
|
||||||
|
operating_investments: number;
|
||||||
|
reserve_investments: number;
|
||||||
|
total_operating: number;
|
||||||
|
total_reserve: number;
|
||||||
|
total_all: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FinancialSnapshot {
|
||||||
|
summary: FinancialSummary;
|
||||||
|
investment_accounts: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
institution: string;
|
||||||
|
investment_type: string;
|
||||||
|
fund_type: string;
|
||||||
|
principal: string;
|
||||||
|
interest_rate: string;
|
||||||
|
maturity_date: string | null;
|
||||||
|
current_value: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CdRate {
|
||||||
|
bank_name: string;
|
||||||
|
apy: string;
|
||||||
|
min_deposit: string | null;
|
||||||
|
term: string;
|
||||||
|
term_months: number | null;
|
||||||
|
fetched_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Recommendation {
|
||||||
|
type: string;
|
||||||
|
priority: 'high' | 'medium' | 'low';
|
||||||
|
title: string;
|
||||||
|
summary: string;
|
||||||
|
details: string;
|
||||||
|
fund_type: string;
|
||||||
|
suggested_amount?: number;
|
||||||
|
suggested_term?: string;
|
||||||
|
suggested_rate?: number;
|
||||||
|
bank_name?: string;
|
||||||
|
rationale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIResponse {
|
||||||
|
recommendations: Recommendation[];
|
||||||
|
overall_assessment: string;
|
||||||
|
risk_notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
const fmt = (v: number) =>
|
||||||
|
v.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
high: 'red',
|
||||||
|
medium: 'yellow',
|
||||||
|
low: 'blue',
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeIcons: Record<string, any> = {
|
||||||
|
cd_ladder: IconChartAreaLine,
|
||||||
|
new_investment: IconBuildingBank,
|
||||||
|
reallocation: IconRefresh,
|
||||||
|
maturity_action: IconCash,
|
||||||
|
liquidity_warning: IconAlertTriangle,
|
||||||
|
general: IconBulb,
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
cd_ladder: 'CD Ladder',
|
||||||
|
new_investment: 'New Investment',
|
||||||
|
reallocation: 'Reallocation',
|
||||||
|
maturity_action: 'Maturity Action',
|
||||||
|
liquidity_warning: 'Liquidity',
|
||||||
|
general: 'General',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
export function InvestmentPlanningPage() {
|
||||||
|
const [aiResult, setAiResult] = useState<AIResponse | null>(null);
|
||||||
|
|
||||||
|
// Load financial snapshot on mount
|
||||||
|
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
||||||
|
queryKey: ['investment-planning-snapshot'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/investment-planning/snapshot');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load CD rates on mount
|
||||||
|
const { data: cdRates = [], isLoading: ratesLoading } = useQuery<CdRate[]>({
|
||||||
|
queryKey: ['investment-planning-cd-rates'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get('/investment-planning/cd-rates');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// AI recommendation (on-demand)
|
||||||
|
const aiMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } = await api.post('/investment-planning/recommendations');
|
||||||
|
return data as AIResponse;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setAiResult(data);
|
||||||
|
if (data.recommendations.length > 0) {
|
||||||
|
notifications.show({
|
||||||
|
message: `Generated ${data.recommendations.length} investment recommendations`,
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({
|
||||||
|
message: err.response?.data?.message || 'Failed to get AI recommendations',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (snapshotLoading) {
|
||||||
|
return (
|
||||||
|
<Center h={400}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = snapshot?.summary;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
{/* Page Header */}
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Investment Planning</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Account overview, market rates, and AI-powered investment recommendations
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* ── Section 1: Financial Snapshot Cards ── */}
|
||||||
|
{s && (
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon variant="light" color="blue" size="sm">
|
||||||
|
<IconCash size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
Operating Cash
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="xl" ff="monospace">
|
||||||
|
{fmt(s.operating_cash)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Investments: {fmt(s.operating_investments)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon variant="light" color="violet" size="sm">
|
||||||
|
<IconPigMoney size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
Reserve Cash
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="xl" ff="monospace">
|
||||||
|
{fmt(s.reserve_cash)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Investments: {fmt(s.reserve_investments)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon variant="light" color="teal" size="sm">
|
||||||
|
<IconChartAreaLine size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
Total All Funds
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="xl" ff="monospace">
|
||||||
|
{fmt(s.total_all)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Operating: {fmt(s.total_operating)} | Reserve: {fmt(s.total_reserve)}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder p="md">
|
||||||
|
<Group gap="xs" mb={4}>
|
||||||
|
<ThemeIcon variant="light" color="green" size="sm">
|
||||||
|
<IconCoin size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
|
||||||
|
Total Invested
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text fw={700} size="xl" ff="monospace">
|
||||||
|
{fmt(s.operating_investments + s.reserve_investments)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Earning interest across all accounts
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Section 2: Current Investments Table ── */}
|
||||||
|
{snapshot?.investment_accounts && snapshot.investment_accounts.length > 0 && (
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
Current Investments
|
||||||
|
</Title>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>Institution</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Fund</Table.Th>
|
||||||
|
<Table.Th ta="right">Principal</Table.Th>
|
||||||
|
<Table.Th ta="right">Rate</Table.Th>
|
||||||
|
<Table.Th>Maturity</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{snapshot.investment_accounts.map((inv) => (
|
||||||
|
<Table.Tr key={inv.id}>
|
||||||
|
<Table.Td fw={500}>{inv.name}</Table.Td>
|
||||||
|
<Table.Td>{inv.institution || '-'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" variant="light">
|
||||||
|
{inv.investment_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
color={inv.fund_type === 'reserve' ? 'violet' : 'blue'}
|
||||||
|
>
|
||||||
|
{inv.fund_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
{fmt(parseFloat(inv.principal))}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
{parseFloat(inv.interest_rate || '0').toFixed(2)}%
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{inv.maturity_date
|
||||||
|
? new Date(inv.maturity_date).toLocaleDateString()
|
||||||
|
: '-'}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Section 3: Market CD Rates ── */}
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Title order={4}>Market CD Rates</Title>
|
||||||
|
{cdRates.length > 0 && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Last fetched: {new Date(cdRates[0].fetched_at).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{ratesLoading ? (
|
||||||
|
<Center py="lg">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Bank</Table.Th>
|
||||||
|
<Table.Th ta="right">APY</Table.Th>
|
||||||
|
<Table.Th>Term</Table.Th>
|
||||||
|
<Table.Th ta="right">Min Deposit</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{cdRates.map((r, i) => (
|
||||||
|
<Table.Tr key={i}>
|
||||||
|
<Table.Td fw={500}>{r.bank_name}</Table.Td>
|
||||||
|
<Table.Td ta="right" fw={700} c="green">
|
||||||
|
{parseFloat(r.apy).toFixed(2)}%
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{r.term}</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
{r.min_deposit
|
||||||
|
? `$${parseFloat(r.min_deposit).toLocaleString()}`
|
||||||
|
: '-'}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
{cdRates.length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={4}>
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
No CD rates available. Run the fetch-cd-rates script to populate market data.
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* ── Section 4: AI Investment Recommendations ── */}
|
||||||
|
<Card withBorder p="lg">
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon variant="light" color="grape" size="md">
|
||||||
|
<IconSparkles size={18} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={4}>AI Investment Recommendations</Title>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Powered by AI analysis of your complete financial picture
|
||||||
|
</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>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{aiMutation.isPending && (
|
||||||
|
<Center py="xl">
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<Loader size="lg" type="dots" />
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Analyzing your financial data and market rates...
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" size="xs">
|
||||||
|
This may take up to 30 seconds
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{aiResult && !aiMutation.isPending && (
|
||||||
|
<Stack>
|
||||||
|
{/* Overall Assessment */}
|
||||||
|
<Alert color="blue" variant="light" title="Overall Assessment">
|
||||||
|
<Text size="sm">{aiResult.overall_assessment}</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* Risk Notes */}
|
||||||
|
{aiResult.risk_notes && aiResult.risk_notes.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
title="Risk Notes"
|
||||||
|
icon={<IconAlertTriangle />}
|
||||||
|
>
|
||||||
|
<Stack gap={4}>
|
||||||
|
{aiResult.risk_notes.map((note, i) => (
|
||||||
|
<Text key={i} size="sm">
|
||||||
|
{note}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommendation Cards */}
|
||||||
|
{aiResult.recommendations.length > 0 ? (
|
||||||
|
<Accordion variant="separated">
|
||||||
|
{aiResult.recommendations.map((rec, i) => {
|
||||||
|
const Icon = typeIcons[rec.type] || IconBulb;
|
||||||
|
return (
|
||||||
|
<Accordion.Item key={i} value={`rec-${i}`}>
|
||||||
|
<Accordion.Control>
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="light"
|
||||||
|
color={priorityColors[rec.priority] || 'gray'}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Icon size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text fw={600}>{rec.title}</Text>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
color={priorityColors[rec.priority]}
|
||||||
|
>
|
||||||
|
{rec.priority}
|
||||||
|
</Badge>
|
||||||
|
<Badge size="xs" variant="light">
|
||||||
|
{typeLabels[rec.type] || rec.type}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="dot"
|
||||||
|
color={
|
||||||
|
rec.fund_type === 'reserve'
|
||||||
|
? 'violet'
|
||||||
|
: rec.fund_type === 'operating'
|
||||||
|
? 'blue'
|
||||||
|
: 'gray'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{rec.fund_type}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c="dimmed" mt={2}>
|
||||||
|
{rec.summary}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{rec.suggested_amount != null && (
|
||||||
|
<Text fw={700} ff="monospace" c="green" size="lg">
|
||||||
|
{fmt(rec.suggested_amount)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm">{rec.details}</Text>
|
||||||
|
|
||||||
|
{(rec.suggested_term ||
|
||||||
|
rec.suggested_rate != null ||
|
||||||
|
rec.bank_name) && (
|
||||||
|
<Paper withBorder p="sm" radius="sm">
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
{rec.suggested_term && (
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Suggested Term
|
||||||
|
</Text>
|
||||||
|
<Text fw={600}>{rec.suggested_term}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rec.suggested_rate != null && (
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Target Rate
|
||||||
|
</Text>
|
||||||
|
<Text fw={600}>
|
||||||
|
{rec.suggested_rate}% APY
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rec.bank_name && (
|
||||||
|
<div>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Bank
|
||||||
|
</Text>
|
||||||
|
<Text fw={600}>{rec.bank_name}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Alert variant="light" color="gray" title="Rationale">
|
||||||
|
<Text size="sm">{rec.rationale}</Text>
|
||||||
|
</Alert>
|
||||||
|
</Stack>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Accordion>
|
||||||
|
) : (
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
No specific recommendations at this time.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!aiResult && !aiMutation.isPending && (
|
||||||
|
<Paper p="xl" radius="sm" style={{ textAlign: 'center' }}>
|
||||||
|
<ThemeIcon variant="light" color="grape" size={48} mx="auto" mb="md">
|
||||||
|
<IconSparkles size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fw={500} mb={4}>
|
||||||
|
AI-Powered Investment Analysis
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" size="sm" maw={500} mx="auto">
|
||||||
|
Click "Get AI Recommendations" to analyze your accounts, cash flow,
|
||||||
|
budget, and capital projects against current market rates. The AI will
|
||||||
|
suggest specific investment moves to maximize interest income while
|
||||||
|
maintaining adequate liquidity.
|
||||||
|
</Text>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,16 @@ api.interceptors.response.use(
|
|||||||
useAuthStore.getState().logout();
|
useAuthStore.getState().logout();
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
// Handle org suspended/archived — redirect to org selection
|
||||||
|
if (
|
||||||
|
error.response?.status === 403 &&
|
||||||
|
typeof error.response?.data?.message === 'string' &&
|
||||||
|
error.response.data.message.includes('has been')
|
||||||
|
) {
|
||||||
|
const store = useAuthStore.getState();
|
||||||
|
store.setCurrentOrg({ id: '', name: '', role: '' }); // Clear current org
|
||||||
|
window.location.href = '/select-org';
|
||||||
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface Organization {
|
|||||||
name: string;
|
name: string;
|
||||||
role: string;
|
role: string;
|
||||||
schemaName?: string;
|
schemaName?: string;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -14,6 +15,14 @@ interface User {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
isSuperadmin?: boolean;
|
isSuperadmin?: boolean;
|
||||||
|
isPlatformOwner?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImpersonationOriginal {
|
||||||
|
token: string;
|
||||||
|
user: User;
|
||||||
|
organizations: Organization[];
|
||||||
|
currentOrg: Organization | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
@@ -21,18 +30,22 @@ interface AuthState {
|
|||||||
user: User | null;
|
user: User | null;
|
||||||
organizations: Organization[];
|
organizations: Organization[];
|
||||||
currentOrg: Organization | null;
|
currentOrg: Organization | null;
|
||||||
|
impersonationOriginal: ImpersonationOriginal | null;
|
||||||
setAuth: (token: string, user: User, organizations: Organization[]) => void;
|
setAuth: (token: string, user: User, organizations: Organization[]) => void;
|
||||||
setCurrentOrg: (org: Organization, token?: string) => void;
|
setCurrentOrg: (org: Organization, token?: string) => void;
|
||||||
|
startImpersonation: (token: string, user: User, organizations: Organization[]) => void;
|
||||||
|
stopImpersonation: () => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set, get) => ({
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
currentOrg: null,
|
currentOrg: null,
|
||||||
|
impersonationOriginal: null,
|
||||||
setAuth: (token, user, organizations) =>
|
setAuth: (token, user, organizations) =>
|
||||||
set({
|
set({
|
||||||
token,
|
token,
|
||||||
@@ -46,22 +59,51 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
currentOrg: org,
|
currentOrg: org,
|
||||||
token: token || state.token,
|
token: token || state.token,
|
||||||
})),
|
})),
|
||||||
|
startImpersonation: (token, user, organizations) => {
|
||||||
|
const state = get();
|
||||||
|
set({
|
||||||
|
impersonationOriginal: {
|
||||||
|
token: state.token!,
|
||||||
|
user: state.user!,
|
||||||
|
organizations: state.organizations,
|
||||||
|
currentOrg: state.currentOrg,
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
organizations,
|
||||||
|
currentOrg: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
stopImpersonation: () => {
|
||||||
|
const { impersonationOriginal } = get();
|
||||||
|
if (impersonationOriginal) {
|
||||||
|
set({
|
||||||
|
token: impersonationOriginal.token,
|
||||||
|
user: impersonationOriginal.user,
|
||||||
|
organizations: impersonationOriginal.organizations,
|
||||||
|
currentOrg: impersonationOriginal.currentOrg,
|
||||||
|
impersonationOriginal: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
logout: () =>
|
logout: () =>
|
||||||
set({
|
set({
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
currentOrg: null,
|
currentOrg: null,
|
||||||
|
impersonationOriginal: null,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ledgeriq-auth',
|
name: 'ledgeriq-auth',
|
||||||
version: 3,
|
version: 4,
|
||||||
migrate: () => ({
|
migrate: () => ({
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
currentOrg: null,
|
currentOrg: null,
|
||||||
|
impersonationOriginal: null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -23,6 +23,22 @@ server {
|
|||||||
proxy_cache_bypass $http_upgrade;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
# Everything else -> Vite dev server (frontend)
|
# Everything else -> Vite dev server (frontend)
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://frontend;
|
proxy_pass http://frontend;
|
||||||
|
|||||||
47
scripts/README.md
Normal file
47
scripts/README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# HOA LedgerIQ - Scripts
|
||||||
|
|
||||||
|
Standalone scripts for data fetching, maintenance, and automation tasks.
|
||||||
|
|
||||||
|
## CD Rate Fetcher
|
||||||
|
|
||||||
|
Scrapes the top 25 CD rates from [Bankrate.com](https://www.bankrate.com/banking/cds/cd-rates/) and stores them in the `shared.cd_rates` PostgreSQL table.
|
||||||
|
|
||||||
|
**Note:** Bankrate renders rate data dynamically via JavaScript, so this script uses Puppeteer (headless Chrome) to fully render the page before extracting data.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- PostgreSQL with the `shared.cd_rates` table (created by `db/init/00-init.sql` or `db/migrations/005-cd-rates.sql`)
|
||||||
|
- A `.env` file at the project root with `DATABASE_URL`
|
||||||
|
|
||||||
|
### Manual Execution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd scripts
|
||||||
|
npm install
|
||||||
|
npx tsx fetch-cd-rates.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cron Setup
|
||||||
|
|
||||||
|
To run daily at 6:00 AM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Edit crontab
|
||||||
|
crontab -e
|
||||||
|
|
||||||
|
# Add this line (adjust path to your project directory):
|
||||||
|
0 6 * * * cd /path/to/HOA_Financial_Platform/scripts && /usr/local/bin/npx tsx fetch-cd-rates.ts >> /var/log/hoa-cd-rates.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
For Docker-based deployments, you can use a host cron job that executes into the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
0 6 * * * docker exec hoa-backend sh -c "cd /app/scripts && npx tsx fetch-cd-rates.ts" >> /var/log/hoa-cd-rates.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
- **0 rates extracted**: Bankrate likely changed their page structure. Inspect the page DOM in a browser and update the CSS selectors in `fetch-cd-rates.ts`.
|
||||||
|
- **Database connection error**: Verify `DATABASE_URL` in `.env` points to the correct PostgreSQL instance. For local development (outside Docker), use `localhost:5432` instead of `postgres:5432`.
|
||||||
|
- **Puppeteer launch error**: Ensure Chromium dependencies are installed. On Ubuntu: `apt-get install -y libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libasound2`
|
||||||
403
scripts/fetch-cd-rates.ts
Normal file
403
scripts/fetch-cd-rates.ts
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
#!/usr/bin/env tsx
|
||||||
|
/**
|
||||||
|
* CD Rate Fetcher Script
|
||||||
|
*
|
||||||
|
* Scrapes the top CD rates from Bankrate.com and stores them in the
|
||||||
|
* shared.cd_rates table in PostgreSQL. Designed to run standalone via cron.
|
||||||
|
*
|
||||||
|
* Bankrate renders rate data dynamically via JavaScript, so this script
|
||||||
|
* uses Puppeteer (headless Chrome) to fully render the page before scraping.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* cd scripts
|
||||||
|
* npm install
|
||||||
|
* npx tsx fetch-cd-rates.ts
|
||||||
|
*
|
||||||
|
* Environment:
|
||||||
|
* DATABASE_URL - PostgreSQL connection string (reads from ../.env)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { Pool } from 'pg';
|
||||||
|
import puppeteer, { type Browser } from 'puppeteer';
|
||||||
|
|
||||||
|
// Load .env from project root
|
||||||
|
dotenv.config({ path: resolve(__dirname, '..', '.env') });
|
||||||
|
|
||||||
|
const BANKRATE_URL = 'https://www.bankrate.com/banking/cds/cd-rates/';
|
||||||
|
const MAX_RATES = 25;
|
||||||
|
|
||||||
|
interface CdRate {
|
||||||
|
bank_name: string;
|
||||||
|
apy: number;
|
||||||
|
min_deposit: number | null;
|
||||||
|
term: string;
|
||||||
|
term_months: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a term string like "3 months", "1 year", "18 months" into a month count.
|
||||||
|
*/
|
||||||
|
function parseTermMonths(term: string): number | null {
|
||||||
|
const lower = term.toLowerCase().trim();
|
||||||
|
const monthMatch = lower.match(/(\d+)\s*month/);
|
||||||
|
if (monthMatch) return parseInt(monthMatch[1], 10);
|
||||||
|
const yearMatch = lower.match(/(\d+)\s*year/);
|
||||||
|
if (yearMatch) return parseInt(yearMatch[1], 10) * 12;
|
||||||
|
// Handle fractional years like "1.5 years"
|
||||||
|
const fracYearMatch = lower.match(/([\d.]+)\s*year/);
|
||||||
|
if (fracYearMatch) return Math.round(parseFloat(fracYearMatch[1]) * 12);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a currency string like "$500", "$1,000", "$0", "No minimum" into a number or null.
|
||||||
|
*/
|
||||||
|
function parseMinDeposit(raw: string): number | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const cleaned = raw.replace(/[^0-9.]/g, '');
|
||||||
|
if (!cleaned) return null;
|
||||||
|
const val = parseFloat(cleaned);
|
||||||
|
return isNaN(val) ? null : val;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an APY string like "4.50%", "4.50% APY" into a number.
|
||||||
|
*/
|
||||||
|
function parseApy(raw: string): number {
|
||||||
|
const cleaned = raw.replace(/[^0-9.]/g, '');
|
||||||
|
return parseFloat(cleaned) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch headless Chrome, navigate to Bankrate, and scrape CD rate data.
|
||||||
|
*/
|
||||||
|
async function fetchRates(): Promise<CdRate[]> {
|
||||||
|
let browser: Browser | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Launching headless browser...');
|
||||||
|
browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setUserAgent(
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Navigating to ${BANKRATE_URL}...`);
|
||||||
|
await page.goto(BANKRATE_URL, {
|
||||||
|
waitUntil: 'networkidle2',
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for rate content to render
|
||||||
|
// Bankrate uses various table/card patterns; we'll try multiple selectors
|
||||||
|
console.log('Waiting for rate data to render...');
|
||||||
|
await page.waitForSelector(
|
||||||
|
'table, [data-testid*="rate"], .brc-table, [class*="ComparisonTable"], [class*="rate-table"]',
|
||||||
|
{ timeout: 30000 },
|
||||||
|
).catch(() => {
|
||||||
|
console.log('Primary selectors not found, proceeding with page scan...');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extra wait for dynamic content
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
|
// Scroll down to load all content (rate tables may be below the fold)
|
||||||
|
console.log('Scrolling to load all content...');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
window.scrollBy(0, 800);
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
// Extract rate data from the page using multiple strategies
|
||||||
|
const rates = await page.evaluate((maxRates: number) => {
|
||||||
|
const results: Array<{
|
||||||
|
bank_name: string;
|
||||||
|
apy_raw: string;
|
||||||
|
min_deposit_raw: string;
|
||||||
|
term_raw: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Strategy 1: Look for detailed bank comparison tables with named banks
|
||||||
|
// These typically have 4+ columns: Bank, APY, Min Deposit, Term
|
||||||
|
const tables = document.querySelectorAll('table');
|
||||||
|
for (const table of tables) {
|
||||||
|
const rows = table.querySelectorAll('tbody tr');
|
||||||
|
if (rows.length < 3) continue; // Skip small tables
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const cells = row.querySelectorAll('td, th');
|
||||||
|
if (cells.length < 3) continue;
|
||||||
|
|
||||||
|
const texts = Array.from(cells).map((c) => c.textContent?.trim() || '');
|
||||||
|
const apyCell = texts.find((t) => /\d+\.\d+\s*%/.test(t));
|
||||||
|
if (!apyCell) continue;
|
||||||
|
|
||||||
|
// Bank name: look for a cell with a real name (not just number/percent/dollar)
|
||||||
|
const bankCell = texts.find(
|
||||||
|
(t) =>
|
||||||
|
t.length > 3 &&
|
||||||
|
!/^\d/.test(t) &&
|
||||||
|
!t.includes('%') &&
|
||||||
|
!t.startsWith('$') &&
|
||||||
|
!/^\d+\s*(month|year)/i.test(t),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also try to find the bank name from links or images in the row
|
||||||
|
const linkEl = row.querySelector('a[href*="review"], a[href*="bank"], img[alt]');
|
||||||
|
const linkName = linkEl?.textContent?.trim() || (linkEl as HTMLImageElement)?.alt || '';
|
||||||
|
|
||||||
|
const name = linkName.length > 3 ? linkName : bankCell || '';
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
bank_name: name,
|
||||||
|
apy_raw: apyCell,
|
||||||
|
min_deposit_raw:
|
||||||
|
texts.find((t) => t.includes('$') || /no min/i.test(t)) || '',
|
||||||
|
term_raw: texts.find((t) => /\d+\s*(month|year)/i.test(t)) || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results.length >= maxRates) break;
|
||||||
|
}
|
||||||
|
if (results.length >= 5) break; // Found a good table
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: Look for card/list layouts with bank names and rates
|
||||||
|
if (results.length < 5) {
|
||||||
|
const cardSelectors = [
|
||||||
|
'[class*="product"]',
|
||||||
|
'[class*="offer-card"]',
|
||||||
|
'[class*="rate-card"]',
|
||||||
|
'[class*="ComparisonRow"]',
|
||||||
|
'[class*="comparison-row"]',
|
||||||
|
'[data-testid*="product"]',
|
||||||
|
'[class*="partner"]',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const selector of cardSelectors) {
|
||||||
|
const cards = document.querySelectorAll(selector);
|
||||||
|
if (cards.length < 3) continue;
|
||||||
|
|
||||||
|
for (const card of cards) {
|
||||||
|
const text = card.textContent || '';
|
||||||
|
if (text.length < 20 || text.length > 2000) continue;
|
||||||
|
|
||||||
|
const apyMatch = text.match(/([\d.]+)\s*%/);
|
||||||
|
if (!apyMatch) continue;
|
||||||
|
|
||||||
|
// Try to find bank name from heading, link, or image alt text
|
||||||
|
const nameEl =
|
||||||
|
card.querySelector(
|
||||||
|
'h2, h3, h4, h5, strong, [class*="name"], [class*="bank"], [class*="title"], a[href*="review"], img[alt]',
|
||||||
|
);
|
||||||
|
let bankName = nameEl?.textContent?.trim() || (nameEl as HTMLImageElement)?.alt || '';
|
||||||
|
|
||||||
|
// Skip if the "name" is just a rate or term
|
||||||
|
if (!bankName || bankName.length < 3 || /^\d/.test(bankName) || bankName.includes('%')) continue;
|
||||||
|
|
||||||
|
const depositMatch = text.match(/\$[\d,]+/);
|
||||||
|
const termMatch = text.match(/\d+\s*(?:month|year)s?/i);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
bank_name: bankName,
|
||||||
|
apy_raw: apyMatch[0],
|
||||||
|
min_deposit_raw: depositMatch?.[0] || '',
|
||||||
|
term_raw: termMatch?.[0] || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results.length >= maxRates) break;
|
||||||
|
}
|
||||||
|
if (results.length >= 5) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: Broad scan for rate-bearing elements
|
||||||
|
if (results.length < 5) {
|
||||||
|
const allElements = document.querySelectorAll(
|
||||||
|
'div, section, article, li',
|
||||||
|
);
|
||||||
|
for (const el of allElements) {
|
||||||
|
if (el.children.length > 20) continue;
|
||||||
|
const text = el.textContent || '';
|
||||||
|
if (text.length < 20 || text.length > 500) continue;
|
||||||
|
|
||||||
|
const apyMatch = text.match(/([\d.]+)\s*%\s*(?:APY)?/i);
|
||||||
|
if (!apyMatch) continue;
|
||||||
|
|
||||||
|
const bankEl = el.querySelector(
|
||||||
|
'h2, h3, h4, h5, strong, b, a[href*="review"]',
|
||||||
|
);
|
||||||
|
let bankName = bankEl?.textContent?.trim() || '';
|
||||||
|
if (!bankName || bankName.length < 3 || /^\d/.test(bankName)) continue;
|
||||||
|
|
||||||
|
const depositMatch = text.match(/\$[\d,]+/);
|
||||||
|
const termMatch = text.match(/\d+\s*(?:month|year)s?/i);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
bank_name: bankName,
|
||||||
|
apy_raw: apyMatch[0],
|
||||||
|
min_deposit_raw: depositMatch?.[0] || '',
|
||||||
|
term_raw: termMatch?.[0] || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (results.length >= maxRates) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}, MAX_RATES);
|
||||||
|
|
||||||
|
console.log(`Raw extraction found ${rates.length} rate entries.`);
|
||||||
|
|
||||||
|
// Parse and normalize the scraped data
|
||||||
|
const parsed: CdRate[] = rates
|
||||||
|
.map((r) => {
|
||||||
|
let bankName = r.bank_name.replace(/\s+/g, ' ').trim();
|
||||||
|
const term = r.term_raw || 'N/A';
|
||||||
|
|
||||||
|
// If the bank name looks like a term or deposit info, it's a
|
||||||
|
// summary card — label it more descriptively using the term
|
||||||
|
const termText = r.term_raw || bankName;
|
||||||
|
if (
|
||||||
|
/^\d+\s*(month|year)/i.test(bankName) ||
|
||||||
|
/no\s*min/i.test(bankName) ||
|
||||||
|
/^\$/.test(bankName) ||
|
||||||
|
bankName.length < 4
|
||||||
|
) {
|
||||||
|
bankName = `Top CD Rate - ${termText.replace(/^\d+/, (m: string) => m + ' ')}`.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bank_name: bankName,
|
||||||
|
apy: parseApy(r.apy_raw),
|
||||||
|
min_deposit: parseMinDeposit(r.min_deposit_raw),
|
||||||
|
term,
|
||||||
|
term_months: parseTermMonths(r.term_raw || bankName),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((r) => r.bank_name && r.apy > 0);
|
||||||
|
|
||||||
|
// Deduplicate by bank name + term (keep highest APY)
|
||||||
|
const seen = new Map<string, CdRate>();
|
||||||
|
for (const rate of parsed) {
|
||||||
|
const key = `${rate.bank_name}|${rate.term}`;
|
||||||
|
const existing = seen.get(key);
|
||||||
|
if (!existing || rate.apy > existing.apy) {
|
||||||
|
seen.set(key, rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(seen.values())
|
||||||
|
.sort((a, b) => b.apy - a.apy)
|
||||||
|
.slice(0, MAX_RATES);
|
||||||
|
} finally {
|
||||||
|
if (browser) {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store scraped rates into shared.cd_rates, replacing all previous data.
|
||||||
|
*/
|
||||||
|
async function storeRates(rates: CdRate[]): Promise<void> {
|
||||||
|
const connectionString =
|
||||||
|
process.env.DATABASE_URL ||
|
||||||
|
'postgresql://hoafinance:change_me@localhost:5432/hoafinance';
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
// Clear previous batch (we only keep the latest fetch)
|
||||||
|
await client.query('DELETE FROM shared.cd_rates');
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
for (const rate of rates) {
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO shared.cd_rates
|
||||||
|
(bank_name, apy, min_deposit, term, term_months, fetched_at, source_url)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||||
|
[
|
||||||
|
rate.bank_name,
|
||||||
|
rate.apy,
|
||||||
|
rate.min_deposit,
|
||||||
|
rate.term,
|
||||||
|
rate.term_months,
|
||||||
|
now,
|
||||||
|
BANKRATE_URL,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
console.log(`Successfully stored ${rates.length} CD rates at ${now}`);
|
||||||
|
} catch (err) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point.
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
console.log('=== CD Rate Fetcher ===');
|
||||||
|
console.log(`Fetching top CD rates from Bankrate.com...`);
|
||||||
|
console.log(`Time: ${new Date().toISOString()}`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rates = await fetchRates();
|
||||||
|
|
||||||
|
if (rates.length === 0) {
|
||||||
|
console.warn('');
|
||||||
|
console.warn('WARNING: No CD rates were extracted from Bankrate.');
|
||||||
|
console.warn(
|
||||||
|
'This likely means Bankrate changed their page structure.',
|
||||||
|
);
|
||||||
|
console.warn(
|
||||||
|
'Review the page DOM and update selectors in fetch-cd-rates.ts.',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nExtracted ${rates.length} rates:`);
|
||||||
|
console.log('─'.repeat(70));
|
||||||
|
for (const r of rates) {
|
||||||
|
console.log(
|
||||||
|
` ${r.bank_name.padEnd(30)} ${String(r.apy + '%').padEnd(8)} ${r.term.padEnd(15)} ${r.min_deposit != null ? '$' + r.min_deposit.toLocaleString() : 'N/A'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log('─'.repeat(70));
|
||||||
|
|
||||||
|
console.log('\nStoring to database...');
|
||||||
|
await storeRates(rates);
|
||||||
|
console.log('Done.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('\nFATAL ERROR:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
1945
scripts/package-lock.json
generated
Normal file
1945
scripts/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
scripts/package.json
Normal file
19
scripts/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "hoa-ledgeriq-scripts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Standalone scripts for HOA LedgerIQ platform (cron jobs, data fetching)",
|
||||||
|
"scripts": {
|
||||||
|
"fetch-cd-rates": "tsx fetch-cd-rates.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"puppeteer": "^23.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/pg": "^8.11.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user