Compare commits
31 Commits
06bc0181f8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4df796e977 | |||
| a7e3f80eda | |||
| 19bd19b0c4 | |||
| 3e7463cf46 | |||
| cefcc296fb | |||
| 2aad137bd7 | |||
| f5bea7cdc2 | |||
| e06ca74d1d | |||
| 5144da4680 | |||
| 95c83a57b6 | |||
| 83115c9b5c | |||
| c57dd3e155 | |||
| afe5633b0a | |||
| 43b10869f0 | |||
| f76c67f51a | |||
| 5fec296569 | |||
| c981676bc7 | |||
|
|
bd174fc22b | ||
| 827eef4f49 | |||
|
|
4797669591 | ||
| 629d112850 | |||
| 32506d6a2e | |||
| 9a60970837 | |||
| 1ade446187 | |||
|
|
d430b96b51 | ||
|
|
140cd7acb7 | ||
| 2f6297ae68 | |||
| 121b8138e3 | |||
| 2b331bb3ef | |||
| ae856bfb2f | |||
| 31f8274b8d |
65
.gitea/workflows/deploy.yml
Normal file
65
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Production Deployment Workflow for HOA LedgerIQ
|
||||||
|
#
|
||||||
|
# Trigger: Manual only (workflow_dispatch) — production deploys are intentional.
|
||||||
|
# Runner: Self-hosted on the production server at /opt/hoa-ledgeriq.
|
||||||
|
#
|
||||||
|
# This workflow does NOT use actions/checkout. The runner operates directly
|
||||||
|
# on the production directory. The deploy script itself handles git pull.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
name: Deploy to Production
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
seed_existing:
|
||||||
|
description: "Mark existing migrations as applied without running them (first deployment only)"
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: /opt/hoa-ledgeriq
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Pre-deploy info
|
||||||
|
run: |
|
||||||
|
echo "## Pre-Deploy Info" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Server:** $(hostname)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Directory:** $(pwd)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Current commit:** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Branch:** $(git branch --show-current || echo 'detached')" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Triggered by:** ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Seed existing:** ${{ inputs.seed_existing }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Started at:** $(date -Iseconds)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
- name: Run deployment
|
||||||
|
run: |
|
||||||
|
DEPLOY_FLAGS=""
|
||||||
|
if [ "${{ inputs.seed_existing }}" = "true" ]; then
|
||||||
|
DEPLOY_FLAGS="--seed-existing"
|
||||||
|
fi
|
||||||
|
bash scripts/deploy-prod.sh $DEPLOY_FLAGS
|
||||||
|
env:
|
||||||
|
TERM: xterm
|
||||||
|
|
||||||
|
- name: Deployment result
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "## Deployment Result" >> $GITHUB_STEP_SUMMARY
|
||||||
|
if [ "${{ job.status }}" = "success" ]; then
|
||||||
|
echo "- **Status:** Successful" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Commit:** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "- **Status:** FAILED (auto-rollback triggered)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- **Commit (after rollback):** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Check the deploy log on the server for details" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
echo "- **Completed at:** $(date -Iseconds)" >> $GITHUB_STEP_SUMMARY
|
||||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.3.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.3.19",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.4.15",
|
"@nestjs/common": "^10.4.15",
|
||||||
"@nestjs/config": "^3.3.0",
|
"@nestjs/config": "^3.3.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-backend",
|
"name": "hoa-ledgeriq-backend",
|
||||||
"version": "2026.3.19",
|
"version": "2026.3.24",
|
||||||
"description": "HOA LedgerIQ - Backend API",
|
"description": "HOA LedgerIQ - Backend API",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { AppController } from './app.controller';
|
|||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { TenantMiddleware } from './database/tenant.middleware';
|
import { TenantMiddleware } from './database/tenant.middleware';
|
||||||
import { WriteAccessGuard } from './common/guards/write-access.guard';
|
import { WriteAccessGuard } from './common/guards/write-access.guard';
|
||||||
|
import { CapabilityGuard } from './common/guards/capability.guard';
|
||||||
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
|
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
||||||
@@ -33,6 +34,8 @@ import { BoardPlanningModule } from './modules/board-planning/board-planning.mod
|
|||||||
import { BillingModule } from './modules/billing/billing.module';
|
import { BillingModule } from './modules/billing/billing.module';
|
||||||
import { EmailModule } from './modules/email/email.module';
|
import { EmailModule } from './modules/email/email.module';
|
||||||
import { OnboardingModule } from './modules/onboarding/onboarding.module';
|
import { OnboardingModule } from './modules/onboarding/onboarding.module';
|
||||||
|
import { IdeasModule } from './modules/ideas/ideas.module';
|
||||||
|
import { ShadowAiModule } from './modules/shadow-ai/shadow-ai.module';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -88,6 +91,8 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
BillingModule,
|
BillingModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
OnboardingModule,
|
OnboardingModule,
|
||||||
|
IdeasModule,
|
||||||
|
ShadowAiModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
@@ -96,6 +101,10 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: WriteAccessGuard,
|
useClass: WriteAccessGuard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: CapabilityGuard,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: NoCacheInterceptor,
|
useClass: NoCacheInterceptor,
|
||||||
|
|||||||
14
backend/src/common/decorators/capability.decorator.ts
Normal file
14
backend/src/common/decorators/capability.decorator.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CAPABILITIES_KEY = 'required_capabilities';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decorator to require specific capabilities on an endpoint.
|
||||||
|
* User must have ALL listed capabilities to access the endpoint.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* @RequireCapability('financials.accounts.edit')
|
||||||
|
* @RequireCapability('financials.accounts.view', 'financials.accounts.edit')
|
||||||
|
*/
|
||||||
|
export const RequireCapability = (...capabilities: string[]) =>
|
||||||
|
SetMetadata(CAPABILITIES_KEY, capabilities);
|
||||||
83
backend/src/common/guards/capability.guard.ts
Normal file
83
backend/src/common/guards/capability.guard.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { CAPABILITIES_KEY } from '../decorators/capability.decorator';
|
||||||
|
import { resolveCapabilities } from '../permissions';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CapabilityGuard implements CanActivate {
|
||||||
|
// Cache org settings (including permissionOverrides) per orgId
|
||||||
|
private settingsCache = new Map<string, { settings: Record<string, any>; cachedAt: number }>();
|
||||||
|
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private reflector: Reflector,
|
||||||
|
private dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const requiredCapabilities = this.reflector.getAllAndOverride<string[]>(CAPABILITIES_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// No capabilities required — pass through (backward compatible)
|
||||||
|
if (!requiredCapabilities || requiredCapabilities.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
// No authenticated user — let other guards handle auth
|
||||||
|
if (!user) return true;
|
||||||
|
|
||||||
|
// Superadmins bypass all capability checks
|
||||||
|
if (user.isSuperadmin) return true;
|
||||||
|
|
||||||
|
const role = user.role;
|
||||||
|
const orgId = user.orgId;
|
||||||
|
|
||||||
|
if (!role || !orgId) return true;
|
||||||
|
|
||||||
|
// Get org settings (with caching)
|
||||||
|
const settings = await this.getOrgSettings(orgId);
|
||||||
|
const userCapabilities = resolveCapabilities(role, settings?.permissionOverrides);
|
||||||
|
|
||||||
|
// User must have ALL required capabilities
|
||||||
|
const hasAll = requiredCapabilities.every((cap) => userCapabilities.has(cap));
|
||||||
|
if (!hasAll) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'You do not have the required permissions for this action.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrgSettings(orgId: string): Promise<Record<string, any> | null> {
|
||||||
|
const cached = this.settingsCache.get(orgId);
|
||||||
|
if (cached && Date.now() - cached.cachedAt < CapabilityGuard.CACHE_TTL) {
|
||||||
|
return cached.settings;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await this.dataSource.query(
|
||||||
|
`SELECT settings FROM shared.organizations WHERE id = $1`,
|
||||||
|
[orgId],
|
||||||
|
);
|
||||||
|
if (result.length > 0) {
|
||||||
|
const settings = result[0].settings || {};
|
||||||
|
this.settingsCache.set(orgId, { settings, cachedAt: Date.now() });
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical — fall through to use defaults only
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear cached settings for an org (call after settings update) */
|
||||||
|
clearCache(orgId: string) {
|
||||||
|
this.settingsCache.delete(orgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
backend/src/common/permissions/capabilities.ts
Normal file
65
backend/src/common/permissions/capabilities.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Capability taxonomy for the HOA Financial Platform.
|
||||||
|
*
|
||||||
|
* Pattern: {area}.{feature}.{action}
|
||||||
|
* Actions: view, edit, approve, manage
|
||||||
|
*
|
||||||
|
* Add new capabilities here when new features are built.
|
||||||
|
* The default role matrix in ./default-role-capabilities.ts must also be updated.
|
||||||
|
*/
|
||||||
|
export const CAPABILITIES = {
|
||||||
|
// Dashboard
|
||||||
|
DASHBOARD_VIEW: 'dashboard.view',
|
||||||
|
|
||||||
|
// Financials
|
||||||
|
FINANCIALS_ACCOUNTS_VIEW: 'financials.accounts.view',
|
||||||
|
FINANCIALS_ACCOUNTS_EDIT: 'financials.accounts.edit',
|
||||||
|
FINANCIALS_CASHFLOW_VIEW: 'financials.cashflow.view',
|
||||||
|
FINANCIALS_CASHFLOW_EDIT: 'financials.cashflow.edit',
|
||||||
|
FINANCIALS_ACTUALS_VIEW: 'financials.actuals.view',
|
||||||
|
FINANCIALS_ACTUALS_EDIT: 'financials.actuals.edit',
|
||||||
|
FINANCIALS_BUDGETS_VIEW: 'financials.budgets.view',
|
||||||
|
FINANCIALS_BUDGETS_EDIT: 'financials.budgets.edit',
|
||||||
|
FINANCIALS_BUDGETS_APPROVE: 'financials.budgets.approve',
|
||||||
|
|
||||||
|
// Assessments
|
||||||
|
ASSESSMENTS_UNITS_VIEW: 'assessments.units.view',
|
||||||
|
ASSESSMENTS_UNITS_EDIT: 'assessments.units.edit',
|
||||||
|
ASSESSMENTS_GROUPS_VIEW: 'assessments.groups.view',
|
||||||
|
ASSESSMENTS_GROUPS_EDIT: 'assessments.groups.edit',
|
||||||
|
|
||||||
|
// Board Planning
|
||||||
|
PLANNING_BUDGETS_VIEW: 'planning.budgets.view',
|
||||||
|
PLANNING_BUDGETS_EDIT: 'planning.budgets.edit',
|
||||||
|
PLANNING_PROJECTS_VIEW: 'planning.projects.view',
|
||||||
|
PLANNING_PROJECTS_EDIT: 'planning.projects.edit',
|
||||||
|
PLANNING_SCENARIOS_VIEW: 'planning.scenarios.view',
|
||||||
|
PLANNING_SCENARIOS_EDIT: 'planning.scenarios.edit',
|
||||||
|
PLANNING_SCENARIOS_APPROVE: 'planning.scenarios.approve',
|
||||||
|
PLANNING_INVESTMENTS_VIEW: 'planning.investments.view',
|
||||||
|
PLANNING_INVESTMENTS_EDIT: 'planning.investments.edit',
|
||||||
|
|
||||||
|
// Board Reference
|
||||||
|
REFERENCE_VENDORS_VIEW: 'reference.vendors.view',
|
||||||
|
REFERENCE_VENDORS_EDIT: 'reference.vendors.edit',
|
||||||
|
|
||||||
|
// Transactions
|
||||||
|
TRANSACTIONS_VIEW: 'transactions.view',
|
||||||
|
TRANSACTIONS_EDIT: 'transactions.edit',
|
||||||
|
TRANSACTIONS_APPROVE: 'transactions.approve',
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
REPORTS_VIEW: 'reports.view',
|
||||||
|
|
||||||
|
// Settings & Administration
|
||||||
|
SETTINGS_ORG_VIEW: 'settings.org.view',
|
||||||
|
SETTINGS_ORG_EDIT: 'settings.org.edit',
|
||||||
|
SETTINGS_MEMBERS_VIEW: 'settings.members.view',
|
||||||
|
SETTINGS_MEMBERS_MANAGE: 'settings.members.manage',
|
||||||
|
SETTINGS_PERMISSIONS_MANAGE: 'settings.permissions.manage',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
|
||||||
|
|
||||||
|
/** Set of all valid capability strings, for validation */
|
||||||
|
export const ALL_CAPABILITIES = new Set<string>(Object.values(CAPABILITIES));
|
||||||
157
backend/src/common/permissions/default-role-capabilities.ts
Normal file
157
backend/src/common/permissions/default-role-capabilities.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { CAPABILITIES, Capability } from './capabilities';
|
||||||
|
|
||||||
|
const C = CAPABILITIES;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default capability sets per role.
|
||||||
|
*
|
||||||
|
* These represent sensible defaults for a typical HOA. Tenant admins can
|
||||||
|
* customize per-role capabilities via permission overrides in org settings.
|
||||||
|
*
|
||||||
|
* Roles not listed here (e.g. unknown future roles) get zero capabilities.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ROLE_CAPABILITIES: Record<string, readonly Capability[]> = {
|
||||||
|
president: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||||
|
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||||
|
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||||
|
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||||
|
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
|
||||||
|
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
|
||||||
|
C.SETTINGS_PERMISSIONS_MANAGE,
|
||||||
|
],
|
||||||
|
|
||||||
|
admin: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||||
|
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||||
|
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||||
|
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||||
|
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
|
||||||
|
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
|
||||||
|
C.SETTINGS_PERMISSIONS_MANAGE,
|
||||||
|
],
|
||||||
|
|
||||||
|
vice_president: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.PLANNING_BUDGETS_VIEW,
|
||||||
|
C.PLANNING_PROJECTS_VIEW,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW,
|
||||||
|
C.TRANSACTIONS_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
C.SETTINGS_ORG_VIEW,
|
||||||
|
C.SETTINGS_MEMBERS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
treasurer: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||||
|
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||||
|
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||||
|
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||||
|
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
C.SETTINGS_MEMBERS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
secretary: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.PLANNING_BUDGETS_VIEW,
|
||||||
|
C.PLANNING_PROJECTS_VIEW,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
member_at_large: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.PLANNING_BUDGETS_VIEW,
|
||||||
|
C.PLANNING_PROJECTS_VIEW,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
manager: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||||
|
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
homeowner: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
viewer: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.PLANNING_BUDGETS_VIEW,
|
||||||
|
C.PLANNING_PROJECTS_VIEW,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW,
|
||||||
|
C.TRANSACTIONS_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
};
|
||||||
5
backend/src/common/permissions/index.ts
Normal file
5
backend/src/common/permissions/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { CAPABILITIES, ALL_CAPABILITIES } from './capabilities';
|
||||||
|
export type { Capability } from './capabilities';
|
||||||
|
export { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
||||||
|
export { resolveCapabilities, resolveCapabilitiesArray } from './resolve-permissions';
|
||||||
|
export type { PermissionOverrides } from './resolve-permissions';
|
||||||
57
backend/src/common/permissions/resolve-permissions.ts
Normal file
57
backend/src/common/permissions/resolve-permissions.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { ALL_CAPABILITIES } from './capabilities';
|
||||||
|
import { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
||||||
|
|
||||||
|
export interface PermissionOverrides {
|
||||||
|
[role: string]: {
|
||||||
|
grant?: string[];
|
||||||
|
revoke?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve effective capabilities for a role, applying tenant overrides.
|
||||||
|
*
|
||||||
|
* 1. Start with default capabilities for the role
|
||||||
|
* 2. Add any granted capabilities from overrides
|
||||||
|
* 3. Remove any revoked capabilities from overrides
|
||||||
|
*
|
||||||
|
* Unknown capabilities in grant/revoke are silently ignored (they may
|
||||||
|
* come from an older version of the overrides).
|
||||||
|
*/
|
||||||
|
export function resolveCapabilities(
|
||||||
|
role: string,
|
||||||
|
overrides?: PermissionOverrides | null,
|
||||||
|
): Set<string> {
|
||||||
|
const defaults = DEFAULT_ROLE_CAPABILITIES[role] || [];
|
||||||
|
const result = new Set<string>(defaults);
|
||||||
|
|
||||||
|
if (overrides && overrides[role]) {
|
||||||
|
const roleOverride = overrides[role];
|
||||||
|
|
||||||
|
if (roleOverride.grant) {
|
||||||
|
for (const cap of roleOverride.grant) {
|
||||||
|
if (ALL_CAPABILITIES.has(cap)) {
|
||||||
|
result.add(cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleOverride.revoke) {
|
||||||
|
for (const cap of roleOverride.revoke) {
|
||||||
|
result.delete(cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: resolve to a sorted array (for API responses).
|
||||||
|
*/
|
||||||
|
export function resolveCapabilitiesArray(
|
||||||
|
role: string,
|
||||||
|
overrides?: PermissionOverrides | null,
|
||||||
|
): string[] {
|
||||||
|
return Array.from(resolveCapabilities(role, overrides)).sort();
|
||||||
|
}
|
||||||
106
backend/src/common/utils/ai-caller.ts
Normal file
106
backend/src/common/utils/ai-caller.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Shared utility for calling OpenAI-compatible chat completion APIs.
|
||||||
|
* Used by both production AI features and shadow AI benchmarking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AICallerParams {
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
messages: Array<{ role: string; content: string }>;
|
||||||
|
temperature: number;
|
||||||
|
maxTokens: number;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AICallerResult {
|
||||||
|
content: string;
|
||||||
|
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||||
|
responseTimeMs: number;
|
||||||
|
rawResponse: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callOpenAICompatible(params: AICallerParams): Promise<AICallerResult> {
|
||||||
|
const { apiUrl, apiKey, model, messages, temperature, maxTokens, timeoutMs = 600000 } = params;
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
temperature,
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyString = JSON.stringify(requestBody);
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const { URL } = await import('url');
|
||||||
|
const https = await import('https');
|
||||||
|
|
||||||
|
const aiResult = await new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||||
|
// Normalize: strip trailing slash and /chat/completions if user included it
|
||||||
|
let baseUrl = apiUrl.replace(/\/+$/, '');
|
||||||
|
if (baseUrl.endsWith('/chat/completions')) {
|
||||||
|
baseUrl = baseUrl.slice(0, -'/chat/completions'.length);
|
||||||
|
}
|
||||||
|
const url = new URL(`${baseUrl}/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: timeoutMs,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ${timeoutMs / 1000}s`));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.write(bodyString);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseTimeMs = Date.now() - startTime;
|
||||||
|
|
||||||
|
if (aiResult.status >= 400) {
|
||||||
|
throw new Error(`AI API returned ${aiResult.status}: ${aiResult.body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = JSON.parse(aiResult.body);
|
||||||
|
const content = data.choices?.[0]?.message?.content || null;
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
throw new Error('AI model returned empty content');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean response: strip markdown fences and thinking blocks
|
||||||
|
let cleaned = content.trim();
|
||||||
|
if (cleaned.startsWith('```')) {
|
||||||
|
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||||
|
}
|
||||||
|
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>\s*/g, '').trim();
|
||||||
|
|
||||||
|
const usage = data.usage || undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: cleaned,
|
||||||
|
usage,
|
||||||
|
responseTimeMs,
|
||||||
|
rawResponse: content,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { AccountsService } from './accounts.service';
|
import { AccountsService } from './accounts.service';
|
||||||
import { CreateAccountDto } from './dto/create-account.dto';
|
import { CreateAccountDto } from './dto/create-account.dto';
|
||||||
import { UpdateAccountDto } from './dto/update-account.dto';
|
import { UpdateAccountDto } from './dto/update-account.dto';
|
||||||
@@ -16,24 +17,28 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List all accounts' })
|
@ApiOperation({ summary: 'List all accounts' })
|
||||||
|
@RequireCapability('financials.accounts.view')
|
||||||
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
|
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
|
||||||
return this.accountsService.findAll(fundType, includeArchived === 'true');
|
return this.accountsService.findAll(fundType, includeArchived === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('trial-balance')
|
@Get('trial-balance')
|
||||||
@ApiOperation({ summary: 'Get trial balance' })
|
@ApiOperation({ summary: 'Get trial balance' })
|
||||||
|
@RequireCapability('financials.accounts.view')
|
||||||
getTrialBalance(@Query('asOfDate') asOfDate?: string) {
|
getTrialBalance(@Query('asOfDate') asOfDate?: string) {
|
||||||
return this.accountsService.getTrialBalance(asOfDate);
|
return this.accountsService.getTrialBalance(asOfDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/set-primary')
|
@Put(':id/set-primary')
|
||||||
@ApiOperation({ summary: 'Set account as primary for its fund type' })
|
@ApiOperation({ summary: 'Set account as primary for its fund type' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
setPrimary(@Param('id') id: string) {
|
setPrimary(@Param('id') id: string) {
|
||||||
return this.accountsService.setPrimary(id);
|
return this.accountsService.setPrimary(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulk-opening-balances')
|
@Post('bulk-opening-balances')
|
||||||
@ApiOperation({ summary: 'Set opening balances for multiple accounts' })
|
@ApiOperation({ summary: 'Set opening balances for multiple accounts' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
bulkSetOpeningBalances(
|
bulkSetOpeningBalances(
|
||||||
@Body() dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] },
|
@Body() dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] },
|
||||||
) {
|
) {
|
||||||
@@ -42,6 +47,7 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Post(':id/opening-balance')
|
@Post(':id/opening-balance')
|
||||||
@ApiOperation({ summary: 'Set opening balance for an account at a specific date' })
|
@ApiOperation({ summary: 'Set opening balance for an account at a specific date' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
setOpeningBalance(
|
setOpeningBalance(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
||||||
@@ -51,6 +57,7 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Post(':id/adjust-balance')
|
@Post(':id/adjust-balance')
|
||||||
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
|
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
adjustBalance(
|
adjustBalance(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
||||||
@@ -58,20 +65,32 @@ export class AccountsController {
|
|||||||
return this.accountsService.adjustBalance(id, dto);
|
return this.accountsService.adjustBalance(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('transfer')
|
||||||
|
@ApiOperation({ summary: 'Transfer funds between asset accounts' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
|
transferFunds(
|
||||||
|
@Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string },
|
||||||
|
) {
|
||||||
|
return this.accountsService.transferFunds(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get account by ID' })
|
@ApiOperation({ summary: 'Get account by ID' })
|
||||||
|
@RequireCapability('financials.accounts.view')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.accountsService.findOne(id);
|
return this.accountsService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a new account' })
|
@ApiOperation({ summary: 'Create a new account' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
create(@Body() dto: CreateAccountDto) {
|
create(@Body() dto: CreateAccountDto) {
|
||||||
return this.accountsService.create(dto);
|
return this.accountsService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@ApiOperation({ summary: 'Update an account' })
|
@ApiOperation({ summary: 'Update an account' })
|
||||||
|
@RequireCapability('financials.accounts.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateAccountDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateAccountDto) {
|
||||||
return this.accountsService.update(id, dto);
|
return this.accountsService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,6 +360,62 @@ export class AccountsService {
|
|||||||
return journalEntry;
|
return journalEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async transferFunds(dto: {
|
||||||
|
fromAccountId: string;
|
||||||
|
toAccountId: string;
|
||||||
|
amount: number;
|
||||||
|
transferDate: string;
|
||||||
|
memo?: string;
|
||||||
|
}) {
|
||||||
|
if (dto.amount <= 0) throw new BadRequestException('Transfer amount must be positive');
|
||||||
|
if (dto.fromAccountId === dto.toAccountId) throw new BadRequestException('Cannot transfer to the same account');
|
||||||
|
|
||||||
|
const fromAccount = await this.findOne(dto.fromAccountId);
|
||||||
|
const toAccount = await this.findOne(dto.toAccountId);
|
||||||
|
|
||||||
|
if (fromAccount.account_type !== 'asset') throw new BadRequestException('Source account must be an asset account');
|
||||||
|
if (toAccount.account_type !== 'asset') throw new BadRequestException('Destination account must be an asset account');
|
||||||
|
|
||||||
|
// Find fiscal period
|
||||||
|
const asOf = new Date(dto.transferDate);
|
||||||
|
const year = asOf.getFullYear();
|
||||||
|
const month = asOf.getMonth() + 1;
|
||||||
|
const periods = await this.tenant.query(
|
||||||
|
'SELECT id FROM fiscal_periods WHERE year = $1 AND month = $2',
|
||||||
|
[year, month],
|
||||||
|
);
|
||||||
|
if (!periods.length) {
|
||||||
|
throw new BadRequestException(`No fiscal period found for ${year}-${String(month).padStart(2, '0')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memo = dto.memo || `Transfer from ${fromAccount.name} to ${toAccount.name}`;
|
||||||
|
|
||||||
|
// Create journal entry: debit destination (increase), credit source (decrease)
|
||||||
|
const jeRows = await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entries (entry_date, description, entry_type, fiscal_period_id, is_posted, posted_at, created_by)
|
||||||
|
VALUES ($1, $2, 'transfer', $3, true, NOW(), $4)
|
||||||
|
RETURNING *`,
|
||||||
|
[dto.transferDate, memo, periods[0].id, '00000000-0000-0000-0000-000000000000'],
|
||||||
|
);
|
||||||
|
const je = jeRows[0];
|
||||||
|
|
||||||
|
// Credit source account (reduces asset balance)
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||||
|
VALUES ($1, $2, 0, $3, $4)`,
|
||||||
|
[je.id, dto.fromAccountId, dto.amount, memo],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debit destination account (increases asset balance)
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO journal_entry_lines (journal_entry_id, account_id, debit, credit, memo)
|
||||||
|
VALUES ($1, $2, $3, 0, $4)`,
|
||||||
|
[je.id, dto.toAccountId, dto.amount, memo],
|
||||||
|
);
|
||||||
|
|
||||||
|
return je;
|
||||||
|
}
|
||||||
|
|
||||||
async getTrialBalance(asOfDate?: string) {
|
async getTrialBalance(asOfDate?: string) {
|
||||||
const dateFilter = asOfDate
|
const dateFilter = asOfDate
|
||||||
? `AND je.entry_date <= $1`
|
? `AND je.entry_date <= $1`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { AssessmentGroupsService } from './assessment-groups.service';
|
import { AssessmentGroupsService } from './assessment-groups.service';
|
||||||
|
|
||||||
@ApiTags('assessment-groups')
|
@ApiTags('assessment-groups')
|
||||||
@@ -11,23 +12,30 @@ export class AssessmentGroupsController {
|
|||||||
constructor(private service: AssessmentGroupsService) {}
|
constructor(private service: AssessmentGroupsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('assessments.groups.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
|
@RequireCapability('assessments.groups.view')
|
||||||
getSummary() { return this.service.getSummary(); }
|
getSummary() { return this.service.getSummary(); }
|
||||||
|
|
||||||
@Get('default')
|
@Get('default')
|
||||||
|
@RequireCapability('assessments.groups.view')
|
||||||
getDefault() { return this.service.getDefault(); }
|
getDefault() { return this.service.getDefault(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('assessments.groups.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('assessments.groups.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('assessments.groups.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
|
|
||||||
@Put(':id/set-default')
|
@Put(':id/set-default')
|
||||||
|
@RequireCapability('assessments.groups.edit')
|
||||||
setDefault(@Param('id') id: string) { return this.service.setDefault(id); }
|
setDefault(@Param('id') id: string) { return this.service.setDefault(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 { AdminAnalyticsService } from './admin-analytics.service';
|
||||||
|
import { IdeasService } from '../ideas/ideas.service';
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
@ApiTags('admin')
|
@ApiTags('admin')
|
||||||
@@ -17,6 +18,7 @@ export class AdminController {
|
|||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private orgService: OrganizationsService,
|
private orgService: OrganizationsService,
|
||||||
private analyticsService: AdminAnalyticsService,
|
private analyticsService: AdminAnalyticsService,
|
||||||
|
private ideasService: IdeasService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async requireSuperadmin(req: any) {
|
private async requireSuperadmin(req: any) {
|
||||||
@@ -196,4 +198,45 @@ export class AdminController {
|
|||||||
|
|
||||||
return { success: true, organization: org };
|
return { success: true, organization: org };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ideation ──
|
||||||
|
|
||||||
|
@Get('ideas')
|
||||||
|
async listAllIdeas(@Req() req: any) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
return this.ideasService.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('ideas/:id/status')
|
||||||
|
async updateIdeaStatus(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { status: string },
|
||||||
|
) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const idea = await this.ideasService.updateStatus(id, body.status);
|
||||||
|
return { success: true, idea };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('ideas/:id/note')
|
||||||
|
async updateIdeaNote(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { adminNote: string },
|
||||||
|
) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const idea = await this.ideasService.updateNote(id, body.adminNote);
|
||||||
|
return { success: true, idea };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('organizations/:id/settings')
|
||||||
|
async updateOrgSettings(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: Record<string, any>,
|
||||||
|
) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const org = await this.orgService.updateSettings(id, body);
|
||||||
|
return { success: true, organization: org };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ 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';
|
||||||
import { OrganizationsModule } from '../organizations/organizations.module';
|
import { OrganizationsModule } from '../organizations/organizations.module';
|
||||||
|
import { IdeasModule } from '../ideas/ideas.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
UsersModule,
|
UsersModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
|
IdeasModule,
|
||||||
PassportModule,
|
PassportModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { EmailService } from '../email/email.service';
|
|||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
import { RefreshTokenService } from './refresh-token.service';
|
import { RefreshTokenService } from './refresh-token.service';
|
||||||
|
import { resolveCapabilitiesArray } from '../../common/permissions';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -162,6 +163,12 @@ export class AuthService {
|
|||||||
// Generate new refresh token for org switch
|
// Generate new refresh token for org switch
|
||||||
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
|
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
|
||||||
|
|
||||||
|
const orgSettings = membership.organization.settings || {};
|
||||||
|
const capabilities = resolveCapabilitiesArray(
|
||||||
|
membership.role,
|
||||||
|
orgSettings.permissionOverrides,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: this.jwtService.sign(payload),
|
accessToken: this.jwtService.sign(payload),
|
||||||
refreshToken,
|
refreshToken,
|
||||||
@@ -169,7 +176,8 @@ export class AuthService {
|
|||||||
id: membership.organization.id,
|
id: membership.organization.id,
|
||||||
name: membership.organization.name,
|
name: membership.organization.name,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
settings: membership.organization.settings || {},
|
settings: orgSettings,
|
||||||
|
capabilities,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -468,12 +476,16 @@ export class AuthService {
|
|||||||
hasSeenIntro: user.hasSeenIntro || false,
|
hasSeenIntro: user.hasSeenIntro || false,
|
||||||
mfaEnabled: user.mfaEnabled || false,
|
mfaEnabled: user.mfaEnabled || false,
|
||||||
},
|
},
|
||||||
organizations: orgs.map((uo) => ({
|
organizations: orgs.map((uo) => {
|
||||||
|
const settings = uo.organization?.settings || {};
|
||||||
|
return {
|
||||||
id: uo.organizationId,
|
id: uo.organizationId,
|
||||||
name: uo.organization?.name,
|
name: uo.organization?.name,
|
||||||
status: uo.organization?.status,
|
status: uo.organization?.status,
|
||||||
role: uo.role,
|
role: uo.role,
|
||||||
})),
|
capabilities: resolveCapabilitiesArray(uo.role, settings.permissionOverrides),
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ export class BoardPlanningProjectionService {
|
|||||||
return this.computeProjection(scenarioId);
|
return this.computeProjection(scenarioId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute full projection for a scenario. */
|
/** Compute full projection for a scenario. Also auto-creates renewal records for auto_renew investments. */
|
||||||
async computeProjection(scenarioId: string) {
|
async computeProjection(scenarioId: string) {
|
||||||
const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
|
const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]);
|
||||||
if (!scenarioRows.length) throw new NotFoundException('Scenario not found');
|
if (!scenarioRows.length) throw new NotFoundException('Scenario not found');
|
||||||
const scenario = scenarioRows[0];
|
const scenario = scenarioRows[0];
|
||||||
|
|
||||||
|
// Auto-create renewal investment records for auto_renew investments that have maturity dates
|
||||||
|
await this.ensureRenewalRecords(scenarioId);
|
||||||
|
|
||||||
const investments = await this.tenant.query(
|
const investments = await this.tenant.query(
|
||||||
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId],
|
'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId],
|
||||||
);
|
);
|
||||||
@@ -152,6 +155,53 @@ export class BoardPlanningProjectionService {
|
|||||||
|
|
||||||
// ── Private Helpers ──
|
// ── Private Helpers ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each auto_renew investment with a maturity_date, ensure a corresponding
|
||||||
|
* renewal investment record exists (starting at maturity_date, same term).
|
||||||
|
* The renewal record has auto_renew=false so it won't create infinite chains.
|
||||||
|
*/
|
||||||
|
private async ensureRenewalRecords(scenarioId: string) {
|
||||||
|
const autoRenewInvestments = await this.tenant.query(
|
||||||
|
`SELECT * FROM scenario_investments
|
||||||
|
WHERE scenario_id = $1 AND auto_renew = true AND maturity_date IS NOT NULL AND executed_investment_id IS NULL`,
|
||||||
|
[scenarioId],
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const inv of autoRenewInvestments) {
|
||||||
|
// Check if a renewal record already exists (linked by notes convention or same label pattern)
|
||||||
|
const renewalLabel = `${inv.label} (Renewal)`;
|
||||||
|
const existing = await this.tenant.query(
|
||||||
|
`SELECT id FROM scenario_investments WHERE scenario_id = $1 AND label = $2 AND purchase_date = $3`,
|
||||||
|
[scenarioId, renewalLabel, inv.maturity_date],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0) continue; // Already created
|
||||||
|
|
||||||
|
// Compute new maturity date from original term
|
||||||
|
let newMaturityDate: string | null = null;
|
||||||
|
const termMonths = parseInt(inv.term_months) || 0;
|
||||||
|
if (termMonths > 0 && inv.maturity_date) {
|
||||||
|
const d = new Date(inv.maturity_date);
|
||||||
|
d.setMonth(d.getMonth() + termMonths);
|
||||||
|
newMaturityDate = d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.tenant.query(
|
||||||
|
`INSERT INTO scenario_investments
|
||||||
|
(scenario_id, label, investment_type, fund_type, principal, interest_rate,
|
||||||
|
term_months, institution, purchase_date, maturity_date, auto_renew, notes, sort_order)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, false, $11, $12)`,
|
||||||
|
[
|
||||||
|
scenarioId, renewalLabel, inv.investment_type, inv.fund_type,
|
||||||
|
inv.principal, inv.interest_rate, inv.term_months || null,
|
||||||
|
inv.institution, inv.maturity_date, newMaturityDate,
|
||||||
|
`Auto-created renewal of "${inv.label}". Modify as needed.`,
|
||||||
|
(parseInt(inv.sort_order) || 0) + 1,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async getBaselineState(startYear: number, months: number) {
|
private async getBaselineState(startYear: number, months: number) {
|
||||||
// Current balances from asset accounts
|
// Current balances from asset accounts
|
||||||
const opCashRows = await this.tenant.query(`
|
const opCashRows = await this.tenant.query(`
|
||||||
@@ -403,11 +453,9 @@ export class BoardPlanningProjectionService {
|
|||||||
if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; }
|
if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; }
|
||||||
else { resCashFlow += maturityTotal; resInvChange -= principal; }
|
else { resCashFlow += maturityTotal; resInvChange -= principal; }
|
||||||
|
|
||||||
// Auto-renew: immediately reinvest
|
// Note: auto_renew investments now create separate renewal records
|
||||||
if (inv.auto_renew) {
|
// (via ensureRenewalRecords), so the renewal purchase is handled by
|
||||||
if (isOp) { opCashFlow -= principal; opInvChange += principal; }
|
// that record's purchase_date logic above — no inline reinvest needed.
|
||||||
else { resCashFlow -= principal; resInvChange += principal; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Response } from 'express';
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { BoardPlanningService } from './board-planning.service';
|
import { BoardPlanningService } from './board-planning.service';
|
||||||
import { BoardPlanningProjectionService } from './board-planning-projection.service';
|
import { BoardPlanningProjectionService } from './board-planning-projection.service';
|
||||||
import { BudgetPlanningService } from './budget-planning.service';
|
import { BudgetPlanningService } from './budget-planning.service';
|
||||||
@@ -22,27 +23,32 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('scenarios')
|
@Get('scenarios')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
listScenarios(@Query('type') type?: string) {
|
listScenarios(@Query('type') type?: string) {
|
||||||
return this.service.listScenarios(type);
|
return this.service.listScenarios(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('scenarios/:id')
|
@Get('scenarios/:id')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
getScenario(@Param('id') id: string) {
|
getScenario(@Param('id') id: string) {
|
||||||
return this.service.getScenario(id);
|
return this.service.getScenario(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios')
|
@Post('scenarios')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
createScenario(@Body() dto: any, @Req() req: any) {
|
createScenario(@Body() dto: any, @Req() req: any) {
|
||||||
return this.service.createScenario(dto, req.user.sub);
|
return this.service.createScenario(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('scenarios/:id')
|
@Put('scenarios/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
updateScenario(@Param('id') id: string, @Body() dto: any) {
|
updateScenario(@Param('id') id: string, @Body() dto: any) {
|
||||||
return this.service.updateScenario(id, dto);
|
return this.service.updateScenario(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('scenarios/:id')
|
@Delete('scenarios/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
deleteScenario(@Param('id') id: string) {
|
deleteScenario(@Param('id') id: string) {
|
||||||
return this.service.deleteScenario(id);
|
return this.service.deleteScenario(id);
|
||||||
}
|
}
|
||||||
@@ -51,26 +57,31 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('scenarios/:scenarioId/investments')
|
@Get('scenarios/:scenarioId/investments')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
listInvestments(@Param('scenarioId') scenarioId: string) {
|
listInvestments(@Param('scenarioId') scenarioId: string) {
|
||||||
return this.service.listInvestments(scenarioId);
|
return this.service.listInvestments(scenarioId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios/:scenarioId/investments')
|
@Post('scenarios/:scenarioId/investments')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
return this.service.addInvestment(scenarioId, dto);
|
return this.service.addInvestment(scenarioId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios/:scenarioId/investments/from-recommendation')
|
@Post('scenarios/:scenarioId/investments/from-recommendation')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
|
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('investments/:id')
|
@Put('investments/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
updateInvestment(@Param('id') id: string, @Body() dto: any) {
|
updateInvestment(@Param('id') id: string, @Body() dto: any) {
|
||||||
return this.service.updateInvestment(id, dto);
|
return this.service.updateInvestment(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('investments/:id')
|
@Delete('investments/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
removeInvestment(@Param('id') id: string) {
|
removeInvestment(@Param('id') id: string) {
|
||||||
return this.service.removeInvestment(id);
|
return this.service.removeInvestment(id);
|
||||||
}
|
}
|
||||||
@@ -79,21 +90,25 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('scenarios/:scenarioId/assessments')
|
@Get('scenarios/:scenarioId/assessments')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
listAssessments(@Param('scenarioId') scenarioId: string) {
|
listAssessments(@Param('scenarioId') scenarioId: string) {
|
||||||
return this.service.listAssessments(scenarioId);
|
return this.service.listAssessments(scenarioId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios/:scenarioId/assessments')
|
@Post('scenarios/:scenarioId/assessments')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
return this.service.addAssessment(scenarioId, dto);
|
return this.service.addAssessment(scenarioId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('assessments/:id')
|
@Put('assessments/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
updateAssessment(@Param('id') id: string, @Body() dto: any) {
|
updateAssessment(@Param('id') id: string, @Body() dto: any) {
|
||||||
return this.service.updateAssessment(id, dto);
|
return this.service.updateAssessment(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('assessments/:id')
|
@Delete('assessments/:id')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
removeAssessment(@Param('id') id: string) {
|
removeAssessment(@Param('id') id: string) {
|
||||||
return this.service.removeAssessment(id);
|
return this.service.removeAssessment(id);
|
||||||
}
|
}
|
||||||
@@ -102,11 +117,13 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('scenarios/:id/projection')
|
@Get('scenarios/:id/projection')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
getProjection(@Param('id') id: string) {
|
getProjection(@Param('id') id: string) {
|
||||||
return this.projection.getProjection(id);
|
return this.projection.getProjection(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios/:id/projection/refresh')
|
@Post('scenarios/:id/projection/refresh')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
refreshProjection(@Param('id') id: string) {
|
refreshProjection(@Param('id') id: string) {
|
||||||
return this.projection.computeProjection(id);
|
return this.projection.computeProjection(id);
|
||||||
}
|
}
|
||||||
@@ -115,6 +132,7 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('compare')
|
@Get('compare')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
compareScenarios(@Query('ids') ids: string) {
|
compareScenarios(@Query('ids') ids: string) {
|
||||||
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
|
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
return this.projection.compareScenarios(scenarioIds);
|
return this.projection.compareScenarios(scenarioIds);
|
||||||
@@ -123,6 +141,7 @@ export class BoardPlanningController {
|
|||||||
// ── Execute Investment ──
|
// ── Execute Investment ──
|
||||||
|
|
||||||
@Post('investments/:id/execute')
|
@Post('investments/:id/execute')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
executeInvestment(
|
executeInvestment(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: { executionDate: string },
|
@Body() dto: { executionDate: string },
|
||||||
@@ -135,43 +154,51 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('budget-plans')
|
@Get('budget-plans')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
listBudgetPlans() {
|
listBudgetPlans() {
|
||||||
return this.budgetPlanning.listPlans();
|
return this.budgetPlanning.listPlans();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('budget-plans/available-years')
|
@Get('budget-plans/available-years')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
getAvailableYears() {
|
getAvailableYears() {
|
||||||
return this.budgetPlanning.getAvailableYears();
|
return this.budgetPlanning.getAvailableYears();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('budget-plans/:year')
|
@Get('budget-plans/:year')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
getBudgetPlan(@Param('year') year: string) {
|
getBudgetPlan(@Param('year') year: string) {
|
||||||
return this.budgetPlanning.getPlan(parseInt(year, 10));
|
return this.budgetPlanning.getPlan(parseInt(year, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('budget-plans')
|
@Post('budget-plans')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
createBudgetPlan(@Body() dto: { fiscalYear: number; baseYear: number; inflationRate?: number }, @Req() req: any) {
|
createBudgetPlan(@Body() dto: { fiscalYear: number; baseYear: number; inflationRate?: number }, @Req() req: any) {
|
||||||
return this.budgetPlanning.createPlan(dto.fiscalYear, dto.baseYear, dto.inflationRate ?? 2.5, req.user.sub);
|
return this.budgetPlanning.createPlan(dto.fiscalYear, dto.baseYear, dto.inflationRate ?? 2.5, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('budget-plans/:year/lines')
|
@Put('budget-plans/:year/lines')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
|
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
|
||||||
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
|
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('budget-plans/:year/inflation')
|
@Put('budget-plans/:year/inflation')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
|
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
|
||||||
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
|
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('budget-plans/:year/status')
|
@Put('budget-plans/:year/status')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
|
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
|
||||||
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
|
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('budget-plans/:year/import')
|
@Post('budget-plans/:year/import')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
importBudgetPlanLines(
|
importBudgetPlanLines(
|
||||||
@Param('year') year: string,
|
@Param('year') year: string,
|
||||||
@Body() lines: any[],
|
@Body() lines: any[],
|
||||||
@@ -181,6 +208,7 @@ export class BoardPlanningController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('budget-plans/:year/template')
|
@Get('budget-plans/:year/template')
|
||||||
|
@RequireCapability('planning.scenarios.view')
|
||||||
async getBudgetPlanTemplate(
|
async getBudgetPlanTemplate(
|
||||||
@Param('year') year: string,
|
@Param('year') year: string,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
@@ -194,6 +222,7 @@ export class BoardPlanningController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('budget-plans/:year')
|
@Delete('budget-plans/:year')
|
||||||
|
@RequireCapability('planning.scenarios.edit')
|
||||||
deleteBudgetPlan(@Param('year') year: string) {
|
deleteBudgetPlan(@Param('year') year: string) {
|
||||||
return this.budgetPlanning.deletePlan(parseInt(year, 10));
|
return this.budgetPlanning.deletePlan(parseInt(year, 10));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Put, Post, Body, Param, Query, Res, UseGuards, ParseIn
|
|||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { BudgetsService } from './budgets.service';
|
import { BudgetsService } from './budgets.service';
|
||||||
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Post(':year/import')
|
@Post(':year/import')
|
||||||
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
|
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
|
||||||
|
@RequireCapability('financials.budgets.edit')
|
||||||
importBudget(
|
importBudget(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Body() lines: any[],
|
@Body() lines: any[],
|
||||||
@@ -23,6 +25,7 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Get(':year/template')
|
@Get(':year/template')
|
||||||
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
|
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
|
||||||
|
@RequireCapability('financials.budgets.view')
|
||||||
async getTemplate(
|
async getTemplate(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
@@ -37,6 +40,7 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Get(':year/vs-actual')
|
@Get(':year/vs-actual')
|
||||||
@ApiOperation({ summary: 'Budget vs actual comparison' })
|
@ApiOperation({ summary: 'Budget vs actual comparison' })
|
||||||
|
@RequireCapability('financials.budgets.view')
|
||||||
budgetVsActual(
|
budgetVsActual(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Query('month') month?: string,
|
@Query('month') month?: string,
|
||||||
@@ -46,12 +50,14 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Get(':year')
|
@Get(':year')
|
||||||
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
|
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
|
||||||
|
@RequireCapability('financials.budgets.view')
|
||||||
findByYear(@Param('year', ParseIntPipe) year: number) {
|
findByYear(@Param('year', ParseIntPipe) year: number) {
|
||||||
return this.budgetsService.findByYear(year);
|
return this.budgetsService.findByYear(year);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':year')
|
@Put(':year')
|
||||||
@ApiOperation({ summary: 'Upsert budgets for a fiscal year' })
|
@ApiOperation({ summary: 'Upsert budgets for a fiscal year' })
|
||||||
|
@RequireCapability('financials.budgets.edit')
|
||||||
upsert(
|
upsert(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Body() budgets: UpsertBudgetDto[],
|
@Body() budgets: UpsertBudgetDto[],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { CapitalProjectsService } from './capital-projects.service';
|
import { CapitalProjectsService } from './capital-projects.service';
|
||||||
|
|
||||||
@ApiTags('capital-projects')
|
@ApiTags('capital-projects')
|
||||||
@@ -11,14 +12,18 @@ export class CapitalProjectsController {
|
|||||||
constructor(private service: CapitalProjectsService) {}
|
constructor(private service: CapitalProjectsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ import { HealthScoresScheduler } from './health-scores.scheduler';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [HealthScoresController],
|
controllers: [HealthScoresController],
|
||||||
providers: [HealthScoresService, HealthScoresScheduler],
|
providers: [HealthScoresService, HealthScoresScheduler],
|
||||||
|
exports: [HealthScoresService],
|
||||||
})
|
})
|
||||||
export class HealthScoresModule {}
|
export class HealthScoresModule {}
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ export class HealthScoresService {
|
|||||||
|
|
||||||
// ── Data Readiness Checks ──
|
// ── Data Readiness Checks ──
|
||||||
|
|
||||||
private async checkDataReadiness(qr: any, scoreType: string): Promise<string[]> {
|
async checkDataReadiness(qr: any, scoreType: string): Promise<string[]> {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
|
|
||||||
if (scoreType === 'operating') {
|
if (scoreType === 'operating') {
|
||||||
@@ -249,7 +249,7 @@ export class HealthScoresService {
|
|||||||
|
|
||||||
// ── Data Gathering ──
|
// ── Data Gathering ──
|
||||||
|
|
||||||
private async gatherOperatingData(qr: any) {
|
async gatherOperatingData(qr: any) {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
const [accounts, budgets, assessments, cashFlow, recentTransactions, actualsMonths] = await Promise.all([
|
const [accounts, budgets, assessments, cashFlow, recentTransactions, actualsMonths] = await Promise.all([
|
||||||
@@ -520,7 +520,7 @@ export class HealthScoresService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async gatherReserveData(qr: any) {
|
async gatherReserveData(qr: any) {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const currentMonth = new Date().getMonth(); // 0-indexed
|
const currentMonth = new Date().getMonth(); // 0-indexed
|
||||||
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||||
@@ -625,14 +625,16 @@ export class HealthScoresService {
|
|||||||
.filter((b: any) => b.account_type === 'expense')
|
.filter((b: any) => b.account_type === 'expense')
|
||||||
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
|
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
|
||||||
|
|
||||||
// Components needing replacement within 5 years — use whichever source has data
|
// Projects due within 5 years — based on planned date (target_year/target_month),
|
||||||
const urgentComponents = useComponentsTable
|
// NOT remaining_life_years. The planned date is the board's decision on when to act;
|
||||||
? reserveComponents.filter(
|
// remaining life is documentation-only reference info.
|
||||||
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
|
const now = new Date();
|
||||||
)
|
const fiveYearsFromNow = new Date(now.getFullYear() + 5, now.getMonth(), 1);
|
||||||
: reserveProjects.filter(
|
const urgentProjects = reserveProjects.filter((p: any) => {
|
||||||
(p: any) => p.remaining_life_years !== null && parseFloat(p.remaining_life_years) <= 5,
|
if (!p.target_year) return false;
|
||||||
);
|
const targetDate = new Date(parseInt(p.target_year), (parseInt(p.target_month) || 6) - 1, 1);
|
||||||
|
return targetDate <= fiveYearsFromNow;
|
||||||
|
});
|
||||||
|
|
||||||
// ── Build 12-month forward reserve cash flow projection ──
|
// ── Build 12-month forward reserve cash flow projection ──
|
||||||
|
|
||||||
@@ -773,7 +775,7 @@ export class HealthScoresService {
|
|||||||
totalProjectCost,
|
totalProjectCost,
|
||||||
annualReserveContribution,
|
annualReserveContribution,
|
||||||
annualReserveExpenses,
|
annualReserveExpenses,
|
||||||
urgentComponents,
|
urgentProjects,
|
||||||
monthlySpecialAssessmentIncome,
|
monthlySpecialAssessmentIncome,
|
||||||
year,
|
year,
|
||||||
forecast,
|
forecast,
|
||||||
@@ -785,7 +787,7 @@ export class HealthScoresService {
|
|||||||
|
|
||||||
// ── AI Prompt Construction ──
|
// ── AI Prompt Construction ──
|
||||||
|
|
||||||
private buildOperatingPrompt(data: any): Array<{ role: string; content: string }> {
|
buildOperatingPrompt(data: any): Array<{ role: string; content: string }> {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
const systemPrompt = `You are an HOA financial health analyst. You evaluate the operating fund health of homeowners associations on a scale of 0-100.
|
const systemPrompt = `You are an HOA financial health analyst. You evaluate the operating fund health of homeowners associations on a scale of 0-100.
|
||||||
@@ -925,7 +927,7 @@ Projected Year-End Cash: $${data.projectedYearEndCash.toFixed(0)}`;
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildReservePrompt(data: any): Array<{ role: string; content: string }> {
|
buildReservePrompt(data: any): Array<{ role: string; content: string }> {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
const systemPrompt = `You are an HOA reserve fund analyst. You evaluate reserve fund health on a scale of 0-100, assessing whether the HOA is adequately prepared for future capital expenditures.
|
const systemPrompt = `You are an HOA reserve fund analyst. You evaluate reserve fund health on a scale of 0-100, assessing whether the HOA is adequately prepared for future capital expenditures.
|
||||||
@@ -940,12 +942,13 @@ SCORING GUIDELINES:
|
|||||||
|
|
||||||
KEY FACTORS TO EVALUATE:
|
KEY FACTORS TO EVALUATE:
|
||||||
1. Percent funded (total reserve assets vs total replacement costs)
|
1. Percent funded (total reserve assets vs total replacement costs)
|
||||||
2. Annual contribution adequacy (is annual contribution enough to keep pace with aging components?)
|
2. Annual contribution adequacy (is annual contribution enough to keep pace with planned projects?)
|
||||||
3. Component urgency (components due within 5 years and their funding status)
|
3. Project urgency — based ONLY on the "Planned Date" field. The Planned Date is the board's decision on when a project will be executed. Do NOT use "Useful Life" or "Remaining Life" to determine urgency — those are reference information only. A project is only urgent if its Planned Date falls within the next 1-3 years.
|
||||||
4. Capital project readiness (are planned projects adequately funded?)
|
4. Capital project readiness (are planned projects adequately funded by their planned dates?)
|
||||||
5. Investment strategy (are reserves earning returns through CDs, money markets, etc.?)
|
5. Investment strategy (are reserves earning returns through CDs, money markets, etc.?)
|
||||||
6. Diversity of reserve components (is the full building covered?)
|
6. Diversity of reserve components (is the full scope of community infrastructure tracked?)
|
||||||
7. CRITICAL — Projected cash flow: Use the 12-MONTH RESERVE CASH FLOW FORECAST to assess future liquidity. The forecast shows month-by-month projected income (from special assessments collected from homeowners AND budgeted reserve income), expenses, capital project costs, and investment maturities returning cash. Check whether the reserve fund will have sufficient liquidity when capital projects are due. If special assessment income arrives before project costs, the position may be adequate even if current cash seems low.
|
7. CRITICAL — Projected cash flow: Use the 12-MONTH RESERVE CASH FLOW FORECAST to assess future liquidity. The forecast shows month-by-month projected income (from special assessments collected from homeowners AND budgeted reserve income), expenses, capital project costs, and investment maturities returning cash. Check whether the reserve fund will have sufficient liquidity when capital projects are due. If special assessment income arrives before project costs, the position may be adequate even if current cash seems low.
|
||||||
|
8. IMPORTANT — Projects with no Planned Date or with "Not scheduled" should be noted but NOT treated as urgent or imminent. Only assess urgency for projects with actual planned dates.
|
||||||
|
|
||||||
RESPONSE FORMAT:
|
RESPONSE FORMAT:
|
||||||
Respond with ONLY valid JSON (no markdown, no code fences):
|
Respond with ONLY valid JSON (no markdown, no code fences):
|
||||||
@@ -974,7 +977,8 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
|
|||||||
`- ${i.name} | ${i.investment_type} @ ${i.institution} | $${parseFloat(i.current_value || i.principal || '0').toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`,
|
`- ${i.name} | ${i.investment_type} @ ${i.institution} | $${parseFloat(i.current_value || i.principal || '0').toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`,
|
||||||
).join('\n');
|
).join('\n');
|
||||||
|
|
||||||
// Build component lines from reserve_components if available, otherwise from reserve-funded projects
|
// Build component lines from reserve_components if available, otherwise from reserve-funded projects.
|
||||||
|
// Use planned date (target_year/target_month) as the authoritative timeline, not remaining_life_years.
|
||||||
const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects;
|
const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects;
|
||||||
const componentLines = componentSource.length === 0
|
const componentLines = componentSource.length === 0
|
||||||
? 'No reserve components or reserve projects tracked.'
|
? 'No reserve components or reserve projects tracked.'
|
||||||
@@ -982,7 +986,8 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
|
|||||||
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
|
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
|
||||||
const funded = parseFloat(c.current_fund_balance || '0');
|
const funded = parseFloat(c.current_fund_balance || '0');
|
||||||
const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0';
|
const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0';
|
||||||
return `- ${c.name} [${c.category || 'N/A'}] | Life: ${c.useful_life_years || '?'}yr, Remaining: ${c.remaining_life_years || '?'}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`;
|
const plannedDate = c.target_year ? `${c.target_year}/${c.target_month || '?'}` : 'Not scheduled';
|
||||||
|
return `- ${c.name} [${c.category || 'N/A'}] | Planned Date: ${plannedDate} | Useful Life: ${c.useful_life_years || '?'}yr (reference only) | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
const projectLines = data.projects.length === 0
|
const projectLines = data.projects.length === 0
|
||||||
@@ -995,13 +1000,14 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar
|
|||||||
.map((b: any) => `- ${b.name} (${b.account_number}) [${b.account_type}]: $${parseFloat(b.annual_total || '0').toFixed(2)}/yr`)
|
.map((b: any) => `- ${b.name} (${b.account_number}) [${b.account_type}]: $${parseFloat(b.annual_total || '0').toFixed(2)}/yr`)
|
||||||
.join('\n') || 'No reserve budget line items.';
|
.join('\n') || 'No reserve budget line items.';
|
||||||
|
|
||||||
const urgentLines = data.urgentComponents.length === 0
|
const urgentLines = data.urgentProjects.length === 0
|
||||||
? 'None — no components due within 5 years.'
|
? 'None — no reserve projects planned within 5 years.'
|
||||||
: data.urgentComponents.map((c: any) => {
|
: data.urgentProjects.map((p: any) => {
|
||||||
const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0');
|
const cost = parseFloat(p.estimated_cost || '0');
|
||||||
const funded = parseFloat(c.current_fund_balance || '0');
|
const funded = parseFloat(p.current_fund_balance || '0');
|
||||||
const gap = cost - funded;
|
const gap = cost - funded;
|
||||||
return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`;
|
const targetDate = `${p.target_year}/${p.target_month || '?'}`;
|
||||||
|
return `- ${p.name}: planned for ${targetDate}, Cost: $${cost.toFixed(0)}, $${gap.toFixed(0)} funding gap`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
|
|
||||||
const userPrompt = `Evaluate this HOA's reserve fund health.
|
const userPrompt = `Evaluate this HOA's reserve fund health.
|
||||||
@@ -1027,10 +1033,10 @@ ${accountLines}
|
|||||||
=== RESERVE INVESTMENTS ===
|
=== RESERVE INVESTMENTS ===
|
||||||
${investmentLines}
|
${investmentLines}
|
||||||
|
|
||||||
=== RESERVE COMPONENTS (ordered by urgency) ===
|
=== RESERVE COMPONENTS (ordered by planned date) ===
|
||||||
${componentLines}
|
${componentLines}
|
||||||
|
|
||||||
=== COMPONENTS DUE WITHIN 5 YEARS (URGENT) ===
|
=== PROJECTS PLANNED WITHIN 5 YEARS (by planned date) ===
|
||||||
${urgentLines}
|
${urgentLines}
|
||||||
|
|
||||||
=== CAPITAL PROJECTS ===
|
=== CAPITAL PROJECTS ===
|
||||||
|
|||||||
12
backend/src/modules/ideas/dto/create-idea.dto.ts
Normal file
12
backend/src/modules/ideas/dto/create-idea.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateIdeaDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
49
backend/src/modules/ideas/entities/idea.entity.ts
Normal file
49
backend/src/modules/ideas/entities/idea.entity.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Organization } from '../../organizations/entities/organization.entity';
|
||||||
|
import { User } from '../../users/entities/user.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'shared', name: 'ideas' })
|
||||||
|
export class Idea {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'org_id' })
|
||||||
|
orgId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ length: 255 })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, default: 'new' })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@Column({ name: 'admin_note', type: 'text', nullable: true })
|
||||||
|
adminNote: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => Organization)
|
||||||
|
@JoinColumn({ name: 'org_id' })
|
||||||
|
organization: Organization;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
27
backend/src/modules/ideas/ideas.controller.ts
Normal file
27
backend/src/modules/ideas/ideas.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Controller, Get, Post, Body, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { IdeasService } from './ideas.service';
|
||||||
|
import { CreateIdeaDto } from './dto/create-idea.dto';
|
||||||
|
|
||||||
|
@ApiTags('ideas')
|
||||||
|
@Controller('ideas')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class IdeasController {
|
||||||
|
constructor(private ideasService: IdeasService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(@Req() req: any, @Body() dto: CreateIdeaDto) {
|
||||||
|
const orgId = req.user.orgId;
|
||||||
|
const userId = req.user.userId || req.user.sub;
|
||||||
|
const idea = await this.ideasService.create(orgId, userId, dto);
|
||||||
|
return { success: true, idea };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async findByOrg(@Req() req: any) {
|
||||||
|
const orgId = req.user.orgId;
|
||||||
|
return this.ideasService.findByOrg(orgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/modules/ideas/ideas.module.ts
Normal file
14
backend/src/modules/ideas/ideas.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Idea } from './entities/idea.entity';
|
||||||
|
import { Organization } from '../organizations/entities/organization.entity';
|
||||||
|
import { IdeasController } from './ideas.controller';
|
||||||
|
import { IdeasService } from './ideas.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Idea, Organization])],
|
||||||
|
controllers: [IdeasController],
|
||||||
|
providers: [IdeasService],
|
||||||
|
exports: [IdeasService],
|
||||||
|
})
|
||||||
|
export class IdeasModule {}
|
||||||
89
backend/src/modules/ideas/ideas.service.ts
Normal file
89
backend/src/modules/ideas/ideas.service.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable, ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Idea } from './entities/idea.entity';
|
||||||
|
import { Organization } from '../organizations/entities/organization.entity';
|
||||||
|
import { CreateIdeaDto } from './dto/create-idea.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IdeasService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Idea)
|
||||||
|
private ideasRepository: Repository<Idea>,
|
||||||
|
@InjectRepository(Organization)
|
||||||
|
private orgRepository: Repository<Organization>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(orgId: string, userId: string, dto: CreateIdeaDto): Promise<Idea> {
|
||||||
|
const org = await this.orgRepository.findOne({ where: { id: orgId } });
|
||||||
|
if (!org) {
|
||||||
|
throw new NotFoundException('Organization not found');
|
||||||
|
}
|
||||||
|
if (org.settings?.ideationEnabled !== true) {
|
||||||
|
throw new ForbiddenException('Ideation is not enabled for this organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
const idea = this.ideasRepository.create({
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
title: dto.title,
|
||||||
|
description: dto.description,
|
||||||
|
});
|
||||||
|
return this.ideasRepository.save(idea);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByOrg(orgId: string): Promise<Idea[]> {
|
||||||
|
return this.ideasRepository.find({
|
||||||
|
where: { orgId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<any[]> {
|
||||||
|
return this.ideasRepository
|
||||||
|
.createQueryBuilder('idea')
|
||||||
|
.leftJoin('idea.organization', 'org')
|
||||||
|
.leftJoin('idea.user', 'user')
|
||||||
|
.select([
|
||||||
|
'idea.id AS id',
|
||||||
|
'idea.title AS title',
|
||||||
|
'idea.description AS description',
|
||||||
|
'idea.status AS status',
|
||||||
|
'idea.createdAt AS "createdAt"',
|
||||||
|
'idea.adminNote AS "adminNote"',
|
||||||
|
'org.id AS "orgId"',
|
||||||
|
'org.name AS "orgName"',
|
||||||
|
'user.id AS "userId"',
|
||||||
|
'user.email AS "userEmail"',
|
||||||
|
'user.firstName AS "userFirstName"',
|
||||||
|
'user.lastName AS "userLastName"',
|
||||||
|
])
|
||||||
|
.orderBy('idea.createdAt', 'DESC')
|
||||||
|
.getRawMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(id: string, status: string): Promise<Idea> {
|
||||||
|
const validStatuses = ['new', 'reviewed', 'accepted', 'rejected'];
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idea = await this.ideasRepository.findOne({ where: { id } });
|
||||||
|
if (!idea) {
|
||||||
|
throw new NotFoundException('Idea not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
idea.status = status;
|
||||||
|
return this.ideasRepository.save(idea);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateNote(id: string, adminNote: string): Promise<Idea> {
|
||||||
|
const idea = await this.ideasRepository.findOne({ where: { id } });
|
||||||
|
if (!idea) {
|
||||||
|
throw new NotFoundException('Idea not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
idea.adminNote = adminNote;
|
||||||
|
return this.ideasRepository.save(idea);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
|||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { InvestmentPlanningService } from './investment-planning.service';
|
import { InvestmentPlanningService } from './investment-planning.service';
|
||||||
|
|
||||||
@ApiTags('investment-planning')
|
@ApiTags('investment-planning')
|
||||||
@@ -13,24 +14,28 @@ export class InvestmentPlanningController {
|
|||||||
|
|
||||||
@Get('snapshot')
|
@Get('snapshot')
|
||||||
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
|
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
getSnapshot() {
|
getSnapshot() {
|
||||||
return this.service.getFinancialSnapshot();
|
return this.service.getFinancialSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('cd-rates')
|
@Get('cd-rates')
|
||||||
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
|
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
getCdRates() {
|
getCdRates() {
|
||||||
return this.service.getCdRates();
|
return this.service.getCdRates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-rates')
|
@Get('market-rates')
|
||||||
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
|
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
getMarketRates() {
|
getMarketRates() {
|
||||||
return this.service.getMarketRates();
|
return this.service.getMarketRates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('saved-recommendation')
|
@Get('saved-recommendation')
|
||||||
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
|
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
getSavedRecommendation() {
|
getSavedRecommendation() {
|
||||||
return this.service.getSavedRecommendation();
|
return this.service.getSavedRecommendation();
|
||||||
}
|
}
|
||||||
@@ -38,6 +43,7 @@ export class InvestmentPlanningController {
|
|||||||
@Post('recommendations')
|
@Post('recommendations')
|
||||||
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
|
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
|
@RequireCapability('planning.investments.edit')
|
||||||
triggerRecommendations(@Req() req: any) {
|
triggerRecommendations(@Req() req: any) {
|
||||||
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
|
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ import { InvestmentPlanningService } from './investment-planning.service';
|
|||||||
@Module({
|
@Module({
|
||||||
controllers: [InvestmentPlanningController],
|
controllers: [InvestmentPlanningController],
|
||||||
providers: [InvestmentPlanningService],
|
providers: [InvestmentPlanningService],
|
||||||
|
exports: [InvestmentPlanningService],
|
||||||
})
|
})
|
||||||
export class InvestmentPlanningModule {}
|
export class InvestmentPlanningModule {}
|
||||||
|
|||||||
@@ -877,7 +877,7 @@ export class InvestmentPlanningService {
|
|||||||
|
|
||||||
// ── Private: AI Prompt Construction ──
|
// ── Private: AI Prompt Construction ──
|
||||||
|
|
||||||
private buildPromptMessages(
|
buildPromptMessages(
|
||||||
snapshot: any,
|
snapshot: any,
|
||||||
allRates: { cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] },
|
allRates: { cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] },
|
||||||
monthlyForecast: any,
|
monthlyForecast: any,
|
||||||
@@ -1059,6 +1059,285 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Schema-Based Prompt Building (for shadow AI benchmarking) ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build investment recommendation prompt messages for a specific tenant schema.
|
||||||
|
* Bypasses request-scoped TenantService by using DataSource directly.
|
||||||
|
*/
|
||||||
|
async buildPromptForSchema(schemaName: string): Promise<Array<{ role: string; content: string }>> {
|
||||||
|
const qr = this.dataSource.createQueryRunner();
|
||||||
|
try {
|
||||||
|
await qr.connect();
|
||||||
|
await qr.query(`SET search_path TO "${schemaName}"`);
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const currentMonth = new Date().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'];
|
||||||
|
|
||||||
|
// ── Gather financial snapshot data ──
|
||||||
|
const [accountBalances, investmentAccounts, budgets, projects] = await Promise.all([
|
||||||
|
qr.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
|
||||||
|
`),
|
||||||
|
qr.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
|
||||||
|
`),
|
||||||
|
qr.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],
|
||||||
|
),
|
||||||
|
qr.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
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Cash flow context
|
||||||
|
const [opCashResult, resCashResult, assessmentIncome] = await Promise.all([
|
||||||
|
qr.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
|
||||||
|
`),
|
||||||
|
qr.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
|
||||||
|
`),
|
||||||
|
qr.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
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const operatingCash = accountBalances
|
||||||
|
.filter((a: any) => a.fund_type === 'operating' && a.account_type === 'asset')
|
||||||
|
.reduce((sum: number, a: any) => sum + parseFloat(a.balance || '0'), 0);
|
||||||
|
const reserveCash = accountBalances
|
||||||
|
.filter((a: any) => a.fund_type === 'reserve' && a.account_type === 'asset')
|
||||||
|
.reduce((sum: number, a: any) => sum + parseFloat(a.balance || '0'), 0);
|
||||||
|
const operatingInvestments = investmentAccounts
|
||||||
|
.filter((i: any) => i.fund_type === 'operating')
|
||||||
|
.reduce((sum: number, i: any) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||||
|
const reserveInvestments = investmentAccounts
|
||||||
|
.filter((i: any) => i.fund_type === 'reserve')
|
||||||
|
.reduce((sum: number, i: any) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
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: {
|
||||||
|
current_operating_cash: parseFloat(opCashResult[0]?.total || '0'),
|
||||||
|
current_reserve_cash: parseFloat(resCashResult[0]?.total || '0'),
|
||||||
|
budget_summary: await qr.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],
|
||||||
|
),
|
||||||
|
monthly_assessment_income: parseFloat(assessmentIncome[0]?.monthly_assessment_income || '0'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Build monthly forecast ──
|
||||||
|
const [opCashRows2, resCashRows2, opInvRows, resInvRows] = await Promise.all([
|
||||||
|
qr.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`),
|
||||||
|
qr.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`),
|
||||||
|
qr.query(`SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true`),
|
||||||
|
qr.query(`SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let runOpCash = parseFloat(opCashRows2[0]?.total || '0');
|
||||||
|
let runResCash = parseFloat(resCashRows2[0]?.total || '0');
|
||||||
|
let runOpInv = parseFloat(opInvRows[0]?.total || '0');
|
||||||
|
let runResInv = parseFloat(resInvRows[0]?.total || '0');
|
||||||
|
|
||||||
|
const assessmentGroups = await qr.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, 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 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
|
||||||
|
for (const yr of [year, year + 1]) {
|
||||||
|
const budgetRows = await qr.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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maturities = await qr.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectExpenses = await qr.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 yr2 = parseInt(p.target_year);
|
||||||
|
const mo = parseInt(p.target_month) || 6;
|
||||||
|
const key = `${yr2}-${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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const datapoints: any[] = [];
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const fYear = year + Math.floor((currentMonth - 1 + i) / 12);
|
||||||
|
const fMonth = ((currentMonth - 1 + i) % 12) + 1;
|
||||||
|
const key = `${fYear}-${fMonth}`;
|
||||||
|
const label = `${monthLabels[fMonth - 1]} ${fYear}`;
|
||||||
|
const assessments = getAssessmentIncome(fMonth);
|
||||||
|
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 };
|
||||||
|
const opIncomeMonth = budget.opIncome > 0 ? budget.opIncome : assessments.operating;
|
||||||
|
const resIncomeMonth = budget.resIncome > 0 ? budget.resIncome : assessments.reserve;
|
||||||
|
runOpCash += opIncomeMonth - budget.opExpense - project.operating + maturity.operating;
|
||||||
|
runResCash += resIncomeMonth - budget.resExpense - project.reserve + maturity.reserve;
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const monthlyForecast = { datapoints, assessment_schedule: assessmentSchedule };
|
||||||
|
const allRates = await this.getMarketRates();
|
||||||
|
|
||||||
|
return this.buildPromptMessages(snapshot, allRates, monthlyForecast);
|
||||||
|
} finally {
|
||||||
|
await qr.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Private: AI API Call ──
|
// ── Private: AI API Call ──
|
||||||
|
|
||||||
private async callAI(messages: Array<{ role: string; content: string }>): Promise<AIResponse> {
|
private async callAI(messages: Array<{ role: string; content: string }>): Promise<AIResponse> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { InvestmentsService } from './investments.service';
|
import { InvestmentsService } from './investments.service';
|
||||||
|
|
||||||
@ApiTags('investments')
|
@ApiTags('investments')
|
||||||
@@ -11,14 +12,18 @@ export class InvestmentsController {
|
|||||||
constructor(private service: InvestmentsService) {}
|
constructor(private service: InvestmentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('planning.investments.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('planning.investments.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('planning.investments.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { InvoicesService } from './invoices.service';
|
import { InvoicesService } from './invoices.service';
|
||||||
|
|
||||||
@ApiTags('invoices')
|
@ApiTags('invoices')
|
||||||
@@ -11,22 +12,27 @@ export class InvoicesController {
|
|||||||
constructor(private invoicesService: InvoicesService) {}
|
constructor(private invoicesService: InvoicesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findAll() { return this.invoicesService.findAll(); }
|
findAll() { return this.invoicesService.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
|
||||||
|
|
||||||
@Post('generate-preview')
|
@Post('generate-preview')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
generatePreview(@Body() dto: { month: number; year: number }) {
|
generatePreview(@Body() dto: { month: number; year: number }) {
|
||||||
return this.invoicesService.generatePreview(dto);
|
return this.invoicesService.generatePreview(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('generate-bulk')
|
@Post('generate-bulk')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
|
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
|
||||||
return this.invoicesService.generateBulk(dto, req.user.sub);
|
return this.invoicesService.generateBulk(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('apply-late-fees')
|
@Post('apply-late-fees')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
applyLateFees(@Body() dto: { grace_period_days: number; late_fee_amount: number }, @Request() req: any) {
|
applyLateFees(@Body() dto: { grace_period_days: number; late_fee_amount: number }, @Request() req: any) {
|
||||||
return this.invoicesService.applyLateFees(dto, req.user.sub);
|
return this.invoicesService.applyLateFees(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { JournalEntriesService } from './journal-entries.service';
|
import { JournalEntriesService } from './journal-entries.service';
|
||||||
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
|
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
|
||||||
import { VoidJournalEntryDto } from './dto/void-journal-entry.dto';
|
import { VoidJournalEntryDto } from './dto/void-journal-entry.dto';
|
||||||
@@ -16,6 +17,7 @@ export class JournalEntriesController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List journal entries' })
|
@ApiOperation({ summary: 'List journal entries' })
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findAll(
|
findAll(
|
||||||
@Query('from') from?: string,
|
@Query('from') from?: string,
|
||||||
@Query('to') to?: string,
|
@Query('to') to?: string,
|
||||||
@@ -27,24 +29,28 @@ export class JournalEntriesController {
|
|||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get journal entry by ID' })
|
@ApiOperation({ summary: 'Get journal entry by ID' })
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.jeService.findOne(id);
|
return this.jeService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a journal entry' })
|
@ApiOperation({ summary: 'Create a journal entry' })
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
create(@Body() dto: CreateJournalEntryDto, @Request() req: any) {
|
create(@Body() dto: CreateJournalEntryDto, @Request() req: any) {
|
||||||
return this.jeService.create(dto, req.user.sub);
|
return this.jeService.create(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/post')
|
@Post(':id/post')
|
||||||
@ApiOperation({ summary: 'Post (finalize) a journal entry' })
|
@ApiOperation({ summary: 'Post (finalize) a journal entry' })
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
post(@Param('id') id: string, @Request() req: any) {
|
post(@Param('id') id: string, @Request() req: any) {
|
||||||
return this.jeService.post(id, req.user.sub);
|
return this.jeService.post(id, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/void')
|
@Post(':id/void')
|
||||||
@ApiOperation({ summary: 'Void a journal entry' })
|
@ApiOperation({ summary: 'Void a journal entry' })
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) {
|
void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) {
|
||||||
return this.jeService.void(id, req.user.sub, dto.reason);
|
return this.jeService.void(id, req.user.sub, dto.reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { MonthlyActualsService } from './monthly-actuals.service';
|
import { MonthlyActualsService } from './monthly-actuals.service';
|
||||||
|
|
||||||
@ApiTags('monthly-actuals')
|
@ApiTags('monthly-actuals')
|
||||||
@@ -12,12 +13,14 @@ export class MonthlyActualsController {
|
|||||||
|
|
||||||
@Get(':year/:month')
|
@Get(':year/:month')
|
||||||
@ApiOperation({ summary: 'Get monthly actuals grid for a specific month' })
|
@ApiOperation({ summary: 'Get monthly actuals grid for a specific month' })
|
||||||
|
@RequireCapability('financials.actuals.view')
|
||||||
async getGrid(@Param('year') year: string, @Param('month') month: string) {
|
async getGrid(@Param('year') year: string, @Param('month') month: string) {
|
||||||
return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month));
|
return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':year/:month')
|
@Post(':year/:month')
|
||||||
@ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' })
|
@ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' })
|
||||||
|
@RequireCapability('financials.actuals.edit')
|
||||||
async save(
|
async save(
|
||||||
@Param('year') year: string,
|
@Param('year') year: string,
|
||||||
@Param('month') month: string,
|
@Param('month') month: string,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|||||||
import { OrganizationsService } from './organizations.service';
|
import { OrganizationsService } from './organizations.service';
|
||||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
|
import { resolveCapabilitiesArray, ALL_CAPABILITIES } from '../../common/permissions';
|
||||||
|
|
||||||
@ApiTags('organizations')
|
@ApiTags('organizations')
|
||||||
@Controller('organizations')
|
@Controller('organizations')
|
||||||
@@ -23,54 +25,87 @@ export class OrganizationsController {
|
|||||||
return this.orgService.findByUser(req.user.sub);
|
return this.orgService.findByUser(req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('my-capabilities')
|
||||||
|
@ApiOperation({ summary: 'Get resolved capabilities for current user in current org' })
|
||||||
|
async getMyCapabilities(@Request() req: any) {
|
||||||
|
const org = await this.orgService.findById(req.user.orgId);
|
||||||
|
const settings = org?.settings || {};
|
||||||
|
const capabilities = resolveCapabilitiesArray(req.user.role, settings.permissionOverrides);
|
||||||
|
return { role: req.user.role, capabilities };
|
||||||
|
}
|
||||||
|
|
||||||
@Patch('settings')
|
@Patch('settings')
|
||||||
@ApiOperation({ summary: 'Update settings for the current organization' })
|
@ApiOperation({ summary: 'Update settings for the current organization' })
|
||||||
|
@RequireCapability('settings.org.edit')
|
||||||
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
|
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
|
||||||
this.requireTenantAdmin(req);
|
// Validate permissionOverrides if present
|
||||||
|
if (body.permissionOverrides) {
|
||||||
|
this.validatePermissionOverrides(body.permissionOverrides);
|
||||||
|
}
|
||||||
return this.orgService.updateSettings(req.user.orgId, body);
|
return this.orgService.updateSettings(req.user.orgId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Org Member Management ──
|
// ── Org Member Management ──
|
||||||
|
|
||||||
private requireTenantAdmin(req: any) {
|
|
||||||
const role = req.user.role;
|
|
||||||
if (!['president', 'admin', 'treasurer'].includes(role) && !req.user.isSuperadmin) {
|
|
||||||
throw new ForbiddenException('Only organization administrators can manage members');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('members')
|
@Get('members')
|
||||||
@ApiOperation({ summary: 'List members of current organization' })
|
@ApiOperation({ summary: 'List members of current organization' })
|
||||||
|
@RequireCapability('settings.members.view')
|
||||||
async getMembers(@Request() req: any) {
|
async getMembers(@Request() req: any) {
|
||||||
this.requireTenantAdmin(req);
|
|
||||||
return this.orgService.getMembers(req.user.orgId);
|
return this.orgService.getMembers(req.user.orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('members')
|
@Post('members')
|
||||||
@ApiOperation({ summary: 'Add a member to the current organization' })
|
@ApiOperation({ summary: 'Add a member to the current organization' })
|
||||||
|
@RequireCapability('settings.members.manage')
|
||||||
async addMember(
|
async addMember(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Body() body: { email: string; firstName: string; lastName: string; password: string; role: string },
|
@Body() body: { email: string; firstName: string; lastName: string; password: string; role: string },
|
||||||
) {
|
) {
|
||||||
this.requireTenantAdmin(req);
|
|
||||||
return this.orgService.addMember(req.user.orgId, body);
|
return this.orgService.addMember(req.user.orgId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('members/:id/role')
|
@Put('members/:id/role')
|
||||||
@ApiOperation({ summary: 'Update a member role' })
|
@ApiOperation({ summary: 'Update a member role' })
|
||||||
|
@RequireCapability('settings.members.manage')
|
||||||
async updateMemberRole(
|
async updateMemberRole(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() body: { role: string },
|
@Body() body: { role: string },
|
||||||
) {
|
) {
|
||||||
this.requireTenantAdmin(req);
|
|
||||||
return this.orgService.updateMemberRole(req.user.orgId, id, body.role);
|
return this.orgService.updateMemberRole(req.user.orgId, id, body.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('members/:id')
|
@Delete('members/:id')
|
||||||
@ApiOperation({ summary: 'Remove a member from the organization' })
|
@ApiOperation({ summary: 'Remove a member from the organization' })
|
||||||
|
@RequireCapability('settings.members.manage')
|
||||||
async removeMember(@Request() req: any, @Param('id') id: string) {
|
async removeMember(@Request() req: any, @Param('id') id: string) {
|
||||||
this.requireTenantAdmin(req);
|
|
||||||
return this.orgService.removeMember(req.user.orgId, id, req.user.sub);
|
return this.orgService.removeMember(req.user.orgId, id, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private validatePermissionOverrides(overrides: any) {
|
||||||
|
if (typeof overrides !== 'object' || overrides === null) {
|
||||||
|
throw new ForbiddenException('permissionOverrides must be an object');
|
||||||
|
}
|
||||||
|
const validRoles = ['president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer'];
|
||||||
|
for (const role of Object.keys(overrides)) {
|
||||||
|
if (!validRoles.includes(role)) {
|
||||||
|
throw new ForbiddenException(`Invalid role in permissionOverrides: ${role}`);
|
||||||
|
}
|
||||||
|
const entry = overrides[role];
|
||||||
|
if (entry.grant) {
|
||||||
|
for (const cap of entry.grant) {
|
||||||
|
if (!ALL_CAPABILITIES.has(cap)) {
|
||||||
|
throw new ForbiddenException(`Unknown capability in grant: ${cap}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.revoke) {
|
||||||
|
for (const cap of entry.revoke) {
|
||||||
|
if (!ALL_CAPABILITIES.has(cap)) {
|
||||||
|
throw new ForbiddenException(`Unknown capability in revoke: ${cap}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from './payments.service';
|
||||||
|
|
||||||
@ApiTags('payments')
|
@ApiTags('payments')
|
||||||
@@ -11,19 +12,24 @@ export class PaymentsController {
|
|||||||
constructor(private paymentsService: PaymentsService) {}
|
constructor(private paymentsService: PaymentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findAll() { return this.paymentsService.findAll(); }
|
findAll() { return this.paymentsService.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('transactions.view')
|
||||||
findOne(@Param('id') id: string) { return this.paymentsService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.paymentsService.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
|
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
|
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
|
||||||
return this.paymentsService.update(id, dto, req.user.sub);
|
return this.paymentsService.update(id, dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RequireCapability('transactions.edit')
|
||||||
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
|
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Body, Param, Res, UseGuards } from '@nestjs
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { ProjectsService } from './projects.service';
|
import { ProjectsService } from './projects.service';
|
||||||
|
|
||||||
@ApiTags('projects')
|
@ApiTags('projects')
|
||||||
@@ -12,9 +13,11 @@ export class ProjectsController {
|
|||||||
constructor(private service: ProjectsService) {}
|
constructor(private service: ProjectsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
async exportCSV(@Res() res: Response) {
|
async exportCSV(@Res() res: Response) {
|
||||||
const csv = await this.service.exportCSV();
|
const csv = await this.service.exportCSV();
|
||||||
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="projects.csv"' });
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="projects.csv"' });
|
||||||
@@ -22,21 +25,27 @@ export class ProjectsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('planning')
|
@Get('planning')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findForPlanning() { return this.service.findForPlanning(); }
|
findForPlanning() { return this.service.findForPlanning(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); }
|
importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
|
|
||||||
@Put(':id/planned-date')
|
@Put(':id/planned-date')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) {
|
updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) {
|
||||||
return this.service.updatePlannedDate(id, dto.planned_date);
|
return this.service.updatePlannedDate(id, dto.planned_date);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { ReportsService } from './reports.service';
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
@ApiTags('reports')
|
@ApiTags('reports')
|
||||||
@@ -11,11 +12,13 @@ export class ReportsController {
|
|||||||
constructor(private reportsService: ReportsService) {}
|
constructor(private reportsService: ReportsService) {}
|
||||||
|
|
||||||
@Get('balance-sheet')
|
@Get('balance-sheet')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getBalanceSheet(@Query('as_of') asOf?: string) {
|
getBalanceSheet(@Query('as_of') asOf?: string) {
|
||||||
return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]);
|
return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('income-statement')
|
@Get('income-statement')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) {
|
getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const defaultFrom = `${now.getFullYear()}-01-01`;
|
const defaultFrom = `${now.getFullYear()}-01-01`;
|
||||||
@@ -24,6 +27,7 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow-sankey')
|
@Get('cash-flow-sankey')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getCashFlowSankey(
|
getCashFlowSankey(
|
||||||
@Query('year') year?: string,
|
@Query('year') year?: string,
|
||||||
@Query('source') source?: string,
|
@Query('source') source?: string,
|
||||||
@@ -37,6 +41,7 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow')
|
@Get('cash-flow')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getCashFlowStatement(
|
getCashFlowStatement(
|
||||||
@Query('from') from?: string,
|
@Query('from') from?: string,
|
||||||
@Query('to') to?: string,
|
@Query('to') to?: string,
|
||||||
@@ -51,21 +56,31 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('aging')
|
@Get('aging')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getAgingReport() {
|
getAgingReport() {
|
||||||
return this.reportsService.getAgingReport();
|
return this.reportsService.getAgingReport();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('year-end')
|
@Get('year-end')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getYearEndSummary(@Query('year') year?: string) {
|
getYearEndSummary(@Query('year') year?: string) {
|
||||||
return this.reportsService.getYearEndSummary(parseInt(year || '') || new Date().getFullYear());
|
return this.reportsService.getYearEndSummary(parseInt(year || '') || new Date().getFullYear());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('dashboard')
|
@Get('dashboard')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getDashboardKPIs() {
|
getDashboardKPIs() {
|
||||||
return this.reportsService.getDashboardKPIs();
|
return this.reportsService.getDashboardKPIs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('upcoming-investment-activities')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
|
getUpcomingInvestmentActivities() {
|
||||||
|
return this.reportsService.getUpcomingInvestmentActivities();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('cash-flow-forecast')
|
@Get('cash-flow-forecast')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getCashFlowForecast(
|
getCashFlowForecast(
|
||||||
@Query('startYear') startYear?: string,
|
@Query('startYear') startYear?: string,
|
||||||
@Query('months') months?: string,
|
@Query('months') months?: string,
|
||||||
@@ -75,7 +90,16 @@ export class ReportsController {
|
|||||||
return this.reportsService.getCashFlowForecast(yr, mo);
|
return this.reportsService.getCashFlowForecast(yr, mo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('capital-planning')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
|
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
|
||||||
|
return this.reportsService.getCapitalPlanningReport(
|
||||||
|
parseInt(startYear || '') || undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('quarterly')
|
@Get('quarterly')
|
||||||
|
@RequireCapability('reports.view')
|
||||||
getQuarterlyFinancial(
|
getQuarterlyFinancial(
|
||||||
@Query('year') year?: string,
|
@Query('year') year?: string,
|
||||||
@Query('quarter') quarter?: string,
|
@Query('quarter') quarter?: string,
|
||||||
|
|||||||
@@ -780,6 +780,78 @@ export class ReportsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUpcomingInvestmentActivities() {
|
||||||
|
const now = new Date();
|
||||||
|
const in45Days = new Date(now);
|
||||||
|
in45Days.setDate(in45Days.getDate() + 45);
|
||||||
|
const in60Days = new Date(now);
|
||||||
|
in60Days.setDate(in60Days.getDate() + 60);
|
||||||
|
|
||||||
|
// 1. Investments maturing within 45 days
|
||||||
|
const maturingInvestments = await this.tenant.query(`
|
||||||
|
SELECT id, name, institution, investment_type, fund_type, current_value, principal,
|
||||||
|
interest_rate, maturity_date, purchase_date
|
||||||
|
FROM investment_accounts
|
||||||
|
WHERE is_active = true
|
||||||
|
AND maturity_date IS NOT NULL
|
||||||
|
AND maturity_date BETWEEN CURRENT_DATE AND $1::date
|
||||||
|
ORDER BY maturity_date ASC
|
||||||
|
`, [in45Days.toISOString().split('T')[0]]);
|
||||||
|
|
||||||
|
// Compute interest earned and days remaining for each
|
||||||
|
const maturing = maturingInvestments.map((inv: any) => {
|
||||||
|
const principal = parseFloat(inv.principal) || parseFloat(inv.current_value) || 0;
|
||||||
|
const rate = parseFloat(inv.interest_rate) || 0;
|
||||||
|
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : now;
|
||||||
|
const maturityDate = new Date(inv.maturity_date);
|
||||||
|
const daysHeld = Math.max((maturityDate.getTime() - purchaseDate.getTime()) / 86400000, 1);
|
||||||
|
const interestEarned = principal * (rate / 100) * (daysHeld / 365);
|
||||||
|
const daysRemaining = Math.max(Math.ceil((maturityDate.getTime() - now.getTime()) / 86400000), 0);
|
||||||
|
return {
|
||||||
|
...inv,
|
||||||
|
interest_earned: interestEarned.toFixed(2),
|
||||||
|
maturity_value: (principal + interestEarned).toFixed(2),
|
||||||
|
days_remaining: daysRemaining,
|
||||||
|
activity_type: 'maturity',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Approved scenario investments due to execute within 60 days
|
||||||
|
let scenarioItems: any[] = [];
|
||||||
|
try {
|
||||||
|
scenarioItems = await this.tenant.query(`
|
||||||
|
SELECT si.id, si.label, si.investment_type, si.fund_type, si.principal,
|
||||||
|
si.interest_rate, si.purchase_date, si.maturity_date, si.institution,
|
||||||
|
bs.name as scenario_name, bs.status as scenario_status
|
||||||
|
FROM scenario_investments si
|
||||||
|
JOIN board_scenarios bs ON bs.id = si.scenario_id
|
||||||
|
WHERE bs.status = 'approved'
|
||||||
|
AND si.executed_investment_id IS NULL
|
||||||
|
AND si.purchase_date IS NOT NULL
|
||||||
|
AND si.purchase_date BETWEEN CURRENT_DATE AND $1::date
|
||||||
|
ORDER BY si.purchase_date ASC
|
||||||
|
`, [in60Days.toISOString().split('T')[0]]);
|
||||||
|
} catch {
|
||||||
|
// scenario tables may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
const upcoming = scenarioItems.map((si: any) => {
|
||||||
|
const purchaseDate = new Date(si.purchase_date);
|
||||||
|
const daysUntil = Math.max(Math.ceil((purchaseDate.getTime() - now.getTime()) / 86400000), 0);
|
||||||
|
return {
|
||||||
|
...si,
|
||||||
|
days_until: daysUntil,
|
||||||
|
activity_type: 'planned_purchase',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
maturing_investments: maturing,
|
||||||
|
upcoming_scenario_investments: upcoming,
|
||||||
|
total_activities: maturing.length + upcoming.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cash Flow Forecast: monthly datapoints with actuals (historical) and projections (future).
|
* Cash Flow Forecast: monthly datapoints with actuals (historical) and projections (future).
|
||||||
* Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments.
|
* Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments.
|
||||||
@@ -1264,4 +1336,120 @@ export class ReportsService {
|
|||||||
over_budget_items: overBudgetItems,
|
over_budget_items: overBudgetItems,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCapitalPlanningReport(startYear?: number) {
|
||||||
|
const baseYear = startYear || new Date().getFullYear();
|
||||||
|
const years = [baseYear, baseYear + 1, baseYear + 2, baseYear + 3, baseYear + 4];
|
||||||
|
|
||||||
|
// Get all active projects
|
||||||
|
const projects = await this.tenant.query(
|
||||||
|
`SELECT id, name, description, category, estimated_cost, target_year, target_month,
|
||||||
|
useful_life_years, last_replacement_date, next_replacement_date, fund_source,
|
||||||
|
status, priority, condition_rating
|
||||||
|
FROM projects
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY category NULLS LAST, priority, name`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also try capital_projects table
|
||||||
|
let capitalProjects: any[] = [];
|
||||||
|
try {
|
||||||
|
capitalProjects = await this.tenant.query(
|
||||||
|
`SELECT id, name, description, estimated_cost, target_year, target_month,
|
||||||
|
fund_source, status, priority, notes
|
||||||
|
FROM capital_projects
|
||||||
|
WHERE status NOT IN ('cancelled')
|
||||||
|
ORDER BY priority, name`,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Table may not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge and group by category
|
||||||
|
const allProjects = [
|
||||||
|
...projects.map((p: any) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
description: p.description,
|
||||||
|
category: p.category || 'Uncategorized',
|
||||||
|
estimated_cost: parseFloat(p.estimated_cost) || 0,
|
||||||
|
target_year: parseInt(p.target_year) || null,
|
||||||
|
useful_life_years: parseInt(p.useful_life_years) || null,
|
||||||
|
last_replacement_date: p.last_replacement_date,
|
||||||
|
fund_source: p.fund_source || 'reserve',
|
||||||
|
status: p.status,
|
||||||
|
priority: parseInt(p.priority) || 3,
|
||||||
|
condition_rating: parseInt(p.condition_rating) || null,
|
||||||
|
})),
|
||||||
|
...capitalProjects
|
||||||
|
.filter((cp: any) => !projects.some((p: any) => p.name === cp.name && p.target_year === cp.target_year))
|
||||||
|
.map((cp: any) => ({
|
||||||
|
id: cp.id,
|
||||||
|
name: cp.name,
|
||||||
|
description: cp.description,
|
||||||
|
category: 'Capital Projects',
|
||||||
|
estimated_cost: parseFloat(cp.estimated_cost) || 0,
|
||||||
|
target_year: parseInt(cp.target_year) || null,
|
||||||
|
useful_life_years: null,
|
||||||
|
last_replacement_date: null,
|
||||||
|
fund_source: cp.fund_source || 'reserve',
|
||||||
|
status: cp.status,
|
||||||
|
priority: parseInt(cp.priority) || 3,
|
||||||
|
condition_rating: null,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const categories: Record<string, any[]> = {};
|
||||||
|
for (const project of allProjects) {
|
||||||
|
const cat = project.category;
|
||||||
|
if (!categories[cat]) categories[cat] = [];
|
||||||
|
categories[cat].push(project);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build year columns for each project
|
||||||
|
const categoryData = Object.entries(categories).map(([category, items]) => ({
|
||||||
|
category,
|
||||||
|
projects: items.map((p) => {
|
||||||
|
const yearAmounts: Record<number, number> = {};
|
||||||
|
let beyond = 0;
|
||||||
|
if (p.target_year) {
|
||||||
|
if (p.target_year >= years[0] && p.target_year <= years[4]) {
|
||||||
|
yearAmounts[p.target_year] = p.estimated_cost;
|
||||||
|
} else if (p.target_year > years[4]) {
|
||||||
|
beyond = p.estimated_cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
year_amounts: yearAmounts,
|
||||||
|
beyond,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Compute totals per year
|
||||||
|
const yearTotals: Record<number, number> = {};
|
||||||
|
let beyondTotal = 0;
|
||||||
|
for (const y of years) yearTotals[y] = 0;
|
||||||
|
for (const cat of categoryData) {
|
||||||
|
for (const p of cat.projects) {
|
||||||
|
for (const y of years) {
|
||||||
|
yearTotals[y] += p.year_amounts[y] || 0;
|
||||||
|
}
|
||||||
|
beyondTotal += p.beyond;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${years[4] - years[0] + 1}-YEAR CAPITAL PROJECT FORECAST`,
|
||||||
|
start_year: years[0],
|
||||||
|
years,
|
||||||
|
categories: categoryData,
|
||||||
|
year_totals: yearTotals,
|
||||||
|
beyond_total: beyondTotal,
|
||||||
|
grand_total: Object.values(yearTotals).reduce((a, b) => a + b, 0) + beyondTotal,
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { ReserveComponentsService } from './reserve-components.service';
|
import { ReserveComponentsService } from './reserve-components.service';
|
||||||
|
|
||||||
@ApiTags('reserve-components')
|
@ApiTags('reserve-components')
|
||||||
@@ -11,14 +12,18 @@ export class ReserveComponentsController {
|
|||||||
constructor(private service: ReserveComponentsService) {}
|
constructor(private service: ReserveComponentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('planning.projects.view')
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('planning.projects.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity({ schema: 'shared', name: 'shadow_ai_models' })
|
||||||
|
export class ShadowAiModel {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 10, unique: true })
|
||||||
|
slot: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100 })
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ name: 'api_url', type: 'varchar', length: 500 })
|
||||||
|
apiUrl: string;
|
||||||
|
|
||||||
|
@Column({ name: 'api_key', type: 'varchar', length: 500 })
|
||||||
|
apiKey: string;
|
||||||
|
|
||||||
|
@Column({ name: 'model_name', type: 'varchar', length: 200 })
|
||||||
|
modelName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive: boolean;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ShadowRun } from './shadow-run.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'shared', name: 'shadow_run_results' })
|
||||||
|
export class ShadowRunResult {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'run_id', type: 'uuid' })
|
||||||
|
runId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'model_role', type: 'varchar', length: 20 })
|
||||||
|
modelRole: string;
|
||||||
|
|
||||||
|
@Column({ name: 'model_name', type: 'varchar', length: 200 })
|
||||||
|
modelName: string;
|
||||||
|
|
||||||
|
@Column({ name: 'api_url', type: 'varchar', length: 500 })
|
||||||
|
apiUrl: string;
|
||||||
|
|
||||||
|
@Column({ name: 'raw_response', type: 'text', nullable: true })
|
||||||
|
rawResponse: string;
|
||||||
|
|
||||||
|
@Column({ name: 'parsed_response', type: 'jsonb', nullable: true })
|
||||||
|
parsedResponse: any;
|
||||||
|
|
||||||
|
@Column({ name: 'response_time_ms', type: 'integer', nullable: true })
|
||||||
|
responseTimeMs: number;
|
||||||
|
|
||||||
|
@Column({ name: 'token_usage', type: 'jsonb', nullable: true })
|
||||||
|
tokenUsage: any;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||||
|
errorMessage: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => ShadowRun, (run) => run.results, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'run_id' })
|
||||||
|
run: ShadowRun;
|
||||||
|
}
|
||||||
44
backend/src/modules/shadow-ai/entities/shadow-run.entity.ts
Normal file
44
backend/src/modules/shadow-ai/entities/shadow-run.entity.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
OneToMany,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { ShadowRunResult } from './shadow-run-result.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'shared', name: 'shadow_runs' })
|
||||||
|
export class ShadowRun {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||||
|
tenantId: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 30 })
|
||||||
|
feature: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'running' })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@Column({ name: 'triggered_by', type: 'uuid', nullable: true })
|
||||||
|
triggeredBy: string;
|
||||||
|
|
||||||
|
@Column({ name: 'prompt_messages', type: 'jsonb' })
|
||||||
|
promptMessages: any;
|
||||||
|
|
||||||
|
@Column({ name: 'started_at', type: 'timestamptz', default: () => 'NOW()' })
|
||||||
|
startedAt: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||||
|
completedAt: Date;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => ShadowRunResult, (result) => result.run, { eager: true })
|
||||||
|
results: ShadowRunResult[];
|
||||||
|
|
||||||
|
// Virtual field populated via JOIN
|
||||||
|
tenantName?: string;
|
||||||
|
}
|
||||||
118
backend/src/modules/shadow-ai/shadow-ai.controller.ts
Normal file
118
backend/src/modules/shadow-ai/shadow-ai.controller.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Put,
|
||||||
|
Post,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
Req,
|
||||||
|
ForbiddenException,
|
||||||
|
BadRequestException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
import { ShadowAiService } from './shadow-ai.service';
|
||||||
|
|
||||||
|
@ApiTags('admin/shadow-ai')
|
||||||
|
@Controller('admin/shadow-ai')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class ShadowAiController {
|
||||||
|
constructor(
|
||||||
|
private shadowAiService: ShadowAiService,
|
||||||
|
private usersService: UsersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async requireSuperadmin(req: any) {
|
||||||
|
const user = await this.usersService.findById(req.user.userId || req.user.sub);
|
||||||
|
if (!user?.isSuperadmin) {
|
||||||
|
throw new ForbiddenException('Superadmin access required');
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Model Configuration ──
|
||||||
|
|
||||||
|
@Get('models')
|
||||||
|
async getModels(@Req() req: any) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
return this.shadowAiService.getModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('models/:slot')
|
||||||
|
async upsertModel(
|
||||||
|
@Req() req: any,
|
||||||
|
@Param('slot') slot: string,
|
||||||
|
@Body() body: { name: string; apiUrl: string; apiKey: string; modelName: string; isActive?: boolean },
|
||||||
|
) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
if (!['A', 'B'].includes(slot)) {
|
||||||
|
throw new BadRequestException('Slot must be A or B');
|
||||||
|
}
|
||||||
|
if (!body.name || !body.apiUrl || !body.apiKey || !body.modelName) {
|
||||||
|
throw new BadRequestException('name, apiUrl, apiKey, and modelName are required');
|
||||||
|
}
|
||||||
|
return this.shadowAiService.upsertModel(slot, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('models/:slot')
|
||||||
|
async deleteModel(@Req() req: any, @Param('slot') slot: string) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
if (!['A', 'B'].includes(slot)) {
|
||||||
|
throw new BadRequestException('Slot must be A or B');
|
||||||
|
}
|
||||||
|
return this.shadowAiService.deleteModel(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shadow Runs ──
|
||||||
|
|
||||||
|
@Post('runs')
|
||||||
|
async triggerRun(
|
||||||
|
@Req() req: any,
|
||||||
|
@Body() body: { tenantId: string; feature: string },
|
||||||
|
) {
|
||||||
|
const user = await this.requireSuperadmin(req);
|
||||||
|
const validFeatures = ['operating_health', 'reserve_health', 'investment_recommendations'];
|
||||||
|
if (!validFeatures.includes(body.feature)) {
|
||||||
|
throw new BadRequestException(`Feature must be one of: ${validFeatures.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (!body.tenantId) {
|
||||||
|
throw new BadRequestException('tenantId is required');
|
||||||
|
}
|
||||||
|
return this.shadowAiService.triggerRun(
|
||||||
|
body.tenantId,
|
||||||
|
body.feature as any,
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('runs')
|
||||||
|
async getRunHistory(
|
||||||
|
@Req() req: any,
|
||||||
|
@Query('page') page?: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
@Query('tenantId') tenantId?: string,
|
||||||
|
@Query('feature') feature?: string,
|
||||||
|
) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
return this.shadowAiService.getRunHistory({
|
||||||
|
page: page ? parseInt(page) : undefined,
|
||||||
|
limit: limit ? parseInt(limit) : undefined,
|
||||||
|
tenantId,
|
||||||
|
feature,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('runs/:id')
|
||||||
|
async getRunDetail(@Req() req: any, @Param('id') id: string) {
|
||||||
|
await this.requireSuperadmin(req);
|
||||||
|
const detail = await this.shadowAiService.getRunDetail(id);
|
||||||
|
if (!detail) throw new NotFoundException('Shadow run not found');
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/src/modules/shadow-ai/shadow-ai.module.ts
Normal file
26
backend/src/modules/shadow-ai/shadow-ai.module.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Module, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ShadowAiController } from './shadow-ai.controller';
|
||||||
|
import { ShadowAiService } from './shadow-ai.service';
|
||||||
|
import { ShadowAiModel } from './entities/shadow-ai-model.entity';
|
||||||
|
import { ShadowRun } from './entities/shadow-run.entity';
|
||||||
|
import { ShadowRunResult } from './entities/shadow-run-result.entity';
|
||||||
|
import { HealthScoresModule } from '../health-scores/health-scores.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ShadowAiModel, ShadowRun, ShadowRunResult]),
|
||||||
|
HealthScoresModule,
|
||||||
|
UsersModule,
|
||||||
|
],
|
||||||
|
controllers: [ShadowAiController],
|
||||||
|
providers: [ShadowAiService],
|
||||||
|
})
|
||||||
|
export class ShadowAiModule implements OnModuleInit {
|
||||||
|
constructor(private shadowAiService: ShadowAiService) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.shadowAiService.ensureTables();
|
||||||
|
}
|
||||||
|
}
|
||||||
723
backend/src/modules/shadow-ai/shadow-ai.service.ts
Normal file
723
backend/src/modules/shadow-ai/shadow-ai.service.ts
Normal file
@@ -0,0 +1,723 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { HealthScoresService } from '../health-scores/health-scores.service';
|
||||||
|
import { callOpenAICompatible } from '../../common/utils/ai-caller';
|
||||||
|
|
||||||
|
type Feature = 'operating_health' | 'reserve_health' | 'investment_recommendations';
|
||||||
|
|
||||||
|
interface ModelConfig {
|
||||||
|
role: string;
|
||||||
|
name: string;
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
modelName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ShadowAiService {
|
||||||
|
private readonly logger = new Logger(ShadowAiService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private dataSource: DataSource,
|
||||||
|
private configService: ConfigService,
|
||||||
|
private healthScoresService: HealthScoresService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ── Model Configuration CRUD ──
|
||||||
|
|
||||||
|
async getModels() {
|
||||||
|
const rows = await this.dataSource.query(
|
||||||
|
`SELECT id, slot, name, api_url, api_key, model_name, is_active, created_at, updated_at
|
||||||
|
FROM shared.shadow_ai_models ORDER BY slot`,
|
||||||
|
);
|
||||||
|
return rows.map((r: any) => ({
|
||||||
|
...r,
|
||||||
|
api_key: r.api_key ? `****${r.api_key.slice(-4)}` : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertModel(slot: string, dto: { name: string; apiUrl: string; apiKey: string; modelName: string; isActive?: boolean }) {
|
||||||
|
const isActive = dto.isActive !== undefined ? dto.isActive : true;
|
||||||
|
|
||||||
|
// Check if model exists for this slot
|
||||||
|
const existing = await this.dataSource.query(
|
||||||
|
`SELECT id, api_key FROM shared.shadow_ai_models WHERE slot = $1`,
|
||||||
|
[slot],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// If apiKey is masked (starts with ****), keep the existing key
|
||||||
|
const apiKey = dto.apiKey.startsWith('****') ? existing[0].api_key : dto.apiKey;
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.shadow_ai_models
|
||||||
|
SET name = $1, api_url = $2, api_key = $3, model_name = $4, is_active = $5, updated_at = NOW()
|
||||||
|
WHERE slot = $6`,
|
||||||
|
[dto.name, dto.apiUrl, apiKey, dto.modelName, isActive, slot],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.shadow_ai_models (slot, name, api_url, api_key, model_name, is_active)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[slot, dto.name, dto.apiUrl, dto.apiKey, dto.modelName, isActive],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { slot, status: 'saved' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteModel(slot: string) {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`DELETE FROM shared.shadow_ai_models WHERE slot = $1`,
|
||||||
|
[slot],
|
||||||
|
);
|
||||||
|
return { slot, status: 'deleted' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shadow Run Execution ──
|
||||||
|
|
||||||
|
async triggerRun(tenantId: string, feature: Feature, userId: string) {
|
||||||
|
// Look up tenant schema
|
||||||
|
const orgs = await this.dataSource.query(
|
||||||
|
`SELECT schema_name, name FROM shared.organizations WHERE id = $1`,
|
||||||
|
[tenantId],
|
||||||
|
);
|
||||||
|
if (!orgs.length) throw new Error('Tenant not found');
|
||||||
|
const schemaName = orgs[0].schema_name;
|
||||||
|
|
||||||
|
// Build prompt messages for the feature
|
||||||
|
const messages = await this.buildPromptMessages(schemaName, feature);
|
||||||
|
|
||||||
|
// Create shadow run record
|
||||||
|
const runRows = await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.shadow_runs (tenant_id, feature, status, triggered_by, prompt_messages, started_at)
|
||||||
|
VALUES ($1, $2, 'running', $3, $4, NOW())
|
||||||
|
RETURNING id`,
|
||||||
|
[tenantId, feature, userId, JSON.stringify(messages)],
|
||||||
|
);
|
||||||
|
const runId = runRows[0].id;
|
||||||
|
|
||||||
|
// Get model configs
|
||||||
|
const modelConfigs = await this.getModelConfigs();
|
||||||
|
|
||||||
|
// Create pending result rows
|
||||||
|
for (const config of modelConfigs) {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO shared.shadow_run_results (run_id, model_role, model_name, api_url, status)
|
||||||
|
VALUES ($1, $2, $3, $4, 'pending')`,
|
||||||
|
[runId, config.role, config.modelName, config.apiUrl],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget: run all models in parallel
|
||||||
|
this.executeModels(runId, messages, modelConfigs, feature).catch((err) => {
|
||||||
|
this.logger.error(`Shadow run ${runId} failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { runId, status: 'running' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run History ──
|
||||||
|
|
||||||
|
async getRunHistory(query: { page?: number; limit?: number; tenantId?: string; feature?: string }) {
|
||||||
|
const page = query.page || 1;
|
||||||
|
const limit = Math.min(query.limit || 20, 100);
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let where = '';
|
||||||
|
const params: any[] = [];
|
||||||
|
let paramIdx = 1;
|
||||||
|
|
||||||
|
if (query.tenantId) {
|
||||||
|
where += ` AND sr.tenant_id = $${paramIdx++}`;
|
||||||
|
params.push(query.tenantId);
|
||||||
|
}
|
||||||
|
if (query.feature) {
|
||||||
|
where += ` AND sr.feature = $${paramIdx++}`;
|
||||||
|
params.push(query.feature);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows, countRows] = await Promise.all([
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT sr.id, sr.tenant_id, sr.feature, sr.status, sr.started_at, sr.completed_at, sr.created_at,
|
||||||
|
o.name as tenant_name,
|
||||||
|
(SELECT COUNT(*) FROM shared.shadow_run_results rr WHERE rr.run_id = sr.id) as result_count,
|
||||||
|
(SELECT COUNT(*) FROM shared.shadow_run_results rr WHERE rr.run_id = sr.id AND rr.status = 'success') as success_count
|
||||||
|
FROM shared.shadow_runs sr
|
||||||
|
LEFT JOIN shared.organizations o ON o.id = sr.tenant_id
|
||||||
|
WHERE 1=1 ${where}
|
||||||
|
ORDER BY sr.created_at DESC
|
||||||
|
LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
|
||||||
|
[...params, limit, offset],
|
||||||
|
),
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT COUNT(*) as total FROM shared.shadow_runs sr WHERE 1=1 ${where}`,
|
||||||
|
params,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
runs: rows,
|
||||||
|
total: parseInt(countRows[0]?.total || '0'),
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRunDetail(runId: string) {
|
||||||
|
const [runs, results] = await Promise.all([
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT sr.*, o.name as tenant_name
|
||||||
|
FROM shared.shadow_runs sr
|
||||||
|
LEFT JOIN shared.organizations o ON o.id = sr.tenant_id
|
||||||
|
WHERE sr.id = $1`,
|
||||||
|
[runId],
|
||||||
|
),
|
||||||
|
this.dataSource.query(
|
||||||
|
`SELECT * FROM shared.shadow_run_results
|
||||||
|
WHERE run_id = $1
|
||||||
|
ORDER BY CASE model_role
|
||||||
|
WHEN 'production' THEN 1
|
||||||
|
WHEN 'alternate_a' THEN 2
|
||||||
|
WHEN 'alternate_b' THEN 3
|
||||||
|
END`,
|
||||||
|
[runId],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!runs.length) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...runs[0],
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private Helpers ──
|
||||||
|
|
||||||
|
private async buildPromptMessages(
|
||||||
|
schemaName: string,
|
||||||
|
feature: Feature,
|
||||||
|
): Promise<Array<{ role: string; content: string }>> {
|
||||||
|
if (feature === 'operating_health' || feature === 'reserve_health') {
|
||||||
|
const qr = this.dataSource.createQueryRunner();
|
||||||
|
try {
|
||||||
|
await qr.connect();
|
||||||
|
await qr.query(`SET search_path TO "${schemaName}"`);
|
||||||
|
|
||||||
|
const scoreType = feature === 'operating_health' ? 'operating' : 'reserve';
|
||||||
|
const data = scoreType === 'operating'
|
||||||
|
? await this.healthScoresService.gatherOperatingData(qr)
|
||||||
|
: await this.healthScoresService.gatherReserveData(qr);
|
||||||
|
|
||||||
|
return scoreType === 'operating'
|
||||||
|
? this.healthScoresService.buildOperatingPrompt(data)
|
||||||
|
: this.healthScoresService.buildReservePrompt(data);
|
||||||
|
} finally {
|
||||||
|
await qr.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// investment_recommendations — build prompt directly via DataSource
|
||||||
|
return this.buildInvestmentPromptForSchema(schemaName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build investment recommendation prompts for a given tenant schema.
|
||||||
|
* Self-contained: uses DataSource directly, no request-scoped dependencies.
|
||||||
|
*/
|
||||||
|
private async buildInvestmentPromptForSchema(schemaName: string): Promise<Array<{ role: string; content: string }>> {
|
||||||
|
const qr = this.dataSource.createQueryRunner();
|
||||||
|
try {
|
||||||
|
await qr.connect();
|
||||||
|
await qr.query(`SET search_path TO "${schemaName}"`);
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const currentMonth = new Date().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'];
|
||||||
|
|
||||||
|
// ── Financial snapshot ──
|
||||||
|
const [accountBalances, investmentAccounts, budgets, projects] = await Promise.all([
|
||||||
|
qr.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
|
||||||
|
`),
|
||||||
|
qr.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`),
|
||||||
|
qr.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]),
|
||||||
|
qr.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`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [opCashResult, resCashResult, budgetSummary, assessmentIncome] = await Promise.all([
|
||||||
|
qr.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`),
|
||||||
|
qr.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`),
|
||||||
|
qr.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]),
|
||||||
|
qr.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`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const operatingCash = accountBalances.filter((a: any) => a.fund_type === 'operating' && a.account_type === 'asset').reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
|
||||||
|
const reserveCash = accountBalances.filter((a: any) => a.fund_type === 'reserve' && a.account_type === 'asset').reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
|
||||||
|
const operatingInvestments = investmentAccounts.filter((i: any) => i.fund_type === 'operating').reduce((s: number, i: any) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||||
|
const reserveInvestments = investmentAccounts.filter((i: any) => i.fund_type === 'reserve').reduce((s: number, i: any) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
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: {
|
||||||
|
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'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 12-month forecast ──
|
||||||
|
const [opInvRows, resInvRows] = await Promise.all([
|
||||||
|
qr.query(`SELECT COALESCE(SUM(current_value),0) as total FROM investment_accounts WHERE fund_type='operating' AND is_active=true`),
|
||||||
|
qr.query(`SELECT COALESCE(SUM(current_value),0) as total FROM investment_accounts WHERE fund_type='reserve' AND is_active=true`),
|
||||||
|
]);
|
||||||
|
let runOpCash = parseFloat(opCashResult[0]?.total || '0'), runResCash = parseFloat(resCashResult[0]?.total || '0');
|
||||||
|
let runOpInv = parseFloat(opInvRows[0]?.total || '0'), runResInv = parseFloat(resInvRows[0]?.total || '0');
|
||||||
|
|
||||||
|
const assessmentGroups = await qr.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 getAssessmentInc = (month: number) => {
|
||||||
|
let op = 0, res = 0;
|
||||||
|
for (const g of assessmentGroups) {
|
||||||
|
const units = parseInt(g.unit_count) || 0, reg = parseFloat(g.regular_assessment) || 0, spec = parseFloat(g.special_assessment) || 0;
|
||||||
|
const freq = g.frequency || 'monthly';
|
||||||
|
let applies = freq === 'monthly' || (freq === 'quarterly' && [1,4,7,10].includes(month)) || (freq === 'annual' && month === 1);
|
||||||
|
if (applies) { op += reg * units; res += spec * units; }
|
||||||
|
}
|
||||||
|
return { operating: op, reserve: res };
|
||||||
|
};
|
||||||
|
|
||||||
|
const budgetsByYM: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
|
||||||
|
for (const yr of [year, year + 1]) {
|
||||||
|
const bRows = await qr.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 k = `${yr}-${m+1}`;
|
||||||
|
if (!budgetsByYM[k]) budgetsByYM[k] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||||
|
for (const r of bRows) {
|
||||||
|
const amt = parseFloat(r[monthNames[m]]) || 0;
|
||||||
|
if (!amt) continue;
|
||||||
|
const isOp = r.fund_type === 'operating';
|
||||||
|
if (r.account_type === 'income') { if (isOp) budgetsByYM[k].opIncome += amt; else budgetsByYM[k].resIncome += amt; }
|
||||||
|
else if (r.account_type === 'expense') { if (isOp) budgetsByYM[k].opExpense += amt; else budgetsByYM[k].resExpense += amt; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maturities = await qr.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 matIdx: Record<string, { operating: number; reserve: number }> = {};
|
||||||
|
for (const inv of maturities) {
|
||||||
|
const d = new Date(inv.maturity_date), k = `${d.getFullYear()}-${d.getMonth()+1}`;
|
||||||
|
if (!matIdx[k]) matIdx[k] = { operating: 0, reserve: 0 };
|
||||||
|
const val = parseFloat(inv.current_value) || 0, rate = parseFloat(inv.interest_rate) || 0;
|
||||||
|
const pDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
|
||||||
|
const days = Math.max((d.getTime() - pDate.getTime()) / 86400000, 1);
|
||||||
|
const total = val + val * (rate/100) * (days/365);
|
||||||
|
if (inv.fund_type === 'operating') matIdx[k].operating += total; else matIdx[k].reserve += total;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projExp = await qr.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 projIdx: Record<string, { operating: number; reserve: number }> = {};
|
||||||
|
for (const p of projExp) {
|
||||||
|
const k = `${parseInt(p.target_year)}-${parseInt(p.target_month)||6}`;
|
||||||
|
if (!projIdx[k]) projIdx[k] = { operating: 0, reserve: 0 };
|
||||||
|
const c = parseFloat(p.estimated_cost) || 0;
|
||||||
|
if (p.fund_source === 'operating') projIdx[k].operating += c; else projIdx[k].reserve += c;
|
||||||
|
}
|
||||||
|
|
||||||
|
const datapoints: any[] = [];
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
const fY = year + Math.floor((currentMonth-1+i)/12), fM = ((currentMonth-1+i)%12)+1;
|
||||||
|
const k = `${fY}-${fM}`, label = `${monthLabels[fM-1]} ${fY}`;
|
||||||
|
const asmt = getAssessmentInc(fM), bud = budgetsByYM[k] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||||
|
const mat = matIdx[k] || { operating: 0, reserve: 0 }, proj = projIdx[k] || { operating: 0, reserve: 0 };
|
||||||
|
const opInc = bud.opIncome > 0 ? bud.opIncome : asmt.operating, resInc = bud.resIncome > 0 ? bud.resIncome : asmt.reserve;
|
||||||
|
runOpCash += opInc - bud.opExpense - proj.operating + mat.operating;
|
||||||
|
runResCash += resInc - bud.resExpense - proj.reserve + mat.reserve;
|
||||||
|
if (mat.operating > 0) runOpInv = Math.max(0, runOpInv - mat.operating * 0.96);
|
||||||
|
if (mat.reserve > 0) runResInv = Math.max(0, runResInv - mat.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,
|
||||||
|
op_income: Math.round(opInc*100)/100, op_expense: Math.round(bud.opExpense*100)/100,
|
||||||
|
res_income: Math.round(resInc*100)/100, res_expense: Math.round(bud.resExpense*100)/100,
|
||||||
|
project_cost_op: Math.round(proj.operating*100)/100, project_cost_res: Math.round(proj.reserve*100)/100,
|
||||||
|
maturity_op: Math.round(mat.operating*100)/100, maturity_res: Math.round(mat.reserve*100)/100 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const asmtSchedule = 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),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Market rates from shared schema ──
|
||||||
|
const fetchLatest = async (rateType: string) =>
|
||||||
|
qr.query(`SELECT bank_name, apy, min_deposit, term, term_months, rate_type, fetched_at
|
||||||
|
FROM shared.cd_rates WHERE rate_type=$1 AND fetched_at=(SELECT MAX(fetched_at) FROM shared.cd_rates WHERE rate_type=$1)
|
||||||
|
ORDER BY apy DESC LIMIT 25`, [rateType]);
|
||||||
|
const [cdRates, mmRates, hysRates] = await Promise.all([fetchLatest('cd'), fetchLatest('money_market'), fetchLatest('high_yield_savings')]);
|
||||||
|
const allRates = { cd: cdRates, money_market: mmRates, high_yield_savings: hysRates };
|
||||||
|
|
||||||
|
// ── Build prompt (replicates InvestmentPlanningService.buildPromptMessages) ──
|
||||||
|
const { summary, investment_accounts: invAccts, cash_flow_context: cfc } = 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 market rates (CDs, Money Market, High Yield Savings) 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.
|
||||||
|
9. When recommending money market or high yield savings accounts, focus on their liquidity advantages for operating funds. When recommending CDs, focus on their higher yields for longer-term reserve fund placement.
|
||||||
|
10. Compare current account rates against available market rates. If better rates are available, suggest specific moves with the potential additional interest income that could be earned.
|
||||||
|
|
||||||
|
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 market rates (if applicable)",
|
||||||
|
"rationale": "Financial reasoning for why this makes sense",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"label": "Component label (e.g. '6-Month CD at Marcus')",
|
||||||
|
"amount": 6600.00,
|
||||||
|
"term_months": 6,
|
||||||
|
"rate": 4.05,
|
||||||
|
"bank_name": "Marcus",
|
||||||
|
"investment_type": "cd"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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 ABOUT COMPONENTS:
|
||||||
|
- For cd_ladder recommendations, you MUST include a "components" array with each individual CD as a separate component. Each component should have its own label, amount, term_months, rate, and bank_name. The suggested_amount should be the total of all component amounts.
|
||||||
|
- For other multi-part strategies (e.g. splitting funds across multiple accounts), also include a "components" array.
|
||||||
|
- For simple single-investment recommendations, omit the "components" field entirely.
|
||||||
|
|
||||||
|
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible. When there are opportunities for better rates on existing positions, quantify the additional annual interest that could be earned.`;
|
||||||
|
|
||||||
|
const investmentsList = invAccts.length === 0 ? 'No current investments.'
|
||||||
|
: invAccts.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 = (cfc.budget_summary || []).length === 0 ? 'No budget summary available.'
|
||||||
|
: cfc.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 formatRates = (rates: any[], label: string) => rates.length === 0
|
||||||
|
? `No ${label} rate data available. Rate fetcher may not have been run yet.`
|
||||||
|
: rates.map((r: any) => `- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}%${r.term !== 'N/A' ? ` | Term: ${r.term}` : ''} | Min Deposit: ${r.min_deposit ? '$'+parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`).join('\n');
|
||||||
|
|
||||||
|
const asmtLines = asmtSchedule.length === 0 ? 'No assessment schedule available.'
|
||||||
|
: asmtSchedule.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');
|
||||||
|
|
||||||
|
const forecastLines = datapoints.map((dp: any) => {
|
||||||
|
const d: string[] = [];
|
||||||
|
if (dp.op_income > 0) d.push(`OpInc:$${dp.op_income.toFixed(0)}`);
|
||||||
|
if (dp.op_expense > 0) d.push(`OpExp:$${dp.op_expense.toFixed(0)}`);
|
||||||
|
if (dp.res_income > 0) d.push(`ResInc:$${dp.res_income.toFixed(0)}`);
|
||||||
|
if (dp.res_expense > 0) d.push(`ResExp:$${dp.res_expense.toFixed(0)}`);
|
||||||
|
if (dp.project_cost_res > 0) d.push(`ResProjCost:$${dp.project_cost_res.toFixed(0)}`);
|
||||||
|
if (dp.project_cost_op > 0) d.push(`OpProjCost:$${dp.project_cost_op.toFixed(0)}`);
|
||||||
|
if (dp.maturity_op > 0) d.push(`OpMaturity:$${dp.maturity_op.toFixed(0)}`);
|
||||||
|
if (dp.maturity_res > 0) d.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: ${d.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 ===
|
||||||
|
${asmtLines}
|
||||||
|
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: $${cfc.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 MARKET RATES ===
|
||||||
|
|
||||||
|
--- CD Rates ---
|
||||||
|
${formatRates(allRates.cd, 'CD')}
|
||||||
|
|
||||||
|
--- Money Market Rates ---
|
||||||
|
${formatRates(allRates.money_market, 'Money Market')}
|
||||||
|
|
||||||
|
--- High Yield Savings Rates ---
|
||||||
|
${formatRates(allRates.high_yield_savings, 'High Yield Savings')}
|
||||||
|
|
||||||
|
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, money market accounts, or high-yield savings?
|
||||||
|
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?
|
||||||
|
6. Could any current money market or savings accounts earn better rates at a different bank? Quantify the potential additional annual interest.
|
||||||
|
7. For operating funds that need to stay liquid, are money market or high-yield savings accounts being used optimally?`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: userPrompt },
|
||||||
|
];
|
||||||
|
} finally {
|
||||||
|
await qr.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getModelConfigs(): Promise<ModelConfig[]> {
|
||||||
|
const configs: ModelConfig[] = [];
|
||||||
|
|
||||||
|
// Production model from env vars
|
||||||
|
const prodApiUrl = this.configService.get<string>('AI_API_URL') || 'https://integrate.api.nvidia.com/v1';
|
||||||
|
const prodApiKey = this.configService.get<string>('AI_API_KEY');
|
||||||
|
const prodModel = this.configService.get<string>('AI_MODEL') || 'qwen/qwen3.5-397b-a17b';
|
||||||
|
|
||||||
|
if (prodApiKey) {
|
||||||
|
configs.push({
|
||||||
|
role: 'production',
|
||||||
|
name: 'Production',
|
||||||
|
apiUrl: prodApiUrl,
|
||||||
|
apiKey: prodApiKey,
|
||||||
|
modelName: prodModel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alternate models from DB
|
||||||
|
const alternates = await this.dataSource.query(
|
||||||
|
`SELECT slot, name, api_url, api_key, model_name
|
||||||
|
FROM shared.shadow_ai_models
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY slot`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const alt of alternates) {
|
||||||
|
configs.push({
|
||||||
|
role: alt.slot === 'A' ? 'alternate_a' : 'alternate_b',
|
||||||
|
name: alt.name,
|
||||||
|
apiUrl: alt.api_url,
|
||||||
|
apiKey: alt.api_key,
|
||||||
|
modelName: alt.model_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFeatureParams(feature: Feature): { temperature: number; maxTokens: number } {
|
||||||
|
if (feature === 'investment_recommendations') {
|
||||||
|
return { temperature: 0.3, maxTokens: 4096 };
|
||||||
|
}
|
||||||
|
return { temperature: 0.1, maxTokens: 2048 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeModels(
|
||||||
|
runId: string,
|
||||||
|
messages: Array<{ role: string; content: string }>,
|
||||||
|
configs: ModelConfig[],
|
||||||
|
feature: Feature,
|
||||||
|
) {
|
||||||
|
const { temperature, maxTokens } = this.getFeatureParams(feature);
|
||||||
|
|
||||||
|
const promises = configs.map(async (config) => {
|
||||||
|
// Mark as running
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.shadow_run_results SET status = 'running' WHERE run_id = $1 AND model_role = $2`,
|
||||||
|
[runId, config.role],
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await callOpenAICompatible({
|
||||||
|
apiUrl: config.apiUrl,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
model: config.modelName,
|
||||||
|
messages,
|
||||||
|
temperature,
|
||||||
|
maxTokens,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to parse the response as JSON
|
||||||
|
let parsedResponse: any = null;
|
||||||
|
try {
|
||||||
|
parsedResponse = JSON.parse(result.content);
|
||||||
|
} catch {
|
||||||
|
// Store raw content if not valid JSON
|
||||||
|
parsedResponse = { raw_text: result.content };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.shadow_run_results
|
||||||
|
SET status = 'success', raw_response = $1, parsed_response = $2,
|
||||||
|
response_time_ms = $3, token_usage = $4
|
||||||
|
WHERE run_id = $5 AND model_role = $6`,
|
||||||
|
[
|
||||||
|
result.rawResponse,
|
||||||
|
JSON.stringify(parsedResponse),
|
||||||
|
result.responseTimeMs,
|
||||||
|
result.usage ? JSON.stringify(result.usage) : null,
|
||||||
|
runId,
|
||||||
|
config.role,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Shadow run ${runId} - ${config.role} (${config.modelName}) completed in ${result.responseTimeMs}ms`);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Shadow run ${runId} - ${config.role} (${config.modelName}) failed: ${error.message}`);
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.shadow_run_results
|
||||||
|
SET status = 'error', error_message = $1
|
||||||
|
WHERE run_id = $2 AND model_role = $3`,
|
||||||
|
[error.message, runId, config.role],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.allSettled(promises);
|
||||||
|
|
||||||
|
// Determine overall run status
|
||||||
|
const results = await this.dataSource.query(
|
||||||
|
`SELECT status FROM shared.shadow_run_results WHERE run_id = $1`,
|
||||||
|
[runId],
|
||||||
|
);
|
||||||
|
const allSuccess = results.every((r: any) => r.status === 'success');
|
||||||
|
const allError = results.every((r: any) => r.status === 'error');
|
||||||
|
const status = allSuccess ? 'completed' : allError ? 'failed' : 'partial';
|
||||||
|
|
||||||
|
await this.dataSource.query(
|
||||||
|
`UPDATE shared.shadow_runs SET status = $1, completed_at = NOW() WHERE id = $2`,
|
||||||
|
[status, runId],
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Shadow run ${runId} finished with status: ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table Creation (for initial setup) ──
|
||||||
|
|
||||||
|
async ensureTables() {
|
||||||
|
const qr = this.dataSource.createQueryRunner();
|
||||||
|
try {
|
||||||
|
await qr.connect();
|
||||||
|
|
||||||
|
await qr.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS shared.shadow_ai_models (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
slot VARCHAR(10) NOT NULL UNIQUE CHECK (slot IN ('A', 'B')),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
api_url VARCHAR(500) NOT NULL,
|
||||||
|
api_key VARCHAR(500) NOT NULL,
|
||||||
|
model_name VARCHAR(200) NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await qr.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS shared.shadow_runs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
feature VARCHAR(30) NOT NULL CHECK (feature IN ('operating_health', 'reserve_health', 'investment_recommendations')),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'partial', 'failed')),
|
||||||
|
triggered_by UUID,
|
||||||
|
prompt_messages JSONB NOT NULL,
|
||||||
|
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await qr.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shadow_runs_tenant ON shared.shadow_runs(tenant_id)
|
||||||
|
`);
|
||||||
|
await qr.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shadow_runs_created ON shared.shadow_runs(created_at DESC)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await qr.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS shared.shadow_run_results (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
run_id UUID NOT NULL REFERENCES shared.shadow_runs(id) ON DELETE CASCADE,
|
||||||
|
model_role VARCHAR(20) NOT NULL CHECK (model_role IN ('production', 'alternate_a', 'alternate_b')),
|
||||||
|
model_name VARCHAR(200) NOT NULL,
|
||||||
|
api_url VARCHAR(500) NOT NULL,
|
||||||
|
raw_response TEXT,
|
||||||
|
parsed_response JSONB,
|
||||||
|
response_time_ms INTEGER,
|
||||||
|
token_usage JSONB,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'success', 'error')),
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(run_id, model_role)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await qr.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shadow_results_run ON shared.shadow_run_results(run_id)
|
||||||
|
`);
|
||||||
|
|
||||||
|
this.logger.log('Shadow AI tables ensured');
|
||||||
|
} finally {
|
||||||
|
await qr.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Res, UseGuards } from
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { UnitsService } from './units.service';
|
import { UnitsService } from './units.service';
|
||||||
|
|
||||||
@ApiTags('units')
|
@ApiTags('units')
|
||||||
@@ -12,9 +13,11 @@ export class UnitsController {
|
|||||||
constructor(private unitsService: UnitsService) {}
|
constructor(private unitsService: UnitsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('assessments.units.view')
|
||||||
findAll() { return this.unitsService.findAll(); }
|
findAll() { return this.unitsService.findAll(); }
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
|
@RequireCapability('assessments.units.view')
|
||||||
async exportCSV(@Res() res: Response) {
|
async exportCSV(@Res() res: Response) {
|
||||||
const csv = await this.unitsService.exportCSV();
|
const csv = await this.unitsService.exportCSV();
|
||||||
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="units.csv"' });
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="units.csv"' });
|
||||||
@@ -22,17 +25,22 @@ export class UnitsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('assessments.units.view')
|
||||||
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
|
@RequireCapability('assessments.units.edit')
|
||||||
importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); }
|
importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('assessments.units.edit')
|
||||||
create(@Body() dto: any) { return this.unitsService.create(dto); }
|
create(@Body() dto: any) { return this.unitsService.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('assessments.units.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RequireCapability('assessments.units.edit')
|
||||||
delete(@Param('id') id: string) { return this.unitsService.delete(id); }
|
delete(@Param('id') id: string) { return this.unitsService.delete(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Body, Param, Query, Res, UseGuards } from '
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
||||||
import { VendorsService } from './vendors.service';
|
import { VendorsService } from './vendors.service';
|
||||||
|
|
||||||
@ApiTags('vendors')
|
@ApiTags('vendors')
|
||||||
@@ -12,9 +13,11 @@ export class VendorsController {
|
|||||||
constructor(private vendorsService: VendorsService) {}
|
constructor(private vendorsService: VendorsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
|
@RequireCapability('reference.vendors.view')
|
||||||
findAll() { return this.vendorsService.findAll(); }
|
findAll() { return this.vendorsService.findAll(); }
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
|
@RequireCapability('reference.vendors.view')
|
||||||
async exportCSV(@Res() res: Response) {
|
async exportCSV(@Res() res: Response) {
|
||||||
const csv = await this.vendorsService.exportCSV();
|
const csv = await this.vendorsService.exportCSV();
|
||||||
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="vendors.csv"' });
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="vendors.csv"' });
|
||||||
@@ -22,19 +25,24 @@ export class VendorsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('1099-data')
|
@Get('1099-data')
|
||||||
|
@RequireCapability('reference.vendors.view')
|
||||||
get1099Data(@Query('year') year: string) {
|
get1099Data(@Query('year') year: string) {
|
||||||
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
|
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RequireCapability('reference.vendors.view')
|
||||||
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
|
@RequireCapability('reference.vendors.edit')
|
||||||
importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); }
|
importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@RequireCapability('reference.vendors.edit')
|
||||||
create(@Body() dto: any) { return this.vendorsService.create(dto); }
|
create(@Body() dto: any) { return this.vendorsService.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
|
@RequireCapability('reference.vendors.edit')
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ CREATE TABLE shared.user_organizations (
|
|||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||||
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
||||||
role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')),
|
role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')),
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
UNIQUE(user_id, organization_id)
|
UNIQUE(user_id, organization_id)
|
||||||
|
|||||||
15
db/migrations/018-ideas.sql
Normal file
15
db/migrations/018-ideas.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Ideation feature: shared ideas table for cross-tenant idea submissions
|
||||||
|
CREATE TABLE IF NOT EXISTS shared.ideas (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
org_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'new',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ideas_org_id ON shared.ideas(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ideas_status ON shared.ideas(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ideas_created_at ON shared.ideas(created_at DESC);
|
||||||
2
db/migrations/019-ideas-admin-note.sql
Normal file
2
db/migrations/019-ideas-admin-note.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add private admin note column to ideas table
|
||||||
|
ALTER TABLE shared.ideas ADD COLUMN IF NOT EXISTS admin_note TEXT;
|
||||||
9
db/migrations/020-add-vice-president-role.sql
Normal file
9
db/migrations/020-add-vice-president-role.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Migration 020: Add vice_president role to user_organizations
|
||||||
|
-- This adds the vice_president role to the CHECK constraint on the role column.
|
||||||
|
|
||||||
|
ALTER TABLE shared.user_organizations
|
||||||
|
DROP CONSTRAINT IF EXISTS user_organizations_role_check;
|
||||||
|
|
||||||
|
ALTER TABLE shared.user_organizations
|
||||||
|
ADD CONSTRAINT user_organizations_role_check
|
||||||
|
CHECK (role IN ('president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer'));
|
||||||
230
docs/gitea-runner-setup.md
Normal file
230
docs/gitea-runner-setup.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# Gitea Actions Runner Setup — HOALedgerIQ Production Server
|
||||||
|
|
||||||
|
This guide walks through setting up a self-hosted Gitea Actions runner on the production server so the deployment workflow (`.gitea/workflows/deploy.yml`) can execute automatically.
|
||||||
|
|
||||||
|
The runner uses **host execution mode** — jobs run directly on the server (not inside Docker containers) so the deploy script has access to Docker, the git repo, and the local filesystem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Ubuntu Linux production server
|
||||||
|
- Gitea instance (e.g., `https://git.sensetostyle.com`)
|
||||||
|
- Docker and Docker Compose installed on the server
|
||||||
|
- The HOALedgerIQ repo cloned at `/opt/hoa-ledgeriq`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: Enable Actions in Gitea
|
||||||
|
|
||||||
|
Ensure Actions are enabled in your Gitea configuration (`/etc/gitea/app.ini`):
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[actions]
|
||||||
|
ENABLED = true
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart Gitea after making changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: Get a Registration Token
|
||||||
|
|
||||||
|
1. Log into your Gitea instance
|
||||||
|
2. Navigate to **Site Administration** → **Actions** → **Runners**
|
||||||
|
3. Copy the **Registration Token**
|
||||||
|
|
||||||
|
> **Tip:** For tighter security, you can get a repo-scoped token instead:
|
||||||
|
> Repo → **Settings** → **Actions** → **Runners** → copy the token shown there.
|
||||||
|
> This limits the runner to only execute workflows from that specific repository.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: Install the Act Runner Binary
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download the latest act_runner for x86_64 Linux
|
||||||
|
wget https://dl.gitea.com/act_runner/latest/act_runner-linux-amd64
|
||||||
|
|
||||||
|
# Make executable and install to system path
|
||||||
|
chmod +x act_runner-linux-amd64
|
||||||
|
sudo mv act_runner-linux-amd64 /usr/local/bin/act_runner
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
act_runner --version
|
||||||
|
```
|
||||||
|
|
||||||
|
> For ARM64 servers, use `act_runner-linux-arm64` instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 4: Generate and Edit the Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /etc/act_runner
|
||||||
|
act_runner generate-config > /tmp/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `/tmp/config.yaml` and set the **labels to use host execution mode**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
runner:
|
||||||
|
labels:
|
||||||
|
- "ubuntu-latest:host"
|
||||||
|
- "ubuntu-22.04:host"
|
||||||
|
```
|
||||||
|
|
||||||
|
The `:host` suffix tells the runner to execute jobs directly on the server rather than spinning up Docker containers. This is required because the deploy script needs access to:
|
||||||
|
|
||||||
|
- The Docker socket (to run `docker compose`)
|
||||||
|
- The git repository at `/opt/hoa-ledgeriq`
|
||||||
|
- The backup scripts and database
|
||||||
|
|
||||||
|
Move the config into place and lock down permissions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mv /tmp/config.yaml /etc/act_runner/config.yaml
|
||||||
|
sudo chmod 600 /etc/act_runner/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 5: Register the Runner
|
||||||
|
|
||||||
|
```bash
|
||||||
|
act_runner register \
|
||||||
|
--no-interactive \
|
||||||
|
--instance "https://git.sensetostyle.com" \
|
||||||
|
--token "YOUR_REGISTRATION_TOKEN_HERE" \
|
||||||
|
--name "hoaledgeriq-prod" \
|
||||||
|
--labels "ubuntu-latest:host,ubuntu-22.04:host" \
|
||||||
|
--config /etc/act_runner/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a `.runner` file in the current directory containing the registration state.
|
||||||
|
|
||||||
|
> **Interactive alternative:** Run `act_runner register --config /etc/act_runner/config.yaml` and follow the prompts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 6: Set Up as a Systemd Service
|
||||||
|
|
||||||
|
Create the service file at `/etc/systemd/system/act_runner.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Gitea Actions Runner (HOALedgerIQ Prod)
|
||||||
|
Documentation=https://docs.gitea.com/usage/actions/act-runner
|
||||||
|
After=docker.service network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/opt/hoa-ledgeriq
|
||||||
|
ExecStart=/usr/local/bin/act_runner daemon --config /etc/act_runner/config.yaml
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Security note on `User=root`:** The deploy script needs to run `docker compose`, `git reset --hard`, etc. If you have a dedicated deploy user in the `docker` group with write access to `/opt/hoa-ledgeriq`, use that instead. Running as root is the simplest option but grants maximum privileges.
|
||||||
|
|
||||||
|
Enable and start the service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable act_runner
|
||||||
|
sudo systemctl start act_runner
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 7: Verify the Runner Is Online
|
||||||
|
|
||||||
|
Check the service is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl status act_runner
|
||||||
|
```
|
||||||
|
|
||||||
|
View logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo journalctl -u act_runner -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Then confirm in Gitea:
|
||||||
|
|
||||||
|
1. Go to **Site Administration** → **Actions** → **Runners**
|
||||||
|
2. You should see **"hoaledgeriq-prod"** listed with status **Online**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 8: Test the Workflow
|
||||||
|
|
||||||
|
1. Go to your repo on Gitea → **Actions** tab
|
||||||
|
2. Select the **"Deploy to Production"** workflow
|
||||||
|
3. Click **Run Workflow**
|
||||||
|
4. If this is the first deployment against an existing database, check the **"Mark existing migrations as applied"** box
|
||||||
|
5. Monitor the run in the Actions tab
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Runner shows as Offline
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check service status and logs
|
||||||
|
sudo systemctl status act_runner
|
||||||
|
sudo journalctl -u act_runner -n 50
|
||||||
|
|
||||||
|
# Verify the instance URL is reachable from the server
|
||||||
|
wget -qO- https://git.sensetostyle.com/api/v1/version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Workflow stuck on "Waiting for runner"
|
||||||
|
|
||||||
|
- Verify the runner labels match what the workflow expects. The workflow uses `runs-on: ubuntu-latest` which must match the `ubuntu-latest:host` label.
|
||||||
|
- Check the runner is registered at the correct scope (instance-wide, org-level, or repo-level).
|
||||||
|
|
||||||
|
### Permission denied errors during deploy
|
||||||
|
|
||||||
|
- Ensure the systemd service `User` has Docker access (`usermod -aG docker <user>`)
|
||||||
|
- Ensure the user has write access to `/opt/hoa-ledgeriq`
|
||||||
|
|
||||||
|
### Re-registering after token expiry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop act_runner
|
||||||
|
# Get a new token from Gitea admin panel, then:
|
||||||
|
act_runner register \
|
||||||
|
--no-interactive \
|
||||||
|
--instance "https://git.sensetostyle.com" \
|
||||||
|
--token "NEW_TOKEN_HERE" \
|
||||||
|
--name "hoaledgeriq-prod" \
|
||||||
|
--labels "ubuntu-latest:host,ubuntu-22.04:host" \
|
||||||
|
--config /etc/act_runner/config.yaml
|
||||||
|
sudo systemctl start act_runner
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
| Concern | Recommendation |
|
||||||
|
|---------|----------------|
|
||||||
|
| Runner user | Use a dedicated user with `docker` group access rather than `root` when possible |
|
||||||
|
| Registration token | Rotate periodically in the Gitea admin panel |
|
||||||
|
| Config file | Keep `/etc/act_runner/config.yaml` at mode `600` (owner-read only) |
|
||||||
|
| Runner scope | Register at the **repo level** instead of instance-wide so only this repo can trigger deployments |
|
||||||
|
| Workflow triggers | The deploy workflow uses `workflow_dispatch` (manual only) — no automatic triggers on push |
|
||||||
|
| Network | Ensure Gitea is accessed over HTTPS with valid SSL certificates |
|
||||||
275
docs/shadow-ai-benchmarking-plan.md
Normal file
275
docs/shadow-ai-benchmarking-plan.md
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
# Shadow AI Benchmarking Feature
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The platform uses a single AI model (Qwen 3.5 via NVIDIA NIM) for three features: Operating Health Score, Reserve Health Score, and Investment Recommendations. The platform owner needs a way to evaluate alternate models (different providers, different versions) against the production model using real tenant data — without impacting users. This enables informed model migration decisions by comparing outputs side-by-side.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
- **New admin page** at `/admin/shadow-ai` with model configuration, run trigger, and history
|
||||||
|
- **New backend module** `shadow-ai` with controller, service, and 3 entities
|
||||||
|
- **3 new DB tables** in the `shared` schema for model configs, runs, and results
|
||||||
|
- **Shared AI caller utility** to avoid duplicating HTTP logic
|
||||||
|
- **Minimal changes** to existing services: make prompt-building methods public and export modules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Shared AI Caller Utility
|
||||||
|
|
||||||
|
### New file: `backend/src/common/utils/ai-caller.ts`
|
||||||
|
|
||||||
|
Extract the HTTP POST logic (currently duplicated in both `callAI()` methods) into a reusable function:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function callOpenAICompatible(params: {
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
messages: Array<{ role: string; content: string }>;
|
||||||
|
temperature: number;
|
||||||
|
maxTokens: number;
|
||||||
|
timeoutMs?: number; // default 600000
|
||||||
|
}): Promise<{
|
||||||
|
content: string; // cleaned JSON string (fences + <think> stripped)
|
||||||
|
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||||
|
responseTimeMs: number;
|
||||||
|
}>
|
||||||
|
```
|
||||||
|
|
||||||
|
Handles: HTTPS POST to `{apiUrl}/chat/completions`, timeout, markdown fence stripping, `<think>` block removal, timing.
|
||||||
|
|
||||||
|
## Phase 2: Expose Existing Prompt Builders
|
||||||
|
|
||||||
|
### `backend/src/modules/health-scores/health-scores.service.ts`
|
||||||
|
- Change `private` → `public` on:
|
||||||
|
- `gatherOperatingData(qr)` (line 252)
|
||||||
|
- `gatherReserveData(qr)` (line 523)
|
||||||
|
- `buildOperatingPrompt(data)` (line 790)
|
||||||
|
- `buildReservePrompt(data)` (line 930)
|
||||||
|
- `checkDataReadiness(qr, scoreType)` (used to validate data exists)
|
||||||
|
|
||||||
|
### `backend/src/modules/health-scores/health-scores.module.ts`
|
||||||
|
- Add `exports: [HealthScoresService]`
|
||||||
|
|
||||||
|
### `backend/src/modules/investment-planning/investment-planning.service.ts`
|
||||||
|
- Add new public method `buildPromptForSchema(schemaName: string)` that:
|
||||||
|
1. Creates a query runner, sets `search_path` to the tenant schema
|
||||||
|
2. Runs the same data-gathering queries (financial snapshot, market rates, monthly forecast) using the query runner directly (bypassing request-scoped `TenantService`)
|
||||||
|
3. Calls the existing `buildPromptMessages()` with gathered data
|
||||||
|
4. Returns `Array<{ role: string; content: string }>`
|
||||||
|
- Change `buildPromptMessages()` from `private` → `public` (line 880)
|
||||||
|
|
||||||
|
### `backend/src/modules/investment-planning/investment-planning.module.ts`
|
||||||
|
- Add `exports: [InvestmentPlanningService]`
|
||||||
|
|
||||||
|
## Phase 3: Database Tables & Entities
|
||||||
|
|
||||||
|
### 3 new tables in `shared` schema
|
||||||
|
|
||||||
|
**`shared.shadow_ai_models`** — Alternate model configurations (slots A and B)
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| slot | VARCHAR(10) | CHECK IN ('A', 'B'), UNIQUE |
|
||||||
|
| name | VARCHAR(100) | Display label |
|
||||||
|
| api_url | VARCHAR(500) | OpenAI-compatible endpoint |
|
||||||
|
| api_key | VARCHAR(500) | Bearer token |
|
||||||
|
| model_name | VARCHAR(200) | Model identifier |
|
||||||
|
| is_active | BOOLEAN | Default true |
|
||||||
|
| created_at | TIMESTAMPTZ | |
|
||||||
|
| updated_at | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
**`shared.shadow_runs`** — One row per comparison execution
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| tenant_id | UUID FK | → shared.organizations |
|
||||||
|
| feature | VARCHAR(30) | CHECK IN ('operating_health', 'reserve_health', 'investment_recommendations') |
|
||||||
|
| status | VARCHAR(20) | CHECK IN ('running', 'completed', 'partial', 'failed') |
|
||||||
|
| triggered_by | UUID FK | → shared.users |
|
||||||
|
| prompt_messages | JSONB | Exact messages sent to all models (proof of identical input) |
|
||||||
|
| started_at | TIMESTAMPTZ | |
|
||||||
|
| completed_at | TIMESTAMPTZ | |
|
||||||
|
| created_at | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
**`shared.shadow_run_results`** — One row per model per run (up to 3 per run)
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| id | UUID PK | |
|
||||||
|
| run_id | UUID FK | → shadow_runs ON DELETE CASCADE |
|
||||||
|
| model_role | VARCHAR(20) | CHECK IN ('production', 'alternate_a', 'alternate_b'), UNIQUE(run_id, model_role) |
|
||||||
|
| model_name | VARCHAR(200) | Snapshot of model used |
|
||||||
|
| api_url | VARCHAR(500) | Snapshot of endpoint used |
|
||||||
|
| raw_response | TEXT | Unprocessed AI response |
|
||||||
|
| parsed_response | JSONB | Validated structured output |
|
||||||
|
| response_time_ms | INTEGER | |
|
||||||
|
| token_usage | JSONB | { prompt_tokens, completion_tokens, total_tokens } |
|
||||||
|
| status | VARCHAR(20) | CHECK IN ('pending', 'running', 'success', 'error') |
|
||||||
|
| error_message | TEXT | |
|
||||||
|
| created_at | TIMESTAMPTZ | |
|
||||||
|
|
||||||
|
### Entity files
|
||||||
|
- `backend/src/modules/shadow-ai/entities/shadow-ai-model.entity.ts`
|
||||||
|
- `backend/src/modules/shadow-ai/entities/shadow-run.entity.ts`
|
||||||
|
- `backend/src/modules/shadow-ai/entities/shadow-run-result.entity.ts`
|
||||||
|
|
||||||
|
All use `@Entity({ schema: 'shared', name: '...' })` pattern.
|
||||||
|
|
||||||
|
## Phase 4: Shadow AI Backend Module
|
||||||
|
|
||||||
|
### New directory: `backend/src/modules/shadow-ai/`
|
||||||
|
|
||||||
|
### `shadow-ai.service.ts`
|
||||||
|
|
||||||
|
**Model CRUD:**
|
||||||
|
- `getModels()` — Return both slots, mask API keys (show last 4 chars)
|
||||||
|
- `upsertModel(slot, dto)` — INSERT/UPDATE config for slot A or B
|
||||||
|
- `deleteModel(slot)` — Remove model config
|
||||||
|
|
||||||
|
**Run Execution:**
|
||||||
|
- `triggerRun(tenantId, feature, userId)`:
|
||||||
|
1. Look up tenant `schema_name` from `shared.organizations`
|
||||||
|
2. Build prompt messages by calling the appropriate exposed method:
|
||||||
|
- `operating_health`: Create query runner → set search_path → `healthScoresService.gatherOperatingData(qr)` → `healthScoresService.buildOperatingPrompt(data)`
|
||||||
|
- `reserve_health`: Same pattern with reserve methods
|
||||||
|
- `investment_recommendations`: `investmentPlanningService.buildPromptForSchema(schemaName)`
|
||||||
|
3. Insert `shadow_runs` row with `prompt_messages` stored as JSONB
|
||||||
|
4. Get production config from env vars, alternate configs from DB
|
||||||
|
5. Insert 1-3 `shadow_run_results` rows as 'pending' (production + active alternates)
|
||||||
|
6. Return `{ runId }` immediately
|
||||||
|
7. Fire-and-forget: call all models in parallel using `callOpenAICompatible()`
|
||||||
|
- Per feature: operating/reserve use temp 0.1, max_tokens 2048; investment uses temp 0.3, max_tokens 4096
|
||||||
|
8. Update each result row as it completes (success/error, parsed response, timing)
|
||||||
|
9. Update run status when all complete
|
||||||
|
|
||||||
|
**History:**
|
||||||
|
- `getRunHistory(page, limit, tenantFilter?, featureFilter?)` — Paginated list with tenant name JOIN
|
||||||
|
- `getRunDetail(runId)` — Full run + all results
|
||||||
|
|
||||||
|
### `shadow-ai.controller.ts`
|
||||||
|
|
||||||
|
All endpoints use `@UseGuards(JwtAuthGuard)` + `requireSuperadmin(req)` pattern from `admin.controller.ts`.
|
||||||
|
|
||||||
|
| Method | Path | Body/Params |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/admin/shadow-ai/models` | — |
|
||||||
|
| PUT | `/admin/shadow-ai/models/:slot` | `{ name, apiUrl, apiKey, modelName, isActive }` |
|
||||||
|
| DELETE | `/admin/shadow-ai/models/:slot` | — |
|
||||||
|
| POST | `/admin/shadow-ai/runs` | `{ tenantId, feature }` |
|
||||||
|
| GET | `/admin/shadow-ai/runs` | `?page&limit&tenantId&feature` |
|
||||||
|
| GET | `/admin/shadow-ai/runs/:id` | — |
|
||||||
|
|
||||||
|
### `shadow-ai.module.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([ShadowAiModel, ShadowRun, ShadowRunResult]),
|
||||||
|
HealthScoresModule,
|
||||||
|
InvestmentPlanningModule,
|
||||||
|
UsersModule,
|
||||||
|
],
|
||||||
|
controllers: [ShadowAiController],
|
||||||
|
providers: [ShadowAiService],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Register in `backend/src/app.module.ts`
|
||||||
|
- Add `import { ShadowAiModule }` and include in the `imports` array
|
||||||
|
|
||||||
|
## Phase 5: Frontend — Admin Shadow AI Page
|
||||||
|
|
||||||
|
### New file: `frontend/src/pages/admin/AdminShadowAiPage.tsx`
|
||||||
|
|
||||||
|
**Layout**: Mantine `Tabs` with 3 tabs
|
||||||
|
|
||||||
|
#### Tab 1: "Model Configuration"
|
||||||
|
- Three `Card` components in a `SimpleGrid cols={3}`:
|
||||||
|
- **Production** (read-only): Shows model name, API URL from a dedicated endpoint or hardcoded label "From environment config"
|
||||||
|
- **Alternate A**: Form with `TextInput` (name, API URL, model name), `PasswordInput` (API key), `Switch` (active), Save/Delete buttons
|
||||||
|
- **Alternate B**: Same form
|
||||||
|
- Fetches via `GET /api/admin/shadow-ai/models`
|
||||||
|
- Saves via `PUT /api/admin/shadow-ai/models/A` or `/B`
|
||||||
|
|
||||||
|
#### Tab 2: "Run Comparison"
|
||||||
|
- `Select` dropdown for tenant (reuse `GET /api/admin/organizations` already used by AdminPage)
|
||||||
|
- `Select` for feature type (Operating Health / Reserve Health / Investment Recommendations)
|
||||||
|
- `Button` "Run Shadow Comparison"
|
||||||
|
- On trigger: `POST /api/admin/shadow-ai/runs` → get `runId`
|
||||||
|
- Poll `GET /api/admin/shadow-ai/runs/:id` every 3s via `refetchInterval` until status !== 'running'
|
||||||
|
- Show per-model progress indicators during run
|
||||||
|
- Once complete, render results using shared comparison component (below)
|
||||||
|
|
||||||
|
#### Tab 3: "History"
|
||||||
|
- `Table` with columns: Date, Tenant, Feature, Status (Badge), Duration
|
||||||
|
- Filter controls: tenant Select, feature Select
|
||||||
|
- Click row → expand detail or modal showing full comparison
|
||||||
|
- Pagination
|
||||||
|
|
||||||
|
#### Shared Component: Side-by-Side Results Display
|
||||||
|
- `SimpleGrid cols={3}` (or fewer columns if only some models were configured)
|
||||||
|
- Each column:
|
||||||
|
- Header: model name + response time `Badge`
|
||||||
|
- **For health scores**: Score with `RingProgress`, label `Badge`, summary text, factors list (color-coded by impact), recommendations list (color-coded by priority)
|
||||||
|
- **For investment**: Overall assessment text, recommendation cards with type/priority badges, risk notes
|
||||||
|
- Collapsible raw JSON via `Accordion`
|
||||||
|
- **Diff highlighting**: Where parsed values differ across models, apply subtle background highlight (e.g., `yellow.0` in Mantine theme). Simple recursive comparison of JSON keys/values.
|
||||||
|
|
||||||
|
### Route addition: `frontend/src/App.tsx`
|
||||||
|
Within the `/admin` route group (after `<Route index element={<AdminPage />} />`):
|
||||||
|
```tsx
|
||||||
|
<Route path="shadow-ai" element={<AdminShadowAiPage />} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sidebar nav: `frontend/src/components/layout/Sidebar.tsx`
|
||||||
|
In the `isAdminOnly` section (after the "Admin Panel" NavLink, around line 134):
|
||||||
|
```tsx
|
||||||
|
<NavLink
|
||||||
|
label="AI Benchmarking"
|
||||||
|
leftSection={<IconScale size={18} />}
|
||||||
|
active={location.pathname === '/admin/shadow-ai'}
|
||||||
|
onClick={() => go('/admin/shadow-ai')}
|
||||||
|
color="violet"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **`ai-caller.ts`** — Shared utility (no dependencies)
|
||||||
|
2. **Health scores + investment planning** — Make methods public, add exports, add `buildPromptForSchema`
|
||||||
|
3. **Entities** — 3 TypeORM entity files
|
||||||
|
4. **Service + Controller + Module** — Shadow AI backend
|
||||||
|
5. **Register module** in `app.module.ts`
|
||||||
|
6. **Frontend page** — `AdminShadowAiPage.tsx`
|
||||||
|
7. **Route + Sidebar** — Wire up navigation
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. **Backend**: Start server, confirm no TypeORM errors for new entities
|
||||||
|
2. **Model config**: Use admin UI to save/load/delete alternate model configs
|
||||||
|
3. **Run comparison**: Select a tenant, trigger a run, verify all 3 models are called with identical prompts
|
||||||
|
4. **Results display**: Confirm side-by-side output renders correctly for all 3 feature types
|
||||||
|
5. **History**: Verify past runs are persisted and browsable
|
||||||
|
6. **Auth**: Confirm non-superadmin users get 403 on all shadow-ai endpoints
|
||||||
|
7. **Production safety**: Verify no changes to production AI behavior — shadow runs are completely isolated
|
||||||
|
|
||||||
|
## Key Files to Modify
|
||||||
|
|
||||||
|
- `backend/src/modules/health-scores/health-scores.service.ts` — Make 5 methods public
|
||||||
|
- `backend/src/modules/health-scores/health-scores.module.ts` — Add exports
|
||||||
|
- `backend/src/modules/investment-planning/investment-planning.service.ts` — Add `buildPromptForSchema()`, make `buildPromptMessages()` public
|
||||||
|
- `backend/src/modules/investment-planning/investment-planning.module.ts` — Add exports
|
||||||
|
- `backend/src/app.module.ts` — Register ShadowAiModule
|
||||||
|
- `frontend/src/App.tsx` — Add route
|
||||||
|
- `frontend/src/components/layout/Sidebar.tsx` — Add nav item
|
||||||
|
|
||||||
|
## New Files
|
||||||
|
|
||||||
|
- `backend/src/common/utils/ai-caller.ts`
|
||||||
|
- `backend/src/modules/shadow-ai/shadow-ai.module.ts`
|
||||||
|
- `backend/src/modules/shadow-ai/shadow-ai.service.ts`
|
||||||
|
- `backend/src/modules/shadow-ai/shadow-ai.controller.ts`
|
||||||
|
- `backend/src/modules/shadow-ai/entities/shadow-ai-model.entity.ts`
|
||||||
|
- `backend/src/modules/shadow-ai/entities/shadow-run.entity.ts`
|
||||||
|
- `backend/src/modules/shadow-ai/entities/shadow-run-result.entity.ts`
|
||||||
|
- `frontend/src/pages/admin/AdminShadowAiPage.tsx`
|
||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.3.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.17",
|
"version": "2026.3.19",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^7.15.3",
|
"@mantine/core": "^7.15.3",
|
||||||
"@mantine/dates": "^7.15.3",
|
"@mantine/dates": "^7.15.3",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoa-ledgeriq-frontend",
|
"name": "hoa-ledgeriq-frontend",
|
||||||
"version": "2026.3.19",
|
"version": "2026.3.24",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -24,10 +24,13 @@ import { CashFlowPage } from './pages/reports/CashFlowPage';
|
|||||||
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
import { AgingReportPage } from './pages/reports/AgingReportPage';
|
||||||
import { YearEndPage } from './pages/reports/YearEndPage';
|
import { YearEndPage } from './pages/reports/YearEndPage';
|
||||||
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
|
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
|
||||||
|
import { CapitalPlanningPage } from './pages/reports/CapitalPlanningPage';
|
||||||
import { SettingsPage } from './pages/settings/SettingsPage';
|
import { SettingsPage } from './pages/settings/SettingsPage';
|
||||||
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
||||||
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
||||||
import { AdminPage } from './pages/admin/AdminPage';
|
import { AdminPage } from './pages/admin/AdminPage';
|
||||||
|
import { AdminIdeasPage } from './pages/admin/AdminIdeasPage';
|
||||||
|
import { AdminShadowAiPage } from './pages/admin/AdminShadowAiPage';
|
||||||
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';
|
||||||
@@ -39,6 +42,7 @@ import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentS
|
|||||||
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
|
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
|
||||||
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
|
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
|
||||||
import { PricingPage } from './pages/pricing/PricingPage';
|
import { PricingPage } from './pages/pricing/PricingPage';
|
||||||
|
import { PermissionSettingsPage } from './pages/settings/PermissionSettingsPage';
|
||||||
import { OnboardingPage } from './pages/onboarding/OnboardingPage';
|
import { OnboardingPage } from './pages/onboarding/OnboardingPage';
|
||||||
import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage';
|
import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage';
|
||||||
|
|
||||||
@@ -132,6 +136,8 @@ export function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<AdminPage />} />
|
<Route index element={<AdminPage />} />
|
||||||
|
<Route path="ideas" element={<AdminIdeasPage />} />
|
||||||
|
<Route path="shadow-ai" element={<AdminShadowAiPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Main app routes (require auth + org) */}
|
{/* Main app routes (require auth + org) */}
|
||||||
@@ -167,6 +173,7 @@ export function App() {
|
|||||||
<Route path="reports/sankey" element={<SankeyPage />} />
|
<Route path="reports/sankey" element={<SankeyPage />} />
|
||||||
<Route path="reports/year-end" element={<YearEndPage />} />
|
<Route path="reports/year-end" element={<YearEndPage />} />
|
||||||
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
|
||||||
|
<Route path="reports/capital-planning" element={<CapitalPlanningPage />} />
|
||||||
<Route path="board-planning/budgets" element={<BudgetPlanningPage />} />
|
<Route path="board-planning/budgets" element={<BudgetPlanningPage />} />
|
||||||
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
|
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
|
||||||
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />
|
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />
|
||||||
@@ -176,6 +183,7 @@ export function App() {
|
|||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="preferences" element={<UserPreferencesPage />} />
|
<Route path="preferences" element={<UserPreferencesPage />} />
|
||||||
<Route path="org-members" element={<OrgMembersPage />} />
|
<Route path="org-members" element={<OrgMembersPage />} />
|
||||||
|
<Route path="settings/permissions" element={<PermissionSettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
69
frontend/src/components/ideas/IdeaModal.tsx
Normal file
69
frontend/src/components/ideas/IdeaModal.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal, TextInput, Textarea, Button, Stack } from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface IdeaModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IdeaModal({ opened, onClose }: IdeaModalProps) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
|
const submitIdea = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } = await api.post('/ideas', { title, description });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({ message: 'Idea submitted — thank you!', color: 'green' });
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({
|
||||||
|
message: err.response?.data?.message || 'Failed to submit idea',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={handleClose} title="Submit an Idea" size="md">
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
label="Title"
|
||||||
|
placeholder="Brief summary of your idea"
|
||||||
|
required
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||||
|
maxLength={255}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
placeholder="Describe your idea in more detail (optional)"
|
||||||
|
minRows={4}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => submitIdea.mutate()}
|
||||||
|
loading={submitIdea.isPending}
|
||||||
|
disabled={!title.trim()}
|
||||||
|
>
|
||||||
|
Submit Idea
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
IconSun,
|
IconSun,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
|
IconBulb,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
@@ -18,6 +19,7 @@ import { usePreferencesStore } from '../../stores/preferencesStore';
|
|||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { AppTour } from '../onboarding/AppTour';
|
import { AppTour } from '../onboarding/AppTour';
|
||||||
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
||||||
|
import { IdeaModal } from '../ideas/IdeaModal';
|
||||||
import logoSrc from '../../assets/logo.png';
|
import logoSrc from '../../assets/logo.png';
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
@@ -28,6 +30,10 @@ export function AppLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isImpersonating = !!impersonationOriginal;
|
const isImpersonating = !!impersonationOriginal;
|
||||||
|
|
||||||
|
// ── Ideation State ──
|
||||||
|
const [ideaModalOpened, { open: openIdeaModal, close: closeIdeaModal }] = useDisclosure(false);
|
||||||
|
const ideationEnabled = currentOrg?.settings?.ideationEnabled === true;
|
||||||
|
|
||||||
// ── Onboarding State ──
|
// ── Onboarding State ──
|
||||||
const [showTour, setShowTour] = useState(false);
|
const [showTour, setShowTour] = useState(false);
|
||||||
const [showWizard, setShowWizard] = useState(false);
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
@@ -71,8 +77,9 @@ export function AppLayout() {
|
|||||||
navigate('/admin');
|
navigate('/admin');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tenant admins (president role) can manage org members
|
// Capability-based check: can this user manage members?
|
||||||
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
|
const capabilities = currentOrg?.capabilities || [];
|
||||||
|
const isTenantAdmin = user?.isSuperadmin || capabilities.includes('settings.members.manage');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@@ -121,6 +128,13 @@ export function AppLayout() {
|
|||||||
{currentOrg && (
|
{currentOrg && (
|
||||||
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
||||||
)}
|
)}
|
||||||
|
{ideationEnabled && (
|
||||||
|
<Tooltip label="Submit an idea">
|
||||||
|
<ActionIcon variant="default" size="lg" onClick={openIdeaModal} aria-label="Submit idea">
|
||||||
|
<IconBulb size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -209,6 +223,9 @@ export function AppLayout() {
|
|||||||
{/* ── Onboarding Components ── */}
|
{/* ── Onboarding Components ── */}
|
||||||
<AppTour run={showTour} onComplete={handleTourComplete} />
|
<AppTour run={showTour} onComplete={handleTourComplete} />
|
||||||
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
||||||
|
|
||||||
|
{/* ── Ideation Modal ── */}
|
||||||
|
<IdeaModal opened={ideaModalOpened} onClose={closeIdeaModal} />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,59 +20,63 @@ import {
|
|||||||
IconCalculator,
|
IconCalculator,
|
||||||
IconGitCompare,
|
IconGitCompare,
|
||||||
IconScale,
|
IconScale,
|
||||||
|
IconBulb,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { CAPABILITIES } from '../../permissions/capabilities';
|
||||||
|
|
||||||
|
const C = CAPABILITIES;
|
||||||
|
|
||||||
const navSections = [
|
const navSections = [
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
|
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard', capability: C.DASHBOARD_VIEW },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Financials',
|
label: 'Financials',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts' },
|
{ label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts', capability: C.FINANCIALS_ACCOUNTS_VIEW },
|
||||||
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
|
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow', capability: C.FINANCIALS_CASHFLOW_VIEW },
|
||||||
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' },
|
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals', capability: C.FINANCIALS_ACTUALS_VIEW },
|
||||||
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets' },
|
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets', capability: C.FINANCIALS_BUDGETS_VIEW },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Assessments',
|
label: 'Assessments',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
|
{ label: 'Units / Homeowners', icon: IconHome, path: '/units', capability: C.ASSESSMENTS_UNITS_VIEW },
|
||||||
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
|
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups', capability: C.ASSESSMENTS_GROUPS_VIEW },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Board Planning',
|
label: 'Board Planning',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets' },
|
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets', capability: C.PLANNING_BUDGETS_VIEW },
|
||||||
{
|
{
|
||||||
label: 'Projects', icon: IconShieldCheck, path: '/projects',
|
label: 'Projects', icon: IconShieldCheck, path: '/projects', capability: C.PLANNING_PROJECTS_VIEW,
|
||||||
children: [
|
children: [
|
||||||
{ label: 'Capital Planning', path: '/capital-projects' },
|
{ label: 'Capital Planning', path: '/capital-projects' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
|
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments', capability: C.PLANNING_SCENARIOS_VIEW,
|
||||||
},
|
},
|
||||||
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
|
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning', capability: C.PLANNING_INVESTMENTS_VIEW },
|
||||||
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
|
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments', capability: C.PLANNING_SCENARIOS_VIEW },
|
||||||
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
|
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare', capability: C.PLANNING_SCENARIOS_VIEW },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Board Reference',
|
label: 'Board Reference',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
{ label: 'Vendors', icon: IconUsers, path: '/vendors', capability: C.REFERENCE_VENDORS_VIEW },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Transactions',
|
label: 'Transactions',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions', capability: C.TRANSACTIONS_VIEW },
|
||||||
// Invoices and Payments hidden — see PARKING-LOT.md for future re-enablement
|
// Invoices and Payments hidden — see PARKING-LOT.md for future re-enablement
|
||||||
// { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
// { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||||
// { label: 'Payments', icon: IconCash, path: '/payments' },
|
// { label: 'Payments', icon: IconCash, path: '/payments' },
|
||||||
@@ -85,6 +89,7 @@ const navSections = [
|
|||||||
label: 'Reports',
|
label: 'Reports',
|
||||||
icon: IconChartSankey,
|
icon: IconChartSankey,
|
||||||
tourId: 'nav-reports',
|
tourId: 'nav-reports',
|
||||||
|
capability: C.REPORTS_VIEW,
|
||||||
children: [
|
children: [
|
||||||
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
|
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
|
||||||
{ label: 'Income Statement', path: '/reports/income-statement' },
|
{ label: 'Income Statement', path: '/reports/income-statement' },
|
||||||
@@ -94,6 +99,7 @@ const navSections = [
|
|||||||
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
{ label: 'Sankey Diagram', path: '/reports/sankey' },
|
||||||
{ label: 'Year-End', path: '/reports/year-end' },
|
{ label: 'Year-End', path: '/reports/year-end' },
|
||||||
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
|
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
|
||||||
|
{ label: 'Capital Planning', path: '/reports/capital-planning' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -112,6 +118,15 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
const organizations = useAuthStore((s) => s.organizations);
|
const organizations = useAuthStore((s) => s.organizations);
|
||||||
const isAdminOnly = location.pathname.startsWith('/admin') && !currentOrg;
|
const isAdminOnly = location.pathname.startsWith('/admin') && !currentOrg;
|
||||||
|
|
||||||
|
const capabilities = currentOrg?.capabilities || [];
|
||||||
|
const isSuperadmin = user?.isSuperadmin;
|
||||||
|
|
||||||
|
const hasCapability = (cap?: string) => {
|
||||||
|
if (!cap) return true;
|
||||||
|
if (isSuperadmin) return true;
|
||||||
|
return capabilities.includes(cap);
|
||||||
|
};
|
||||||
|
|
||||||
const go = (path: string) => {
|
const go = (path: string) => {
|
||||||
navigate(path);
|
navigate(path);
|
||||||
onNavigate?.();
|
onNavigate?.();
|
||||||
@@ -131,6 +146,20 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
onClick={() => go('/admin')}
|
onClick={() => go('/admin')}
|
||||||
color="red"
|
color="red"
|
||||||
/>
|
/>
|
||||||
|
<NavLink
|
||||||
|
label="Idea Submissions"
|
||||||
|
leftSection={<IconBulb size={18} />}
|
||||||
|
active={location.pathname === '/admin/ideas'}
|
||||||
|
onClick={() => go('/admin/ideas')}
|
||||||
|
color="yellow"
|
||||||
|
/>
|
||||||
|
<NavLink
|
||||||
|
label="AI Benchmarking"
|
||||||
|
leftSection={<IconScale size={18} />}
|
||||||
|
active={location.pathname === '/admin/shadow-ai'}
|
||||||
|
onClick={() => go('/admin/shadow-ai')}
|
||||||
|
color="violet"
|
||||||
|
/>
|
||||||
{organizations && organizations.length > 0 && (
|
{organizations && organizations.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider my="sm" />
|
<Divider my="sm" />
|
||||||
@@ -148,7 +177,10 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea p="sm" data-tour="sidebar-nav">
|
<ScrollArea p="sm" data-tour="sidebar-nav">
|
||||||
{navSections.map((section, sIdx) => (
|
{navSections.map((section, sIdx) => {
|
||||||
|
const visibleItems = section.items.filter((item: any) => hasCapability(item.capability));
|
||||||
|
if (visibleItems.length === 0) return null;
|
||||||
|
return (
|
||||||
<div key={sIdx}>
|
<div key={sIdx}>
|
||||||
{section.label && (
|
{section.label && (
|
||||||
<>
|
<>
|
||||||
@@ -158,7 +190,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{section.items.map((item: any) =>
|
{visibleItems.map((item: any) =>
|
||||||
item.children && !item.path ? (
|
item.children && !item.path ? (
|
||||||
// Collapsible group without a parent route (e.g. Reports)
|
// Collapsible group without a parent route (e.g. Reports)
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -214,7 +246,8 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{user?.isSuperadmin && (
|
{user?.isSuperadmin && (
|
||||||
<>
|
<>
|
||||||
@@ -229,6 +262,20 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
onClick={() => go('/admin')}
|
onClick={() => go('/admin')}
|
||||||
color="red"
|
color="red"
|
||||||
/>
|
/>
|
||||||
|
<NavLink
|
||||||
|
label="Idea Submissions"
|
||||||
|
leftSection={<IconBulb size={18} />}
|
||||||
|
active={location.pathname === '/admin/ideas'}
|
||||||
|
onClick={() => go('/admin/ideas')}
|
||||||
|
color="yellow"
|
||||||
|
/>
|
||||||
|
<NavLink
|
||||||
|
label="AI Benchmarking"
|
||||||
|
leftSection={<IconScale size={18} />}
|
||||||
|
active={location.pathname === '/admin/shadow-ai'}
|
||||||
|
onClick={() => go('/admin/shadow-ai')}
|
||||||
|
color="violet"
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ import {
|
|||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
IconAdjustments,
|
IconAdjustments,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconArrowsTransferDown,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
|
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
|
||||||
|
|
||||||
@@ -126,8 +127,9 @@ export function AccountsPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filterType, setFilterType] = useState<string | null>(null);
|
const [filterType, setFilterType] = useState<string | null>(null);
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [transferOpened, { open: openTransfer, close: closeTransfer }] = useDisclosure(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT);
|
||||||
|
|
||||||
// ── Accounts query ──
|
// ── Accounts query ──
|
||||||
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
||||||
@@ -283,6 +285,39 @@ export function AccountsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Transfer form ──
|
||||||
|
const transferForm = useForm({
|
||||||
|
initialValues: {
|
||||||
|
fromAccountId: '',
|
||||||
|
toAccountId: '',
|
||||||
|
amount: 0,
|
||||||
|
transferDate: new Date() as Date | null,
|
||||||
|
memo: '',
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
fromAccountId: (v) => (v ? null : 'Required'),
|
||||||
|
toAccountId: (v, values) => !v ? 'Required' : v === values.fromAccountId ? 'Must be different from source' : null,
|
||||||
|
amount: (v) => (v > 0 ? null : 'Must be greater than 0'),
|
||||||
|
transferDate: (v) => (v ? null : 'Required'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const transferMutation = useMutation({
|
||||||
|
mutationFn: (values: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo: string }) =>
|
||||||
|
api.post('/accounts/transfer', values),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['trial-balance'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
|
||||||
|
notifications.show({ message: 'Transfer completed successfully', color: 'green' });
|
||||||
|
closeTransfer();
|
||||||
|
transferForm.reset();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({ message: err.response?.data?.message || 'Transfer failed', color: 'red' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ── Investment edit form ──
|
// ── Investment edit form ──
|
||||||
const invForm = useForm({
|
const invForm = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
@@ -408,6 +443,9 @@ export function AccountsPage() {
|
|||||||
const activeAccounts = filtered.filter((a) => a.is_active);
|
const activeAccounts = filtered.filter((a) => a.is_active);
|
||||||
const archivedAccounts = filtered.filter((a) => !a.is_active);
|
const archivedAccounts = filtered.filter((a) => !a.is_active);
|
||||||
|
|
||||||
|
// Asset accounts for transfer modal (all active asset accounts, not just filtered by search)
|
||||||
|
const assetAccounts = accounts.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset');
|
||||||
|
|
||||||
// ── Investments split by fund type ──
|
// ── Investments split by fund type ──
|
||||||
const operatingInvestments = investments.filter((i) => i.fund_type === 'operating' && i.is_active);
|
const operatingInvestments = investments.filter((i) => i.fund_type === 'operating' && i.is_active);
|
||||||
const reserveInvestments = investments.filter((i) => i.fund_type === 'reserve' && i.is_active);
|
const reserveInvestments = investments.filter((i) => i.fund_type === 'reserve' && i.is_active);
|
||||||
@@ -505,9 +543,14 @@ export function AccountsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
|
<Button variant="light" leftSection={<IconArrowsTransferDown size={16} />} onClick={openTransfer}>
|
||||||
|
Transfer Funds
|
||||||
|
</Button>
|
||||||
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
|
||||||
Add Account
|
Add Account
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -854,6 +897,69 @@ export function AccountsPage() {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Transfer Funds Modal */}
|
||||||
|
<Modal opened={transferOpened} onClose={closeTransfer} title="Transfer Funds Between Accounts" size="md" closeOnClickOutside={false}>
|
||||||
|
<form onSubmit={transferForm.onSubmit((values) => {
|
||||||
|
transferMutation.mutate({
|
||||||
|
...values,
|
||||||
|
transferDate: values.transferDate ? values.transferDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
|
||||||
|
});
|
||||||
|
})}>
|
||||||
|
<Stack>
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||||
|
This creates a journal entry transferring funds between asset accounts.
|
||||||
|
Both accounts will be updated in the general ledger.
|
||||||
|
</Alert>
|
||||||
|
<Select
|
||||||
|
label="From Account"
|
||||||
|
placeholder="Select source account"
|
||||||
|
required
|
||||||
|
data={assetAccounts.map((a) => ({
|
||||||
|
value: a.id,
|
||||||
|
label: `${a.name} (${a.fund_type}) — ${fmt(a.balance)}`,
|
||||||
|
}))}
|
||||||
|
searchable
|
||||||
|
{...transferForm.getInputProps('fromAccountId')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="To Account"
|
||||||
|
placeholder="Select destination account"
|
||||||
|
required
|
||||||
|
data={assetAccounts
|
||||||
|
.filter((a) => a.id !== transferForm.values.fromAccountId)
|
||||||
|
.map((a) => ({
|
||||||
|
value: a.id,
|
||||||
|
label: `${a.name} (${a.fund_type}) — ${fmt(a.balance)}`,
|
||||||
|
}))}
|
||||||
|
searchable
|
||||||
|
{...transferForm.getInputProps('toAccountId')}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Amount"
|
||||||
|
required
|
||||||
|
prefix="$"
|
||||||
|
decimalScale={2}
|
||||||
|
thousandSeparator=","
|
||||||
|
min={0.01}
|
||||||
|
{...transferForm.getInputProps('amount')}
|
||||||
|
/>
|
||||||
|
<DateInput
|
||||||
|
label="Transfer Date"
|
||||||
|
required
|
||||||
|
{...transferForm.getInputProps('transferDate')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Memo (optional)"
|
||||||
|
placeholder="e.g. Monthly reserve contribution"
|
||||||
|
{...transferForm.getInputProps('memo')}
|
||||||
|
/>
|
||||||
|
<Button type="submit" leftSection={<IconArrowsTransferDown size={16} />} loading={transferMutation.isPending}>
|
||||||
|
Complete Transfer
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Investment Edit Modal */}
|
{/* Investment Edit Modal */}
|
||||||
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
|
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
|
||||||
{editingInvestment && (
|
{editingInvestment && (
|
||||||
|
|||||||
308
frontend/src/pages/admin/AdminIdeasPage.tsx
Normal file
308
frontend/src/pages/admin/AdminIdeasPage.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Text, Card, Table, Group, Stack, Badge, Loader, Center,
|
||||||
|
Select, TextInput, Textarea, Button, Modal, SimpleGrid, ActionIcon,
|
||||||
|
Tooltip, Paper,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconBulb, IconSearch, IconNote, IconFilter,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface AdminIdea {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
adminNote: string | null;
|
||||||
|
orgId: string;
|
||||||
|
orgName: string;
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
userFirstName: string;
|
||||||
|
userLastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor: Record<string, string> = {
|
||||||
|
new: 'blue',
|
||||||
|
reviewed: 'yellow',
|
||||||
|
accepted: 'green',
|
||||||
|
rejected: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'new', label: 'New' },
|
||||||
|
{ value: 'reviewed', label: 'Reviewed' },
|
||||||
|
{ value: 'accepted', label: 'Accepted' },
|
||||||
|
{ value: 'rejected', label: 'Rejected' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
return new Date(dateStr).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminIdeasPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
|
const [selectedIdea, setSelectedIdea] = useState<AdminIdea | null>(null);
|
||||||
|
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
||||||
|
const [noteText, setNoteText] = useState('');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: ideas, isLoading } = useQuery<AdminIdea[]>({
|
||||||
|
queryKey: ['admin-ideas'],
|
||||||
|
queryFn: async () => { const { data } = await api.get('/admin/ideas'); return data; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStatus = useMutation({
|
||||||
|
mutationFn: async ({ id, status }: { id: string; status: string }) => {
|
||||||
|
await api.put(`/admin/ideas/${id}/status`, { status });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
|
||||||
|
notifications.show({ message: 'Status updated', color: 'green' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateNote = useMutation({
|
||||||
|
mutationFn: async ({ id, adminNote }: { id: string; adminNote: string }) => {
|
||||||
|
await api.put(`/admin/ideas/${id}/note`, { adminNote });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
|
||||||
|
notifications.show({ message: 'Note saved', color: 'green' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openIdeaDetail = (idea: AdminIdea) => {
|
||||||
|
setSelectedIdea(idea);
|
||||||
|
setNoteText(idea.adminNote || '');
|
||||||
|
openDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveNote = () => {
|
||||||
|
if (selectedIdea) {
|
||||||
|
updateNote.mutate({ id: selectedIdea.id, adminNote: noteText });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = (ideas || []).filter((idea) => {
|
||||||
|
const matchesSearch = !search ||
|
||||||
|
idea.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
idea.description?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
idea.orgName.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
idea.userEmail.toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchesStatus = !statusFilter || idea.status === statusFilter;
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
total: ideas?.length || 0,
|
||||||
|
new: ideas?.filter(i => i.status === 'new').length || 0,
|
||||||
|
reviewed: ideas?.filter(i => i.status === 'reviewed').length || 0,
|
||||||
|
accepted: ideas?.filter(i => i.status === 'accepted').length || 0,
|
||||||
|
rejected: ideas?.filter(i => i.status === 'rejected').length || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Center h={400}><Loader /></Center>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<IconBulb size={28} />
|
||||||
|
<Title order={2}>Idea Submissions</Title>
|
||||||
|
</Group>
|
||||||
|
<Badge size="lg" variant="light">{counts.total} total</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>New</Text>
|
||||||
|
<Text size="xl" fw={700} c="blue">{counts.new}</Text>
|
||||||
|
</Paper>
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reviewed</Text>
|
||||||
|
<Text size="xl" fw={700} c="yellow">{counts.reviewed}</Text>
|
||||||
|
</Paper>
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Accepted</Text>
|
||||||
|
<Text size="xl" fw={700} c="green">{counts.accepted}</Text>
|
||||||
|
</Paper>
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Rejected</Text>
|
||||||
|
<Text size="xl" fw={700} c="red">{counts.rejected}</Text>
|
||||||
|
</Paper>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Group>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search ideas, tenants, users..."
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="All statuses"
|
||||||
|
leftSection={<IconFilter size={16} />}
|
||||||
|
data={statusOptions}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={setStatusFilter}
|
||||||
|
clearable
|
||||||
|
w={160}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Ideas table */}
|
||||||
|
<Card withBorder p={0}>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Date</Table.Th>
|
||||||
|
<Table.Th>Tenant</Table.Th>
|
||||||
|
<Table.Th>Submitted By</Table.Th>
|
||||||
|
<Table.Th>Title</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th w={40}></Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={6}>
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
{ideas?.length === 0 ? 'No ideas submitted yet' : 'No ideas match your filters'}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : (
|
||||||
|
filtered.map((idea) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={idea.id}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => openIdeaDetail(idea)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">{formatDate(idea.createdAt)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={500}>{idea.orgName}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{idea.userFirstName} {idea.userLastName}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{idea.userEmail}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={500} lineClamp={1}>{idea.title}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" variant="light" color={statusColor[idea.status]}>
|
||||||
|
{idea.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{idea.adminNote && (
|
||||||
|
<Tooltip label="Has admin note">
|
||||||
|
<IconNote size={16} color="gray" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
<Modal
|
||||||
|
opened={detailOpened}
|
||||||
|
onClose={closeDetail}
|
||||||
|
title={<Text fw={600}>Idea Detail</Text>}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{selectedIdea && (
|
||||||
|
<Stack>
|
||||||
|
<Card withBorder>
|
||||||
|
<SimpleGrid cols={2} spacing="xs">
|
||||||
|
<Text size="xs" c="dimmed">Tenant</Text>
|
||||||
|
<Text size="sm" fw={500}>{selectedIdea.orgName}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Submitted By</Text>
|
||||||
|
<Text size="sm">{selectedIdea.userFirstName} {selectedIdea.userLastName} ({selectedIdea.userEmail})</Text>
|
||||||
|
<Text size="xs" c="dimmed">Date</Text>
|
||||||
|
<Text size="sm">{formatDateTime(selectedIdea.createdAt)}</Text>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600} mb="xs">Title</Text>
|
||||||
|
<Text size="sm">{selectedIdea.title}</Text>
|
||||||
|
{selectedIdea.description && (
|
||||||
|
<>
|
||||||
|
<Text fw={600} mt="md" mb="xs">Description</Text>
|
||||||
|
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{selectedIdea.description}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600} mb="xs">Status</Text>
|
||||||
|
<Select
|
||||||
|
data={statusOptions}
|
||||||
|
value={selectedIdea.status}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (val && val !== selectedIdea.status) {
|
||||||
|
updateStatus.mutate({ id: selectedIdea.id, status: val }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedIdea({ ...selectedIdea, status: val });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
w={200}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text fw={600}>Private Admin Note</Text>
|
||||||
|
<Text size="xs" c="dimmed">Only visible to super admins</Text>
|
||||||
|
</Group>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add internal notes — sprint reference, thoughts, follow-up actions..."
|
||||||
|
minRows={3}
|
||||||
|
value={noteText}
|
||||||
|
onChange={(e) => setNoteText(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
mt="xs"
|
||||||
|
onClick={handleSaveNote}
|
||||||
|
loading={updateNote.isPending}
|
||||||
|
disabled={noteText === (selectedIdea.adminNote || '')}
|
||||||
|
>
|
||||||
|
Save Note
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
IconCrown, IconPlus, IconArchive, IconChevronDown,
|
IconCrown, IconPlus, IconArchive, IconChevronDown,
|
||||||
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
|
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
|
||||||
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
|
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
|
||||||
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye,
|
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye, IconBulb,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -211,6 +211,16 @@ export function AdminPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toggleIdeation = useMutation({
|
||||||
|
mutationFn: async ({ orgId, enabled }: { orgId: string; enabled: boolean }) => {
|
||||||
|
await api.put(`/admin/organizations/${orgId}/settings`, { ideationEnabled: enabled });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-tenant-detail', selectedOrgId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const impersonateUser = useMutation({
|
const impersonateUser = useMutation({
|
||||||
mutationFn: async (userId: string) => {
|
mutationFn: async (userId: string) => {
|
||||||
const { data } = await api.post(`/admin/impersonate/${userId}`);
|
const { data } = await api.post(`/admin/impersonate/${userId}`);
|
||||||
@@ -782,6 +792,27 @@ export function AdminPage() {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600} mb="xs">Feature Toggles</Text>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconBulb size={16} />
|
||||||
|
<div>
|
||||||
|
<Text size="sm">Ideation</Text>
|
||||||
|
<Text size="xs" c="dimmed">Allow users to submit feature ideas</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Switch
|
||||||
|
checked={tenantDetail.organization.settings?.ideationEnabled === true}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (selectedOrgId) {
|
||||||
|
toggleIdeation.mutate({ orgId: selectedOrgId, enabled: e.currentTarget.checked });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card withBorder>
|
<Card withBorder>
|
||||||
<Text fw={600} mb="xs">Subscription</Text>
|
<Text fw={600} mb="xs">Subscription</Text>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
|
|||||||
780
frontend/src/pages/admin/AdminShadowAiPage.tsx
Normal file
780
frontend/src/pages/admin/AdminShadowAiPage.tsx
Normal file
@@ -0,0 +1,780 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Text, Card, SimpleGrid, Group, Stack, Badge, Loader, Center,
|
||||||
|
Tabs, TextInput, Button, PasswordInput, Select, Table, Accordion,
|
||||||
|
Switch, Paper, RingProgress, Divider, Alert, Code, ScrollArea, Box,
|
||||||
|
Tooltip, ActionIcon,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconScale, IconSettings, IconPlayerPlay, IconHistory,
|
||||||
|
IconCheck, IconX, IconAlertTriangle, IconClock, IconTrash,
|
||||||
|
IconRefresh, IconArrowRight, IconChevronDown,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
// ── Interfaces ──
|
||||||
|
|
||||||
|
interface ShadowModel {
|
||||||
|
id: string;
|
||||||
|
slot: string;
|
||||||
|
name: string;
|
||||||
|
api_url: string;
|
||||||
|
api_key: string;
|
||||||
|
model_name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShadowRunResult {
|
||||||
|
id: string;
|
||||||
|
run_id: string;
|
||||||
|
model_role: string;
|
||||||
|
model_name: string;
|
||||||
|
api_url: string;
|
||||||
|
raw_response: string;
|
||||||
|
parsed_response: any;
|
||||||
|
response_time_ms: number;
|
||||||
|
token_usage: any;
|
||||||
|
status: string;
|
||||||
|
error_message: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShadowRun {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
tenant_name: string;
|
||||||
|
feature: string;
|
||||||
|
status: string;
|
||||||
|
prompt_messages: any;
|
||||||
|
started_at: string;
|
||||||
|
completed_at: string;
|
||||||
|
created_at: string;
|
||||||
|
results: ShadowRunResult[];
|
||||||
|
result_count?: string;
|
||||||
|
success_count?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminOrg {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper Functions ──
|
||||||
|
|
||||||
|
const featureLabels: Record<string, string> = {
|
||||||
|
operating_health: 'Operating Health',
|
||||||
|
reserve_health: 'Reserve Health',
|
||||||
|
investment_recommendations: 'Investment Recommendations',
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
production: 'Production',
|
||||||
|
alternate_a: 'Alternate A',
|
||||||
|
alternate_b: 'Alternate B',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColor: Record<string, string> = {
|
||||||
|
running: 'blue',
|
||||||
|
completed: 'green',
|
||||||
|
partial: 'yellow',
|
||||||
|
failed: 'red',
|
||||||
|
pending: 'gray',
|
||||||
|
success: 'green',
|
||||||
|
error: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDuration(ms: number | null): string {
|
||||||
|
if (!ms) return '-';
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: string): string {
|
||||||
|
if (!d) return '-';
|
||||||
|
return new Date(d).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Model Configuration Tab ──
|
||||||
|
|
||||||
|
function ModelConfigTab() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: models, isLoading } = useQuery<ShadowModel[]>({
|
||||||
|
queryKey: ['shadow-ai-models'],
|
||||||
|
queryFn: () => api.get('/admin/shadow-ai/models').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelA = models?.find((m) => m.slot === 'A');
|
||||||
|
const modelB = models?.find((m) => m.slot === 'B');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Configure alternate AI models to benchmark against the production model.
|
||||||
|
Each model can use any OpenAI-compatible API endpoint.
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, md: 3 }}>
|
||||||
|
<ProductionModelCard />
|
||||||
|
<ModelSlotCard slot="A" model={modelA} isLoading={isLoading} />
|
||||||
|
<ModelSlotCard slot="B" model={modelB} isLoading={isLoading} />
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProductionModelCard() {
|
||||||
|
return (
|
||||||
|
<Card withBorder shadow="sm">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={600}>Production Model</Text>
|
||||||
|
<Badge color="green" variant="light">Active</Badge>
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
<Text size="sm" c="dimmed">Configured via environment variables</Text>
|
||||||
|
<TextInput label="Model" value="(from AI_MODEL env var)" readOnly disabled size="sm" />
|
||||||
|
<TextInput label="API URL" value="(from AI_API_URL env var)" readOnly disabled size="sm" />
|
||||||
|
<Text size="xs" c="dimmed" mt="xs">
|
||||||
|
Production model settings are managed through server environment
|
||||||
|
variables and cannot be changed from the UI.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelSlotCard({ slot, model, isLoading }: { slot: string; model?: ShadowModel; isLoading: boolean }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [apiUrl, setApiUrl] = useState('');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [modelName, setModelName] = useState('');
|
||||||
|
const [isActive, setIsActive] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (model) {
|
||||||
|
setName(model.name);
|
||||||
|
setApiUrl(model.api_url);
|
||||||
|
setApiKey(model.api_key);
|
||||||
|
setModelName(model.model_name);
|
||||||
|
setIsActive(model.is_active);
|
||||||
|
}
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () => api.put(`/admin/shadow-ai/models/${slot}`, { name, apiUrl, apiKey, modelName, isActive }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shadow-ai-models'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api.delete(`/admin/shadow-ai/models/${slot}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
setName(''); setApiUrl(''); setApiKey(''); setModelName(''); setIsActive(true);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['shadow-ai-models'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Card withBorder shadow="sm"><Center h={200}><Loader size="sm" /></Center></Card>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder shadow="sm">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={600}>Alternate {slot}</Text>
|
||||||
|
{model ? (
|
||||||
|
<Badge color={isActive ? 'blue' : 'gray'} variant="light">
|
||||||
|
{isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge color="gray" variant="light">Not configured</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
<TextInput label="Display Name" placeholder="e.g. GPT-4o" value={name} onChange={(e) => setName(e.target.value)} size="sm" />
|
||||||
|
<TextInput label="API URL" description="Base URL only — /chat/completions is added automatically" placeholder="https://openrouter.ai/api/v1" value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} size="sm" />
|
||||||
|
<PasswordInput label="API Key" placeholder="sk-..." value={apiKey} onChange={(e) => setApiKey(e.target.value)} size="sm" />
|
||||||
|
<TextInput label="Model Name" placeholder="gpt-4o" value={modelName} onChange={(e) => setModelName(e.target.value)} size="sm" />
|
||||||
|
<Switch label="Active" checked={isActive} onChange={(e) => setIsActive(e.currentTarget.checked)} />
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
loading={saveMutation.isPending}
|
||||||
|
disabled={!name || !apiUrl || !apiKey || !modelName}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
{model && (
|
||||||
|
<Button size="sm" color="red" variant="light" onClick={() => deleteMutation.mutate()} loading={deleteMutation.isPending}>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{saveMutation.isError && <Text size="xs" c="red">Failed to save</Text>}
|
||||||
|
{saveMutation.isSuccess && <Text size="xs" c="green">Saved</Text>}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run Comparison Tab ──
|
||||||
|
|
||||||
|
function RunComparisonTab() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [tenantId, setTenantId] = useState<string | null>(null);
|
||||||
|
const [feature, setFeature] = useState<string | null>(null);
|
||||||
|
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: orgs } = useQuery<AdminOrg[]>({
|
||||||
|
queryKey: ['admin-orgs'],
|
||||||
|
queryFn: () => api.get('/admin/organizations').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const triggerMutation = useMutation({
|
||||||
|
mutationFn: () => api.post('/admin/shadow-ai/runs', { tenantId, feature }),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
setActiveRunId(res.data.runId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: activeRun } = useQuery<ShadowRun>({
|
||||||
|
queryKey: ['shadow-ai-run', activeRunId],
|
||||||
|
queryFn: () => api.get(`/admin/shadow-ai/runs/${activeRunId}`).then((r) => r.data),
|
||||||
|
enabled: !!activeRunId,
|
||||||
|
refetchInterval: (query) => {
|
||||||
|
const run = query.state.data;
|
||||||
|
return run?.status === 'running' ? 3000 : false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const orgOptions = (orgs || [])
|
||||||
|
.filter((o) => o.status === 'active')
|
||||||
|
.map((o) => ({ value: o.id, label: o.name }));
|
||||||
|
|
||||||
|
const featureOptions = [
|
||||||
|
{ value: 'operating_health', label: 'Operating Health Score' },
|
||||||
|
{ value: 'reserve_health', label: 'Reserve Health Score' },
|
||||||
|
{ value: 'investment_recommendations', label: 'Investment Recommendations' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Card withBorder shadow="sm">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text fw={600}>Run Shadow Comparison</Text>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
<Select
|
||||||
|
label="Tenant"
|
||||||
|
placeholder="Select a tenant"
|
||||||
|
data={orgOptions}
|
||||||
|
value={tenantId}
|
||||||
|
onChange={setTenantId}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="AI Feature"
|
||||||
|
placeholder="Select feature"
|
||||||
|
data={featureOptions}
|
||||||
|
value={feature}
|
||||||
|
onChange={setFeature}
|
||||||
|
/>
|
||||||
|
<Stack justify="flex-end">
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlayerPlay size={16} />}
|
||||||
|
onClick={() => triggerMutation.mutate()}
|
||||||
|
loading={triggerMutation.isPending}
|
||||||
|
disabled={!tenantId || !feature}
|
||||||
|
>
|
||||||
|
Run Comparison
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</SimpleGrid>
|
||||||
|
{triggerMutation.isError && (
|
||||||
|
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
||||||
|
Failed to start comparison. Ensure at least one alternate model is configured.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{activeRun && (
|
||||||
|
<Card withBorder shadow="sm">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<Text fw={600}>
|
||||||
|
{featureLabels[activeRun.feature] || activeRun.feature}
|
||||||
|
</Text>
|
||||||
|
<Badge color={statusColor[activeRun.status]}>{activeRun.status}</Badge>
|
||||||
|
</Group>
|
||||||
|
{activeRun.tenant_name && (
|
||||||
|
<Text size="sm" c="dimmed">Tenant: {activeRun.tenant_name}</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{activeRun.status === 'running' && (
|
||||||
|
<Center py="md">
|
||||||
|
<Stack align="center" gap="xs">
|
||||||
|
<Loader size="md" />
|
||||||
|
<Text size="sm" c="dimmed">Running models... This may take a few minutes.</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{(activeRun.results || []).map((r) => (
|
||||||
|
<Badge key={r.model_role} color={statusColor[r.status]} variant="light">
|
||||||
|
{roleLabels[r.model_role]}: {r.status}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeRun.status !== 'running' && activeRun.results && (
|
||||||
|
<ComparisonResults results={activeRun.results} feature={activeRun.feature} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── History Tab ──
|
||||||
|
|
||||||
|
function HistoryTab() {
|
||||||
|
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||||
|
const [tenantFilter, setTenantFilter] = useState<string | null>(null);
|
||||||
|
const [featureFilter, setFeatureFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: orgs } = useQuery<AdminOrg[]>({
|
||||||
|
queryKey: ['admin-orgs'],
|
||||||
|
queryFn: () => api.get('/admin/organizations').then((r) => r.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: historyData, isLoading } = useQuery({
|
||||||
|
queryKey: ['shadow-ai-runs', tenantFilter, featureFilter],
|
||||||
|
queryFn: () => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (tenantFilter) params.set('tenantId', tenantFilter);
|
||||||
|
if (featureFilter) params.set('feature', featureFilter);
|
||||||
|
params.set('limit', '50');
|
||||||
|
return api.get(`/admin/shadow-ai/runs?${params}`).then((r) => r.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: selectedRun } = useQuery<ShadowRun>({
|
||||||
|
queryKey: ['shadow-ai-run', selectedRunId],
|
||||||
|
queryFn: () => api.get(`/admin/shadow-ai/runs/${selectedRunId}`).then((r) => r.data),
|
||||||
|
enabled: !!selectedRunId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const orgOptions = [
|
||||||
|
{ value: '', label: 'All Tenants' },
|
||||||
|
...(orgs || []).map((o) => ({ value: o.id, label: o.name })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const featureOptions = [
|
||||||
|
{ value: '', label: 'All Features' },
|
||||||
|
{ value: 'operating_health', label: 'Operating Health' },
|
||||||
|
{ value: 'reserve_health', label: 'Reserve Health' },
|
||||||
|
{ value: 'investment_recommendations', label: 'Investment Recommendations' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const runs: ShadowRun[] = historyData?.runs || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group>
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
placeholder="Filter by tenant"
|
||||||
|
data={orgOptions}
|
||||||
|
value={tenantFilter || ''}
|
||||||
|
onChange={(v) => setTenantFilter(v || null)}
|
||||||
|
clearable
|
||||||
|
w={200}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
placeholder="Filter by feature"
|
||||||
|
data={featureOptions}
|
||||||
|
value={featureFilter || ''}
|
||||||
|
onChange={(v) => setFeatureFilter(v || null)}
|
||||||
|
clearable
|
||||||
|
w={200}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Center py="xl"><Loader /></Center>
|
||||||
|
) : runs.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="xl">No shadow runs found.</Text>
|
||||||
|
) : (
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Date</Table.Th>
|
||||||
|
<Table.Th>Tenant</Table.Th>
|
||||||
|
<Table.Th>Feature</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th>Models</Table.Th>
|
||||||
|
<Table.Th>Duration</Table.Th>
|
||||||
|
<Table.Th></Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{runs.map((run) => {
|
||||||
|
const duration = run.completed_at && run.started_at
|
||||||
|
? new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<Table.Tr
|
||||||
|
key={run.id}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => setSelectedRunId(run.id)}
|
||||||
|
bg={selectedRunId === run.id ? 'var(--mantine-color-blue-light)' : undefined}
|
||||||
|
>
|
||||||
|
<Table.Td>{formatDate(run.created_at)}</Table.Td>
|
||||||
|
<Table.Td>{run.tenant_name || '-'}</Table.Td>
|
||||||
|
<Table.Td>{featureLabels[run.feature] || run.feature}</Table.Td>
|
||||||
|
<Table.Td><Badge color={statusColor[run.status]} size="sm">{run.status}</Badge></Table.Td>
|
||||||
|
<Table.Td>{run.success_count || '0'}/{run.result_count || '0'}</Table.Td>
|
||||||
|
<Table.Td>{formatDuration(duration)}</Table.Td>
|
||||||
|
<Table.Td><IconArrowRight size={14} /></Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedRun && selectedRun.results && (
|
||||||
|
<Card withBorder shadow="sm" mt="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<Text fw={600}>{featureLabels[selectedRun.feature] || selectedRun.feature}</Text>
|
||||||
|
<Badge color={statusColor[selectedRun.status]}>{selectedRun.status}</Badge>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{selectedRun.tenant_name} | {formatDate(selectedRun.created_at)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<ComparisonResults results={selectedRun.results} feature={selectedRun.feature} />
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comparison Results Component ──
|
||||||
|
|
||||||
|
function ComparisonResults({ results, feature }: { results: ShadowRunResult[]; feature: string }) {
|
||||||
|
const isHealthScore = feature === 'operating_health' || feature === 'reserve_health';
|
||||||
|
|
||||||
|
// Collect all parsed values for diff highlighting
|
||||||
|
const parsedValues = results
|
||||||
|
.filter((r) => r.status === 'success' && r.parsed_response)
|
||||||
|
.map((r) => r.parsed_response);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SimpleGrid cols={{ base: 1, md: Math.min(results.length, 3) }}>
|
||||||
|
{results.map((result) => (
|
||||||
|
<ResultCard
|
||||||
|
key={result.model_role}
|
||||||
|
result={result}
|
||||||
|
isHealthScore={isHealthScore}
|
||||||
|
allParsed={parsedValues}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultCard({
|
||||||
|
result,
|
||||||
|
isHealthScore,
|
||||||
|
allParsed,
|
||||||
|
}: {
|
||||||
|
result: ShadowRunResult;
|
||||||
|
isHealthScore: boolean;
|
||||||
|
allParsed: any[];
|
||||||
|
}) {
|
||||||
|
const roleColor: Record<string, string> = {
|
||||||
|
production: 'green',
|
||||||
|
alternate_a: 'blue',
|
||||||
|
alternate_b: 'violet',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card withBorder shadow="xs" padding="md">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge color={roleColor[result.model_role] || 'gray'} variant="filled">
|
||||||
|
{roleLabels[result.model_role]}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Badge
|
||||||
|
color={statusColor[result.status]}
|
||||||
|
variant="light"
|
||||||
|
leftSection={result.status === 'success' ? <IconCheck size={12} /> : result.status === 'error' ? <IconX size={12} /> : <IconClock size={12} />}
|
||||||
|
>
|
||||||
|
{result.status}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed" truncate>{result.model_name}</Text>
|
||||||
|
|
||||||
|
{result.response_time_ms && (
|
||||||
|
<Badge color="gray" variant="light" size="sm">
|
||||||
|
{formatDuration(result.response_time_ms)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.token_usage && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Tokens: {result.token_usage.prompt_tokens || '?'} prompt / {result.token_usage.completion_tokens || '?'} completion
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{result.status === 'error' && (
|
||||||
|
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
||||||
|
<Text size="sm">{result.error_message || 'Unknown error'}</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.status === 'success' && result.parsed_response && (
|
||||||
|
isHealthScore
|
||||||
|
? <HealthScoreDisplay data={result.parsed_response} allParsed={allParsed} />
|
||||||
|
: <InvestmentDisplay data={result.parsed_response} allParsed={allParsed} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result.status === 'success' && (
|
||||||
|
<Accordion variant="contained">
|
||||||
|
<Accordion.Item value="raw">
|
||||||
|
<Accordion.Control>
|
||||||
|
<Text size="xs">Raw JSON Response</Text>
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<ScrollArea h={300}>
|
||||||
|
<Code block style={{ fontSize: 11 }}>
|
||||||
|
{JSON.stringify(result.parsed_response, null, 2)}
|
||||||
|
</Code>
|
||||||
|
</ScrollArea>
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
</Accordion>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Health Score Display ──
|
||||||
|
|
||||||
|
function HealthScoreDisplay({ data, allParsed }: { data: any; allParsed: any[] }) {
|
||||||
|
const score = data.score ?? data.raw_text;
|
||||||
|
const label = data.label || '';
|
||||||
|
const summary = data.summary || '';
|
||||||
|
const factors = data.factors || [];
|
||||||
|
const recommendations = data.recommendations || [];
|
||||||
|
|
||||||
|
// Check if score differs from other models
|
||||||
|
const scores = allParsed.map((p) => p.score).filter((s) => typeof s === 'number');
|
||||||
|
const scoreDiffers = scores.length > 1 && !scores.every((s) => s === scores[0]);
|
||||||
|
|
||||||
|
const labelColor: Record<string, string> = {
|
||||||
|
Excellent: 'green', Good: 'teal', Fair: 'yellow',
|
||||||
|
'Needs Attention': 'orange', 'At Risk': 'red', Critical: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{typeof score === 'number' && (
|
||||||
|
<Group justify="center">
|
||||||
|
<Box bg={scoreDiffers ? 'yellow.0' : undefined} p="xs" style={{ borderRadius: 8 }}>
|
||||||
|
<RingProgress
|
||||||
|
size={100}
|
||||||
|
thickness={10}
|
||||||
|
roundCaps
|
||||||
|
sections={[{ value: score, color: labelColor[label] || 'blue' }]}
|
||||||
|
label={
|
||||||
|
<Text ta="center" fw={700} size="lg">{score}</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{label && (
|
||||||
|
<Group justify="center">
|
||||||
|
<Badge color={labelColor[label] || 'gray'} size="lg">{label}</Badge>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{summary && <Text size="sm">{summary}</Text>}
|
||||||
|
|
||||||
|
{factors.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text size="xs" fw={600} c="dimmed" tt="uppercase">Factors</Text>
|
||||||
|
{factors.map((f: any, i: number) => (
|
||||||
|
<Group key={i} gap="xs" wrap="nowrap">
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
|
||||||
|
>
|
||||||
|
{f.impact}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" style={{ flex: 1 }}><b>{f.name}:</b> {f.detail}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recommendations.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text size="xs" fw={600} c="dimmed" tt="uppercase">Recommendations</Text>
|
||||||
|
{recommendations.map((r: any, i: number) => (
|
||||||
|
<Group key={i} gap="xs" wrap="nowrap">
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color={r.priority === 'high' ? 'red' : r.priority === 'medium' ? 'yellow' : 'blue'}
|
||||||
|
>
|
||||||
|
{r.priority}
|
||||||
|
</Badge>
|
||||||
|
<Text size="xs" style={{ flex: 1 }}>{r.text}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Investment Display ──
|
||||||
|
|
||||||
|
function InvestmentDisplay({ data, allParsed }: { data: any; allParsed: any[] }) {
|
||||||
|
const recommendations = data.recommendations || [];
|
||||||
|
const overall = data.overall_assessment || '';
|
||||||
|
const riskNotes = data.risk_notes || [];
|
||||||
|
|
||||||
|
const recCounts = allParsed.map((p) => (p.recommendations || []).length);
|
||||||
|
const countDiffers = recCounts.length > 1 && !recCounts.every((c) => c === recCounts[0]);
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
cd_ladder: 'violet', new_investment: 'blue', reallocation: 'teal',
|
||||||
|
maturity_action: 'orange', liquidity_warning: 'red', general: 'gray',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
{overall && (
|
||||||
|
<Paper p="xs" bg="gray.0" radius="sm">
|
||||||
|
<Text size="sm">{overall}</Text>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recommendations.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||||
|
Recommendations
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color={countDiffers ? 'yellow' : 'gray'}
|
||||||
|
>
|
||||||
|
{recommendations.length}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
{recommendations.map((rec: any, i: number) => (
|
||||||
|
<Card key={i} withBorder padding="xs" radius="sm">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Badge size="xs" color={typeColors[rec.type] || 'gray'}>{rec.type}</Badge>
|
||||||
|
<Badge size="xs" variant="light" color={rec.priority === 'high' ? 'red' : rec.priority === 'medium' ? 'yellow' : 'blue'}>
|
||||||
|
{rec.priority}
|
||||||
|
</Badge>
|
||||||
|
{rec.fund_type && <Badge size="xs" variant="outline">{rec.fund_type}</Badge>}
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" fw={600}>{rec.title}</Text>
|
||||||
|
<Text size="xs">{rec.summary}</Text>
|
||||||
|
{rec.suggested_amount && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Amount: ${rec.suggested_amount.toLocaleString()}
|
||||||
|
{rec.suggested_rate ? ` | Rate: ${rec.suggested_rate}%` : ''}
|
||||||
|
{rec.suggested_term ? ` | Term: ${rec.suggested_term}` : ''}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{riskNotes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text size="xs" fw={600} c="dimmed" tt="uppercase">Risk Notes</Text>
|
||||||
|
{riskNotes.map((note: string, i: number) => (
|
||||||
|
<Group key={i} gap="xs" wrap="nowrap">
|
||||||
|
<IconAlertTriangle size={14} color="orange" />
|
||||||
|
<Text size="xs">{note}</Text>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Page ──
|
||||||
|
|
||||||
|
export function AdminShadowAiPage() {
|
||||||
|
return (
|
||||||
|
<Stack gap="lg" p="md">
|
||||||
|
<Group>
|
||||||
|
<IconScale size={28} />
|
||||||
|
<Title order={2}>AI Benchmarking</Title>
|
||||||
|
</Group>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
Compare AI model outputs side-by-side using real tenant data.
|
||||||
|
Configure alternate models, run shadow comparisons, and review historical results.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Tabs defaultValue="run">
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab value="config" leftSection={<IconSettings size={16} />}>
|
||||||
|
Model Configuration
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="run" leftSection={<IconPlayerPlay size={16} />}>
|
||||||
|
Run Comparison
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab value="history" leftSection={<IconHistory size={16} />}>
|
||||||
|
History
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
|
<Tabs.Panel value="config" pt="md">
|
||||||
|
<ModelConfigTab />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="run" pt="md">
|
||||||
|
<RunComparisonTab />
|
||||||
|
</Tabs.Panel>
|
||||||
|
|
||||||
|
<Tabs.Panel value="history" pt="md">
|
||||||
|
<HistoryTab />
|
||||||
|
</Tabs.Panel>
|
||||||
|
</Tabs>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface AssessmentGroup {
|
interface AssessmentGroup {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -79,7 +79,7 @@ export function AssessmentGroupsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
|
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.ASSESSMENTS_GROUPS_EDIT);
|
||||||
|
|
||||||
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
|
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
|
||||||
queryKey: ['assessment-groups'],
|
queryKey: ['assessment-groups'],
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
interface PlanLine {
|
interface PlanLine {
|
||||||
@@ -87,7 +87,7 @@ const statusColors: Record<string, string> = {
|
|||||||
|
|
||||||
export function BudgetPlanningPage() {
|
export function BudgetPlanningPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_BUDGETS_EDIT);
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||||
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
||||||
Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip,
|
Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip,
|
||||||
@@ -40,7 +40,7 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: projection, isLoading: projLoading } = useQuery({
|
const { data: projection, isLoading: projLoading, dataUpdatedAt: projUpdatedAt } = useQuery({
|
||||||
queryKey: ['board-planning-projection', id],
|
queryKey: ['board-planning-projection', id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
|
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
|
||||||
@@ -49,6 +49,17 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// When projection refreshes (which may create auto-renew records on the backend),
|
||||||
|
// re-fetch the scenario so the investments list picks up any new renewal records.
|
||||||
|
const [lastProjUpdate, setLastProjUpdate] = useState(0);
|
||||||
|
if (projUpdatedAt && projUpdatedAt !== lastProjUpdate) {
|
||||||
|
setLastProjUpdate(projUpdatedAt);
|
||||||
|
if (lastProjUpdate > 0) {
|
||||||
|
// Only re-fetch after a real update (not the initial load)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addMutation = useMutation({
|
const addMutation = useMutation({
|
||||||
mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/investments`, dto),
|
mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/investments`, dto),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -100,12 +111,40 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Compute shared time range for aligned charts (must be above early returns to satisfy Rules of Hooks)
|
||||||
|
const investments = scenario?.investments || [];
|
||||||
|
const summary = projection?.summary;
|
||||||
|
|
||||||
|
const { sharedStartDate, sharedEndDate } = useMemo(() => {
|
||||||
|
const allDates: Date[] = [];
|
||||||
|
|
||||||
|
// Dates from investments
|
||||||
|
for (const inv of investments) {
|
||||||
|
if (inv.purchase_date) allDates.push(new Date(inv.purchase_date));
|
||||||
|
if (inv.maturity_date) allDates.push(new Date(inv.maturity_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dates from projection datapoints
|
||||||
|
const dps = projection?.datapoints || [];
|
||||||
|
if (dps.length > 0) {
|
||||||
|
allDates.push(new Date(dps[0].year, dps[0].monthNum - 1, 1));
|
||||||
|
const last = dps[dps.length - 1];
|
||||||
|
allDates.push(new Date(last.year, last.monthNum - 1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allDates.length === 0) return { sharedStartDate: undefined, sharedEndDate: undefined };
|
||||||
|
|
||||||
|
const min = new Date(Math.min(...allDates.map((d) => d.getTime())));
|
||||||
|
const max = new Date(Math.max(...allDates.map((d) => d.getTime())));
|
||||||
|
return {
|
||||||
|
sharedStartDate: new Date(min.getFullYear(), min.getMonth(), 1),
|
||||||
|
sharedEndDate: new Date(max.getFullYear(), max.getMonth(), 1),
|
||||||
|
};
|
||||||
|
}, [investments, projection]);
|
||||||
|
|
||||||
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||||
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
|
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
|
||||||
|
|
||||||
const investments = scenario.investments || [];
|
|
||||||
const summary = projection?.summary;
|
|
||||||
|
|
||||||
// Build a lookup of per-investment interest from the projection
|
// Build a lookup of per-investment interest from the projection
|
||||||
const interestDetailMap: Record<string, { interest: number; principal: number }> = {};
|
const interestDetailMap: Record<string, { interest: number; principal: number }> = {};
|
||||||
if (summary?.investment_interest_details) {
|
if (summary?.investment_interest_details) {
|
||||||
@@ -259,7 +298,13 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Investment Timeline */}
|
{/* Investment Timeline */}
|
||||||
{investments.length > 0 && <InvestmentTimeline investments={investments} />}
|
{investments.length > 0 && (
|
||||||
|
<InvestmentTimeline
|
||||||
|
investments={investments}
|
||||||
|
sharedStartDate={sharedStartDate}
|
||||||
|
sharedEndDate={sharedEndDate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Projection Chart */}
|
{/* Projection Chart */}
|
||||||
{projection && (
|
{projection && (
|
||||||
@@ -267,6 +312,8 @@ export function InvestmentScenarioDetailPage() {
|
|||||||
datapoints={projection.datapoints || []}
|
datapoints={projection.datapoints || []}
|
||||||
title="Scenario Projection"
|
title="Scenario Projection"
|
||||||
summary={projection.summary}
|
summary={projection.summary}
|
||||||
|
sharedStartDate={sharedStartDate}
|
||||||
|
sharedEndDate={sharedEndDate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{projLoading && <Center py="xl"><Loader /></Center>}
|
{projLoading && <Center py="xl"><Loader /></Center>}
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ const typeColors: Record<string, string> = {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
investments: any[];
|
investments: any[];
|
||||||
|
/** Optional shared time range to align with ProjectionChart */
|
||||||
|
sharedStartDate?: Date;
|
||||||
|
sharedEndDate?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InvestmentTimeline({ investments }: Props) {
|
export function InvestmentTimeline({ investments, sharedStartDate, sharedEndDate }: Props) {
|
||||||
const { items, startDate, endDate, totalMonths } = useMemo(() => {
|
const { items, startDate, endDate, totalMonths } = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const items = investments
|
const items = investments
|
||||||
@@ -28,16 +31,24 @@ export function InvestmentTimeline({ investments }: Props) {
|
|||||||
|
|
||||||
if (!items.length) return { items: [], startDate: now, endDate: now, totalMonths: 1 };
|
if (!items.length) return { items: [], startDate: now, endDate: now, totalMonths: 1 };
|
||||||
|
|
||||||
|
// Use shared range if provided (to align with ProjectionChart), otherwise compute from investments
|
||||||
|
let startDate: Date;
|
||||||
|
let endDate: Date;
|
||||||
|
if (sharedStartDate && sharedEndDate) {
|
||||||
|
startDate = sharedStartDate;
|
||||||
|
endDate = sharedEndDate;
|
||||||
|
} else {
|
||||||
const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[];
|
const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[];
|
||||||
const startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
|
startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
|
||||||
const endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
|
endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
|
||||||
|
}
|
||||||
const totalMonths = Math.max(
|
const totalMonths = Math.max(
|
||||||
(endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1,
|
(endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1,
|
||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
|
|
||||||
return { items, startDate, endDate, totalMonths };
|
return { items, startDate, endDate, totalMonths };
|
||||||
}, [investments]);
|
}, [investments, sharedStartDate, sharedEndDate]);
|
||||||
|
|
||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -23,18 +23,31 @@ interface Props {
|
|||||||
datapoints: Datapoint[];
|
datapoints: Datapoint[];
|
||||||
title?: string;
|
title?: string;
|
||||||
summary?: any;
|
summary?: any;
|
||||||
|
/** Optional shared time range to align with InvestmentTimeline */
|
||||||
|
sharedStartDate?: Date;
|
||||||
|
sharedEndDate?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary }: Props) {
|
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary, sharedStartDate, sharedEndDate }: Props) {
|
||||||
const [fundFilter, setFundFilter] = useState('all');
|
const [fundFilter, setFundFilter] = useState('all');
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
return datapoints.map((d) => ({
|
let filtered = datapoints;
|
||||||
|
// If shared range provided, filter datapoints to match
|
||||||
|
if (sharedStartDate && sharedEndDate) {
|
||||||
|
const startKey = sharedStartDate.getFullYear() * 12 + sharedStartDate.getMonth();
|
||||||
|
const endKey = sharedEndDate.getFullYear() * 12 + sharedEndDate.getMonth();
|
||||||
|
filtered = datapoints.filter((d) => {
|
||||||
|
const dpKey = d.year * 12 + (d.monthNum - 1);
|
||||||
|
return dpKey >= startKey && dpKey <= endKey;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return filtered.map((d) => ({
|
||||||
...d,
|
...d,
|
||||||
label: `${d.month}`,
|
label: `${d.month}`,
|
||||||
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
||||||
}));
|
}));
|
||||||
}, [datapoints]);
|
}, [datapoints, sharedStartDate, sharedEndDate]);
|
||||||
|
|
||||||
// Find first forecast month for reference line
|
// Find first forecast month for reference line
|
||||||
const forecastStart = chartData.findIndex((d) => d.is_forecast);
|
const forecastStart = chartData.findIndex((d) => d.is_forecast);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, IconArrowRight } f
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
interface BudgetLine {
|
interface BudgetLine {
|
||||||
@@ -40,7 +40,7 @@ export function BudgetsPage() {
|
|||||||
const [editData, setEditData] = useState<BudgetLine[] | null>(null); // null = not editing
|
const [editData, setEditData] = useState<BudgetLine[] | null>(null); // null = not editing
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_BUDGETS_EDIT);
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||||
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types & constants
|
// Types & constants
|
||||||
@@ -252,7 +252,7 @@ export function CapitalProjectsPage() {
|
|||||||
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
|
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
|
||||||
const printModeRef = useRef(false);
|
const printModeRef = useRef(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
|
||||||
|
|
||||||
// ---- Data fetching ----
|
// ---- Data fetching ----
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ import {
|
|||||||
IconHeartbeat,
|
IconHeartbeat,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
|
IconCoin,
|
||||||
|
IconCalendarEvent,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { useHasAnyCapability, CAPABILITIES } from '../../permissions';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
interface HealthScore {
|
interface HealthScore {
|
||||||
@@ -348,7 +351,11 @@ interface DashboardData {
|
|||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useHasAnyCapability(
|
||||||
|
CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT,
|
||||||
|
CAPABILITIES.FINANCIALS_BUDGETS_EDIT,
|
||||||
|
CAPABILITIES.FINANCIALS_ACTUALS_EDIT,
|
||||||
|
);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@@ -362,6 +369,16 @@ export function DashboardPage() {
|
|||||||
enabled: !!currentOrg,
|
enabled: !!currentOrg,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: investmentActivities } = useQuery<{
|
||||||
|
maturing_investments: any[];
|
||||||
|
upcoming_scenario_investments: any[];
|
||||||
|
total_activities: number;
|
||||||
|
}>({
|
||||||
|
queryKey: ['upcoming-investment-activities'],
|
||||||
|
queryFn: async () => { const { data } = await api.get('/reports/upcoming-investment-activities'); return data; },
|
||||||
|
enabled: !!currentOrg,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: healthScores } = useQuery<HealthScoresData>({
|
const { data: healthScores } = useQuery<HealthScoresData>({
|
||||||
queryKey: ['health-scores'],
|
queryKey: ['health-scores'],
|
||||||
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
|
||||||
@@ -531,6 +548,97 @@ export function DashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Upcoming Investment Activities */}
|
||||||
|
{(investmentActivities?.total_activities || 0) > 0 && (
|
||||||
|
<Card withBorder padding="lg" radius="md">
|
||||||
|
<Group justify="space-between" mb="sm">
|
||||||
|
<Group gap="xs">
|
||||||
|
<ThemeIcon color="teal" variant="light" size={28} radius="md">
|
||||||
|
<IconCalendarEvent size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Title order={4}>Upcoming Investment Activities</Title>
|
||||||
|
</Group>
|
||||||
|
<Badge variant="light" color="teal">{investmentActivities?.total_activities} upcoming</Badge>
|
||||||
|
</Group>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Activity</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Fund</Table.Th>
|
||||||
|
<Table.Th ta="right">Amount</Table.Th>
|
||||||
|
<Table.Th>Date</Table.Th>
|
||||||
|
<Table.Th>Timeline</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(investmentActivities?.maturing_investments || []).map((inv: any) => (
|
||||||
|
<Table.Tr key={`mat-${inv.id}`}>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconCoin size={14} color="var(--mantine-color-orange-6)" />
|
||||||
|
<Text size="sm" fw={500}>{inv.name}</Text>
|
||||||
|
</Group>
|
||||||
|
{inv.institution && <Text size="xs" c="dimmed">{inv.institution}</Text>}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" color="orange" variant="light">Maturing</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" color={inv.fund_type === 'reserve' ? 'violet' : 'blue'} variant="light">
|
||||||
|
{inv.fund_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text size="sm" fw={500}>{fmt(inv.maturity_value)}</Text>
|
||||||
|
<Text size="xs" c="green">+{fmt(inv.interest_earned)} interest</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{new Date(inv.maturity_date).toLocaleDateString()}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" color={inv.days_remaining <= 14 ? 'red' : inv.days_remaining <= 30 ? 'yellow' : 'gray'} variant="light">
|
||||||
|
{inv.days_remaining} days
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
{(investmentActivities?.upcoming_scenario_investments || []).map((si: any) => (
|
||||||
|
<Table.Tr key={`plan-${si.id}`}>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconTrendingUp size={14} color="var(--mantine-color-blue-6)" />
|
||||||
|
<Text size="sm" fw={500}>{si.label}</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">Scenario: {si.scenario_name}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" color="blue" variant="light">Planned Purchase</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="xs" color={si.fund_type === 'reserve' ? 'violet' : 'blue'} variant="light">
|
||||||
|
{si.fund_type}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text size="sm" fw={500}>{fmt(si.principal)}</Text>
|
||||||
|
{si.interest_rate && <Text size="xs" c="dimmed">{parseFloat(si.interest_rate).toFixed(2)}% APY</Text>}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{new Date(si.purchase_date).toLocaleDateString()}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" color={si.days_until <= 14 ? 'red' : si.days_until <= 30 ? 'yellow' : 'gray'} variant="light">
|
||||||
|
{si.days_until} days
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||||
<Card withBorder padding="lg" radius="md">
|
<Card withBorder padding="lg" radius="md">
|
||||||
<Title order={4}>Quick Stats</Title>
|
<Title order={4}>Quick Stats</Title>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
@@ -385,7 +385,7 @@ export function InvestmentPlanningPage() {
|
|||||||
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
||||||
const [newScenarioName, setNewScenarioName] = useState('');
|
const [newScenarioName, setNewScenarioName] = useState('');
|
||||||
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
|
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_INVESTMENTS_EDIT);
|
||||||
|
|
||||||
// Load investment scenarios for the "Add to Plan" modal
|
// Load investment scenarios for the "Add to Plan" modal
|
||||||
const { data: investmentScenarios } = useQuery<any[]>({
|
const { data: investmentScenarios } = useQuery<any[]>({
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface Investment {
|
interface Investment {
|
||||||
id: string; name: string; institution: string; account_number_last4: string;
|
id: string; name: string; institution: string; account_number_last4: string;
|
||||||
@@ -26,7 +26,7 @@ export function InvestmentsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<Investment | null>(null);
|
const [editing, setEditing] = useState<Investment | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_INVESTMENTS_EDIT);
|
||||||
|
|
||||||
const { data: investments = [], isLoading } = useQuery<Investment[]>({
|
const { data: investments = [], isLoading } = useQuery<Investment[]>({
|
||||||
queryKey: ['investments'],
|
queryKey: ['investments'],
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
id: string; invoice_number: string; unit_number: string; unit_id: string;
|
id: string; invoice_number: string; unit_number: string; unit_id: string;
|
||||||
@@ -65,7 +65,7 @@ export function InvoicesPage() {
|
|||||||
const [preview, setPreview] = useState<Preview | null>(null);
|
const [preview, setPreview] = useState<Preview | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
|
||||||
|
|
||||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||||
queryKey: ['invoices'],
|
queryKey: ['invoices'],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export function MonthlyActualsPage() {
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
|
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_ACTUALS_EDIT);
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||||
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
IconShieldCheck, IconInfoCircle,
|
IconShieldCheck, IconInfoCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { useCanEdit, useHasCapability, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface OrgMember {
|
interface OrgMember {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,19 +31,21 @@ interface OrgMember {
|
|||||||
|
|
||||||
const ROLE_OPTIONS = [
|
const ROLE_OPTIONS = [
|
||||||
{ value: 'president', label: 'President' },
|
{ value: 'president', label: 'President' },
|
||||||
|
{ value: 'vice_president', label: 'Vice President' },
|
||||||
{ value: 'treasurer', label: 'Treasurer' },
|
{ value: 'treasurer', label: 'Treasurer' },
|
||||||
{ value: 'secretary', label: 'Secretary' },
|
{ value: 'secretary', label: 'Secretary' },
|
||||||
{ value: 'board_member', label: 'Board Member' },
|
{ value: 'member_at_large', label: 'Member at Large' },
|
||||||
{ value: 'property_manager', label: 'Property Manager' },
|
{ value: 'manager', label: 'Property Manager' },
|
||||||
{ value: 'viewer', label: 'Viewer (Read-Only)' },
|
{ value: 'viewer', label: 'Viewer (Read-Only)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const roleColors: Record<string, string> = {
|
const roleColors: Record<string, string> = {
|
||||||
president: 'red',
|
president: 'red',
|
||||||
|
vice_president: 'grape',
|
||||||
treasurer: 'blue',
|
treasurer: 'blue',
|
||||||
secretary: 'green',
|
secretary: 'green',
|
||||||
board_member: 'violet',
|
member_at_large: 'violet',
|
||||||
property_manager: 'orange',
|
manager: 'orange',
|
||||||
viewer: 'gray',
|
viewer: 'gray',
|
||||||
admin: 'red',
|
admin: 'red',
|
||||||
};
|
};
|
||||||
@@ -52,7 +56,9 @@ export function OrgMembersPage() {
|
|||||||
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
|
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user, currentOrg } = useAuthStore();
|
const { user, currentOrg } = useAuthStore();
|
||||||
const isReadOnly = useIsReadOnly();
|
const navigate = useNavigate();
|
||||||
|
const isReadOnly = !useCanEdit(CAPABILITIES.SETTINGS_MEMBERS_MANAGE);
|
||||||
|
const canManagePermissions = useHasCapability(CAPABILITIES.SETTINGS_PERMISSIONS_MANAGE);
|
||||||
|
|
||||||
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
|
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
|
||||||
queryKey: ['org-members'],
|
queryKey: ['org-members'],
|
||||||
@@ -68,7 +74,7 @@ export function OrgMembersPage() {
|
|||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
password: '',
|
password: '',
|
||||||
role: 'board_member',
|
role: 'member_at_large',
|
||||||
},
|
},
|
||||||
validate: {
|
validate: {
|
||||||
email: (v) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Valid email required'),
|
email: (v) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Valid email required'),
|
||||||
@@ -80,7 +86,7 @@ export function OrgMembersPage() {
|
|||||||
|
|
||||||
const editForm = useForm({
|
const editForm = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
role: 'board_member',
|
role: 'member_at_large',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,12 +169,19 @@ export function OrgMembersPage() {
|
|||||||
<Title order={2}>Organization Members</Title>
|
<Title order={2}>Organization Members</Title>
|
||||||
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
|
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
|
||||||
</div>
|
</div>
|
||||||
|
<Group>
|
||||||
|
{canManagePermissions && (
|
||||||
|
<Button variant="light" leftSection={<IconShieldCheck size={16} />} onClick={() => navigate('/settings/permissions')}>
|
||||||
|
Role Permissions
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
|
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
|
||||||
Add Member
|
Add Member
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
<Card withBorder p="xs">
|
<Card withBorder p="xs">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface Payment {
|
interface Payment {
|
||||||
id: string; unit_id: string; unit_number: string; invoice_id: string;
|
id: string; unit_id: string; unit_number: string; invoice_id: string;
|
||||||
@@ -23,7 +23,7 @@ export function PaymentsPage() {
|
|||||||
const [editing, setEditing] = useState<Payment | null>(null);
|
const [editing, setEditing] = useState<Payment | null>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
|
||||||
|
|
||||||
const { data: payments = [], isLoading } = useQuery<Payment[]>({
|
const { data: payments = [], isLoading } = useQuery<Payment[]>({
|
||||||
queryKey: ['payments'],
|
queryKey: ['payments'],
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen, I
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types & constants
|
// Types & constants
|
||||||
@@ -79,7 +79,7 @@ export function ProjectsPage() {
|
|||||||
const [editing, setEditing] = useState<Project | null>(null);
|
const [editing, setEditing] = useState<Project | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
|
||||||
|
|
||||||
// ---- Data fetching ----
|
// ---- Data fetching ----
|
||||||
|
|
||||||
|
|||||||
196
frontend/src/pages/reports/CapitalPlanningPage.tsx
Normal file
196
frontend/src/pages/reports/CapitalPlanningPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Text, Card, Table, Group, Stack, Badge, Loader, Center,
|
||||||
|
Button, NumberInput,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { IconPrinter } from '@tabler/icons-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface ProjectItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
estimated_cost: number;
|
||||||
|
target_year: number | null;
|
||||||
|
useful_life_years: number | null;
|
||||||
|
last_replacement_date: string | null;
|
||||||
|
fund_source: string;
|
||||||
|
status: string;
|
||||||
|
priority: number;
|
||||||
|
condition_rating: number | null;
|
||||||
|
year_amounts: Record<number, number>;
|
||||||
|
beyond: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryGroup {
|
||||||
|
category: string;
|
||||||
|
projects: ProjectItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CapitalPlanningData {
|
||||||
|
title: string;
|
||||||
|
start_year: number;
|
||||||
|
years: number[];
|
||||||
|
categories: CategoryGroup[];
|
||||||
|
year_totals: Record<number, number>;
|
||||||
|
beyond_total: number;
|
||||||
|
grand_total: number;
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (v: number) =>
|
||||||
|
v === 0 ? '-' : v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
export function CapitalPlanningPage() {
|
||||||
|
const [startYear, setStartYear] = useState(new Date().getFullYear());
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<CapitalPlanningData>({
|
||||||
|
queryKey: ['capital-planning', startYear],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get(`/reports/capital-planning?startYear=${startYear}`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||||
|
|
||||||
|
const years = data?.years || [];
|
||||||
|
const hasProjects = (data?.categories || []).some((c) => c.projects.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Capital Planning Report</Title>
|
||||||
|
<Text c="dimmed" size="sm">{data?.title || '5-Year Capital Project Forecast'}</Text>
|
||||||
|
</div>
|
||||||
|
<Group>
|
||||||
|
<NumberInput
|
||||||
|
size="xs"
|
||||||
|
w={100}
|
||||||
|
value={startYear}
|
||||||
|
onChange={(v) => v && setStartYear(Number(v))}
|
||||||
|
min={2020}
|
||||||
|
max={2050}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<IconPrinter size={16} />}
|
||||||
|
onClick={() => window.print()}
|
||||||
|
>
|
||||||
|
Print / PDF
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{!hasProjects ? (
|
||||||
|
<Card withBorder p="xl">
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
No capital projects found. Add projects on the Projects page to generate this report.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card withBorder p="lg" className="capital-planning-print">
|
||||||
|
<Title order={3} ta="center" mb="xs">{data?.title}</Title>
|
||||||
|
<Text ta="center" c="dimmed" size="sm" mb="md">
|
||||||
|
Generated {new Date(data?.generated_at || '').toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Table striped withTableBorder withColumnBorders>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Description</Table.Th>
|
||||||
|
<Table.Th ta="center" w={60}>Life (yr)</Table.Th>
|
||||||
|
<Table.Th ta="center" w={90}>Last Done</Table.Th>
|
||||||
|
{years.map((y) => (
|
||||||
|
<Table.Th key={y} ta="right" w={100}>{y}</Table.Th>
|
||||||
|
))}
|
||||||
|
<Table.Th ta="right" w={100}>Beyond</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{(data?.categories || []).map((cat) => {
|
||||||
|
const catTotals: Record<number, number> = {};
|
||||||
|
let catBeyond = 0;
|
||||||
|
for (const y of years) catTotals[y] = 0;
|
||||||
|
for (const p of cat.projects) {
|
||||||
|
for (const y of years) catTotals[y] += p.year_amounts[y] || 0;
|
||||||
|
catBeyond += p.beyond;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
<Table.Tr key={`cat-${cat.category}`} style={{ background: 'var(--mantine-color-blue-0)' }}>
|
||||||
|
<Table.Td colSpan={3 + years.length + 1}>
|
||||||
|
<Text fw={700} size="sm">{cat.category}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>,
|
||||||
|
...cat.projects.map((p) => (
|
||||||
|
<Table.Tr key={p.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{p.name}</Text>
|
||||||
|
{p.status !== 'planned' && (
|
||||||
|
<Badge size="xs" variant="light" ml={4}
|
||||||
|
color={p.status === 'completed' ? 'green' : p.status === 'in_progress' ? 'blue' : 'gray'}>
|
||||||
|
{p.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="center">
|
||||||
|
<Text size="sm">{p.useful_life_years || '-'}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="center">
|
||||||
|
<Text size="sm">
|
||||||
|
{p.last_replacement_date
|
||||||
|
? new Date(p.last_replacement_date).getFullYear()
|
||||||
|
: '-'}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{years.map((y) => (
|
||||||
|
<Table.Td key={y} ta="right" ff="monospace">
|
||||||
|
<Text size="sm">{fmt(p.year_amounts[y] || 0)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text size="sm">{fmt(p.beyond)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)),
|
||||||
|
<Table.Tr key={`subtotal-${cat.category}`} style={{ borderTop: '2px solid var(--mantine-color-gray-4)' }}>
|
||||||
|
<Table.Td colSpan={3}>
|
||||||
|
<Text size="sm" fw={600} fs="italic">Subtotal — {cat.category}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{years.map((y) => (
|
||||||
|
<Table.Td key={y} ta="right" ff="monospace">
|
||||||
|
<Text size="sm" fw={600}>{fmt(catTotals[y])}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text size="sm" fw={600}>{fmt(catBeyond)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>,
|
||||||
|
];
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
<Table.Tfoot>
|
||||||
|
<Table.Tr style={{ background: 'var(--mantine-color-dark-0)' }}>
|
||||||
|
<Table.Td colSpan={3}>
|
||||||
|
<Text fw={700}>TOTAL</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{years.map((y) => (
|
||||||
|
<Table.Td key={y} ta="right" ff="monospace">
|
||||||
|
<Text fw={700}>{fmt(data?.year_totals[y] || 0)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
))}
|
||||||
|
<Table.Td ta="right" ff="monospace">
|
||||||
|
<Text fw={700}>{fmt(data?.beyond_total || 0)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Tfoot>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface ReserveComponent {
|
interface ReserveComponent {
|
||||||
id: string; name: string; category: string; description: string;
|
id: string; name: string; category: string; description: string;
|
||||||
@@ -27,7 +27,7 @@ export function ReservesPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<ReserveComponent | null>(null);
|
const [editing, setEditing] = useState<ReserveComponent | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
|
||||||
|
|
||||||
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
|
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
|
||||||
queryKey: ['reserve-components'],
|
queryKey: ['reserve-components'],
|
||||||
|
|||||||
250
frontend/src/pages/settings/PermissionSettingsPage.tsx
Normal file
250
frontend/src/pages/settings/PermissionSettingsPage.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Text, Card, Stack, Group, Table, Checkbox, Button, Alert,
|
||||||
|
Badge, Tooltip, Divider, Loader, Center,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { IconShieldCheck, IconRefresh, IconInfoCircle } from '@tabler/icons-react';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { CAPABILITY_AREAS } from '../../permissions/capabilities';
|
||||||
|
import { DEFAULT_ROLE_CAPABILITIES } from '../../permissions/default-role-capabilities';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
/** Roles shown as columns (homeowner hidden from UI per product decision) */
|
||||||
|
const DISPLAY_ROLES = [
|
||||||
|
{ value: 'president', label: 'President' },
|
||||||
|
{ value: 'vice_president', label: 'Vice President' },
|
||||||
|
{ value: 'treasurer', label: 'Treasurer' },
|
||||||
|
{ value: 'secretary', label: 'Secretary' },
|
||||||
|
{ value: 'member_at_large', label: 'Member at Large' },
|
||||||
|
{ value: 'manager', label: 'Property Manager' },
|
||||||
|
{ value: 'viewer', label: 'Viewer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PermissionOverrides {
|
||||||
|
[role: string]: {
|
||||||
|
grant?: string[];
|
||||||
|
revoke?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCheckedState(overrides: PermissionOverrides): Record<string, Record<string, boolean>> {
|
||||||
|
const state: Record<string, Record<string, boolean>> = {};
|
||||||
|
for (const role of DISPLAY_ROLES) {
|
||||||
|
const defaults = new Set(DEFAULT_ROLE_CAPABILITIES[role.value] || []);
|
||||||
|
const roleOverride = overrides[role.value];
|
||||||
|
|
||||||
|
if (roleOverride?.grant) {
|
||||||
|
for (const cap of roleOverride.grant) defaults.add(cap);
|
||||||
|
}
|
||||||
|
if (roleOverride?.revoke) {
|
||||||
|
for (const cap of roleOverride.revoke) defaults.delete(cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
state[role.value] = {};
|
||||||
|
for (const area of CAPABILITY_AREAS) {
|
||||||
|
for (const cap of area.capabilities) {
|
||||||
|
state[role.value][cap.key] = defaults.has(cap.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverridesFromState(checkedState: Record<string, Record<string, boolean>>): PermissionOverrides {
|
||||||
|
const overrides: PermissionOverrides = {};
|
||||||
|
for (const role of DISPLAY_ROLES) {
|
||||||
|
const defaults = new Set(DEFAULT_ROLE_CAPABILITIES[role.value] || []);
|
||||||
|
const grant: string[] = [];
|
||||||
|
const revoke: string[] = [];
|
||||||
|
|
||||||
|
for (const [cap, checked] of Object.entries(checkedState[role.value] || {})) {
|
||||||
|
const isDefault = defaults.has(cap);
|
||||||
|
if (checked && !isDefault) grant.push(cap);
|
||||||
|
if (!checked && isDefault) revoke.push(cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grant.length > 0 || revoke.length > 0) {
|
||||||
|
overrides[role.value] = {};
|
||||||
|
if (grant.length > 0) overrides[role.value].grant = grant;
|
||||||
|
if (revoke.length > 0) overrides[role.value].revoke = revoke;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return overrides;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermissionSettingsPage() {
|
||||||
|
const { currentOrg, setOrgSettings } = useAuthStore();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
const existingOverrides: PermissionOverrides = useMemo(
|
||||||
|
() => currentOrg?.settings?.permissionOverrides || {},
|
||||||
|
[currentOrg?.settings?.permissionOverrides],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [checkedState, setCheckedState] = useState<Record<string, Record<string, boolean>>>(() =>
|
||||||
|
buildCheckedState(existingOverrides),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCheckedState(buildCheckedState(existingOverrides));
|
||||||
|
setLoaded(true);
|
||||||
|
}, [existingOverrides]);
|
||||||
|
|
||||||
|
const currentOverrides = useMemo(() => buildOverridesFromState(checkedState), [checkedState]);
|
||||||
|
const hasChanges = JSON.stringify(currentOverrides) !== JSON.stringify(existingOverrides);
|
||||||
|
|
||||||
|
const toggleCapability = (role: string, cap: string) => {
|
||||||
|
setCheckedState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[role]: {
|
||||||
|
...prev[role],
|
||||||
|
[cap]: !prev[role]?.[cap],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetRole = (roleValue: string) => {
|
||||||
|
const defaults = new Set(DEFAULT_ROLE_CAPABILITIES[roleValue] || []);
|
||||||
|
const newRoleState: Record<string, boolean> = {};
|
||||||
|
for (const area of CAPABILITY_AREAS) {
|
||||||
|
for (const cap of area.capabilities) {
|
||||||
|
newRoleState[cap.key] = defaults.has(cap.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCheckedState((prev) => ({ ...prev, [roleValue]: newRoleState }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const overrides = buildOverridesFromState(checkedState);
|
||||||
|
const res = await api.patch('/organizations/settings', { permissionOverrides: overrides });
|
||||||
|
setOrgSettings(res.data);
|
||||||
|
notifications.show({ title: 'Saved', message: 'Permission settings updated. Members will see changes on next login or page refresh.', color: 'green' });
|
||||||
|
} catch (err: any) {
|
||||||
|
notifications.show({ title: 'Error', message: err.response?.data?.message || 'Failed to save', color: 'red' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOverridden = (role: string, cap: string) => {
|
||||||
|
const isDefault = (DEFAULT_ROLE_CAPABILITIES[role] || []).includes(cap);
|
||||||
|
const isChecked = checkedState[role]?.[cap] ?? false;
|
||||||
|
return isChecked !== isDefault;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
return <Center mt="xl"><Loader /></Center>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Group gap="xs">
|
||||||
|
<IconShieldCheck size={28} />
|
||||||
|
<Title order={2}>Role Permissions</Title>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
leftSection={<IconRefresh size={16} />}
|
||||||
|
onClick={() => setCheckedState(buildCheckedState(existingOverrides))}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
>
|
||||||
|
Discard Changes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||||
|
Customize which capabilities each role has in your organization.
|
||||||
|
Highlighted cells differ from the system defaults. Use "Reset" to revert a role to defaults.
|
||||||
|
The <strong>Viewer</strong> role is always read-only regardless of settings.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Card withBorder p={0} style={{ overflow: 'auto' }}>
|
||||||
|
<Table striped highlightOnHover withColumnBorders style={{ minWidth: 900 }}>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th style={{ position: 'sticky', left: 0, background: 'var(--mantine-color-body)', zIndex: 1, minWidth: 200 }}>
|
||||||
|
Capability
|
||||||
|
</Table.Th>
|
||||||
|
{DISPLAY_ROLES.map((role) => (
|
||||||
|
<Table.Th key={role.value} style={{ textAlign: 'center', minWidth: 110 }}>
|
||||||
|
<Stack gap={4} align="center">
|
||||||
|
<Text size="xs" fw={600}>{role.label}</Text>
|
||||||
|
<Tooltip label={`Reset ${role.label} to defaults`}>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="compact-xs"
|
||||||
|
onClick={() => resetRole(role.value)}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Table.Th>
|
||||||
|
))}
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{CAPABILITY_AREAS.map((area) => (
|
||||||
|
<>
|
||||||
|
<Table.Tr key={`area-${area.label}`}>
|
||||||
|
<Table.Td
|
||||||
|
colSpan={DISPLAY_ROLES.length + 1}
|
||||||
|
style={{ background: 'var(--mantine-color-gray-1)', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
<Text size="sm" fw={700} tt="uppercase">{area.label}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
{area.capabilities.map((cap) => (
|
||||||
|
<Table.Tr key={cap.key}>
|
||||||
|
<Table.Td style={{ position: 'sticky', left: 0, background: 'var(--mantine-color-body)', zIndex: 1 }}>
|
||||||
|
<Text size="sm">{cap.label}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
{DISPLAY_ROLES.map((role) => {
|
||||||
|
const checked = checkedState[role.value]?.[cap.key] ?? false;
|
||||||
|
const overridden = isOverridden(role.value, cap.key);
|
||||||
|
return (
|
||||||
|
<Table.Td
|
||||||
|
key={role.value}
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
background: overridden ? 'var(--mantine-color-yellow-0)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleCapability(role.value, cap.key)}
|
||||||
|
styles={{ input: { cursor: 'pointer' } }}
|
||||||
|
/>
|
||||||
|
</Table.Td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
|
<Alert color="yellow" variant="light">
|
||||||
|
You have unsaved changes. Click "Save Changes" to apply.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -237,7 +237,7 @@ export function SettingsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Version</Text>
|
<Text size="sm" c="dimmed">Version</Text>
|
||||||
<Badge variant="light">2026.03.18</Badge>
|
<Badge variant="light">2026.4.6</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">API</Text>
|
<Text size="sm" c="dimmed">API</Text>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from
|
|||||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface JournalEntryLine {
|
interface JournalEntryLine {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -49,7 +49,7 @@ export function TransactionsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [viewId, setViewId] = useState<string | null>(null);
|
const [viewId, setViewId] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
|
||||||
|
|
||||||
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
|
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
|
||||||
queryKey: ['journal-entries'],
|
queryKey: ['journal-entries'],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload,
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
|
|
||||||
interface Unit {
|
interface Unit {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,7 +43,7 @@ export function UnitsPage() {
|
|||||||
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.ASSESSMENTS_UNITS_EDIT);
|
||||||
|
|
||||||
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
||||||
queryKey: ['units'],
|
queryKey: ['units'],
|
||||||
|
|||||||
4
frontend/src/pages/vendors/VendorsPage.tsx
vendored
4
frontend/src/pages/vendors/VendorsPage.tsx
vendored
@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload, IconUsers, IconBulb, IconRocket } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload, IconUsers, IconBulb, IconRocket } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useIsReadOnly } from '../../stores/authStore';
|
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
|
|
||||||
interface Vendor {
|
interface Vendor {
|
||||||
@@ -26,7 +26,7 @@ export function VendorsPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isReadOnly = useIsReadOnly();
|
const isReadOnly = !useCanEdit(CAPABILITIES.REFERENCE_VENDORS_EDIT);
|
||||||
|
|
||||||
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
||||||
queryKey: ['vendors'],
|
queryKey: ['vendors'],
|
||||||
|
|||||||
22
frontend/src/permissions/CapabilityGate.tsx
Normal file
22
frontend/src/permissions/CapabilityGate.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { useHasCapability, useHasAnyCapability } from './useCapability';
|
||||||
|
|
||||||
|
interface CapabilityGateProps {
|
||||||
|
/** Single capability required */
|
||||||
|
capability?: string;
|
||||||
|
/** Multiple capabilities — user needs at least one */
|
||||||
|
anyOf?: string[];
|
||||||
|
/** Content shown when user has the capability */
|
||||||
|
children: ReactNode;
|
||||||
|
/** Optional fallback shown when user lacks the capability */
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CapabilityGate({ capability, anyOf, children, fallback = null }: CapabilityGateProps) {
|
||||||
|
const hasSingle = useHasCapability(capability || '');
|
||||||
|
const hasAny = useHasAnyCapability(...(anyOf || []));
|
||||||
|
|
||||||
|
const allowed = capability ? hasSingle : anyOf ? hasAny : true;
|
||||||
|
|
||||||
|
return allowed ? <>{children}</> : <>{fallback}</>;
|
||||||
|
}
|
||||||
131
frontend/src/permissions/capabilities.ts
Normal file
131
frontend/src/permissions/capabilities.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Capability taxonomy for the HOA Financial Platform.
|
||||||
|
*
|
||||||
|
* This file mirrors backend/src/common/permissions/capabilities.ts.
|
||||||
|
* Keep both files in sync when adding new capabilities.
|
||||||
|
*/
|
||||||
|
export const CAPABILITIES = {
|
||||||
|
DASHBOARD_VIEW: 'dashboard.view',
|
||||||
|
|
||||||
|
FINANCIALS_ACCOUNTS_VIEW: 'financials.accounts.view',
|
||||||
|
FINANCIALS_ACCOUNTS_EDIT: 'financials.accounts.edit',
|
||||||
|
FINANCIALS_CASHFLOW_VIEW: 'financials.cashflow.view',
|
||||||
|
FINANCIALS_CASHFLOW_EDIT: 'financials.cashflow.edit',
|
||||||
|
FINANCIALS_ACTUALS_VIEW: 'financials.actuals.view',
|
||||||
|
FINANCIALS_ACTUALS_EDIT: 'financials.actuals.edit',
|
||||||
|
FINANCIALS_BUDGETS_VIEW: 'financials.budgets.view',
|
||||||
|
FINANCIALS_BUDGETS_EDIT: 'financials.budgets.edit',
|
||||||
|
FINANCIALS_BUDGETS_APPROVE: 'financials.budgets.approve',
|
||||||
|
|
||||||
|
ASSESSMENTS_UNITS_VIEW: 'assessments.units.view',
|
||||||
|
ASSESSMENTS_UNITS_EDIT: 'assessments.units.edit',
|
||||||
|
ASSESSMENTS_GROUPS_VIEW: 'assessments.groups.view',
|
||||||
|
ASSESSMENTS_GROUPS_EDIT: 'assessments.groups.edit',
|
||||||
|
|
||||||
|
PLANNING_BUDGETS_VIEW: 'planning.budgets.view',
|
||||||
|
PLANNING_BUDGETS_EDIT: 'planning.budgets.edit',
|
||||||
|
PLANNING_PROJECTS_VIEW: 'planning.projects.view',
|
||||||
|
PLANNING_PROJECTS_EDIT: 'planning.projects.edit',
|
||||||
|
PLANNING_SCENARIOS_VIEW: 'planning.scenarios.view',
|
||||||
|
PLANNING_SCENARIOS_EDIT: 'planning.scenarios.edit',
|
||||||
|
PLANNING_SCENARIOS_APPROVE: 'planning.scenarios.approve',
|
||||||
|
PLANNING_INVESTMENTS_VIEW: 'planning.investments.view',
|
||||||
|
PLANNING_INVESTMENTS_EDIT: 'planning.investments.edit',
|
||||||
|
|
||||||
|
REFERENCE_VENDORS_VIEW: 'reference.vendors.view',
|
||||||
|
REFERENCE_VENDORS_EDIT: 'reference.vendors.edit',
|
||||||
|
|
||||||
|
TRANSACTIONS_VIEW: 'transactions.view',
|
||||||
|
TRANSACTIONS_EDIT: 'transactions.edit',
|
||||||
|
TRANSACTIONS_APPROVE: 'transactions.approve',
|
||||||
|
|
||||||
|
REPORTS_VIEW: 'reports.view',
|
||||||
|
|
||||||
|
SETTINGS_ORG_VIEW: 'settings.org.view',
|
||||||
|
SETTINGS_ORG_EDIT: 'settings.org.edit',
|
||||||
|
SETTINGS_MEMBERS_VIEW: 'settings.members.view',
|
||||||
|
SETTINGS_MEMBERS_MANAGE: 'settings.members.manage',
|
||||||
|
SETTINGS_PERMISSIONS_MANAGE: 'settings.permissions.manage',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
|
||||||
|
|
||||||
|
export const ALL_CAPABILITIES = new Set<string>(Object.values(CAPABILITIES));
|
||||||
|
|
||||||
|
/** Human-readable labels for capability areas (for admin UI) */
|
||||||
|
export const CAPABILITY_AREAS: { label: string; capabilities: { key: string; label: string }[] }[] = [
|
||||||
|
{
|
||||||
|
label: 'Dashboard',
|
||||||
|
capabilities: [
|
||||||
|
{ key: CAPABILITIES.DASHBOARD_VIEW, label: 'View Dashboard' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Financials',
|
||||||
|
capabilities: [
|
||||||
|
{ key: CAPABILITIES.FINANCIALS_ACCOUNTS_VIEW, label: 'View Accounts' },
|
||||||
|
{ key: CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT, label: 'Edit Accounts' },
|
||||||
|
{ key: CAPABILITIES.FINANCIALS_CASHFLOW_VIEW, label: 'View Cash Flow' },
|
||||||
|
{ key: CAPABILITIES.FINANCIALS_CASHFLOW_EDIT, label: 'Edit Cash Flow' },
|
||||||
|
{ key: CAPABILITIES.FINANCIALS_ACTUALS_VIEW, label: 'View Monthly Actuals' },
|
||||||
|
{ key: CAPABILITIES.FINANCIALS_ACTUALS_EDIT, label: 'Edit Monthly Actuals' },
|
||||||
|
{ key: CAPABILITIES.FINANCIALS_BUDGETS_VIEW, label: 'View Budgets' },
|
||||||
|
{ key: CAPABILITIES.FINANCIALS_BUDGETS_EDIT, label: 'Edit Budgets' },
|
||||||
|
{ key: CAPABILITIES.FINANCIALS_BUDGETS_APPROVE, label: 'Approve Budgets' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Assessments',
|
||||||
|
capabilities: [
|
||||||
|
{ key: CAPABILITIES.ASSESSMENTS_UNITS_VIEW, label: 'View Units' },
|
||||||
|
{ key: CAPABILITIES.ASSESSMENTS_UNITS_EDIT, label: 'Edit Units' },
|
||||||
|
{ key: CAPABILITIES.ASSESSMENTS_GROUPS_VIEW, label: 'View Assessment Groups' },
|
||||||
|
{ key: CAPABILITIES.ASSESSMENTS_GROUPS_EDIT, label: 'Edit Assessment Groups' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Board Planning',
|
||||||
|
capabilities: [
|
||||||
|
{ key: CAPABILITIES.PLANNING_BUDGETS_VIEW, label: 'View Budget Planning' },
|
||||||
|
{ key: CAPABILITIES.PLANNING_BUDGETS_EDIT, label: 'Edit Budget Planning' },
|
||||||
|
{ key: CAPABILITIES.PLANNING_PROJECTS_VIEW, label: 'View Projects' },
|
||||||
|
{ key: CAPABILITIES.PLANNING_PROJECTS_EDIT, label: 'Edit Projects' },
|
||||||
|
{ key: CAPABILITIES.PLANNING_SCENARIOS_VIEW, label: 'View Scenarios' },
|
||||||
|
{ key: CAPABILITIES.PLANNING_SCENARIOS_EDIT, label: 'Edit Scenarios' },
|
||||||
|
{ key: CAPABILITIES.PLANNING_SCENARIOS_APPROVE, label: 'Approve Scenarios' },
|
||||||
|
{ key: CAPABILITIES.PLANNING_INVESTMENTS_VIEW, label: 'View Investments' },
|
||||||
|
{ key: CAPABILITIES.PLANNING_INVESTMENTS_EDIT, label: 'Edit Investments' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Board Reference',
|
||||||
|
capabilities: [
|
||||||
|
{ key: CAPABILITIES.REFERENCE_VENDORS_VIEW, label: 'View Vendors' },
|
||||||
|
{ key: CAPABILITIES.REFERENCE_VENDORS_EDIT, label: 'Edit Vendors' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Transactions',
|
||||||
|
capabilities: [
|
||||||
|
{ key: CAPABILITIES.TRANSACTIONS_VIEW, label: 'View Transactions' },
|
||||||
|
{ key: CAPABILITIES.TRANSACTIONS_EDIT, label: 'Edit Transactions' },
|
||||||
|
{ key: CAPABILITIES.TRANSACTIONS_APPROVE, label: 'Approve Transactions' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Reports',
|
||||||
|
capabilities: [
|
||||||
|
{ key: CAPABILITIES.REPORTS_VIEW, label: 'View Reports' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Administration',
|
||||||
|
capabilities: [
|
||||||
|
{ key: CAPABILITIES.SETTINGS_ORG_VIEW, label: 'View Org Settings' },
|
||||||
|
{ key: CAPABILITIES.SETTINGS_ORG_EDIT, label: 'Edit Org Settings' },
|
||||||
|
{ key: CAPABILITIES.SETTINGS_MEMBERS_VIEW, label: 'View Members' },
|
||||||
|
{ key: CAPABILITIES.SETTINGS_MEMBERS_MANAGE, label: 'Manage Members' },
|
||||||
|
{ key: CAPABILITIES.SETTINGS_PERMISSIONS_MANAGE, label: 'Manage Permissions' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
155
frontend/src/permissions/default-role-capabilities.ts
Normal file
155
frontend/src/permissions/default-role-capabilities.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { CAPABILITIES } from './capabilities';
|
||||||
|
|
||||||
|
const C = CAPABILITIES;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default capability sets per role.
|
||||||
|
*
|
||||||
|
* Mirrors backend/src/common/permissions/default-role-capabilities.ts.
|
||||||
|
* Keep both files in sync.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_ROLE_CAPABILITIES: Record<string, readonly string[]> = {
|
||||||
|
president: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||||
|
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||||
|
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||||
|
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||||
|
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
|
||||||
|
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
|
||||||
|
C.SETTINGS_PERMISSIONS_MANAGE,
|
||||||
|
],
|
||||||
|
|
||||||
|
admin: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||||
|
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||||
|
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||||
|
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||||
|
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
|
||||||
|
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
|
||||||
|
C.SETTINGS_PERMISSIONS_MANAGE,
|
||||||
|
],
|
||||||
|
|
||||||
|
vice_president: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.PLANNING_BUDGETS_VIEW,
|
||||||
|
C.PLANNING_PROJECTS_VIEW,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW,
|
||||||
|
C.TRANSACTIONS_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
C.SETTINGS_ORG_VIEW,
|
||||||
|
C.SETTINGS_MEMBERS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
treasurer: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||||
|
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||||
|
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||||
|
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||||
|
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
C.SETTINGS_MEMBERS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
secretary: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.PLANNING_BUDGETS_VIEW,
|
||||||
|
C.PLANNING_PROJECTS_VIEW,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
member_at_large: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.PLANNING_BUDGETS_VIEW,
|
||||||
|
C.PLANNING_PROJECTS_VIEW,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
manager: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||||
|
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
homeowner: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
|
||||||
|
viewer: [
|
||||||
|
C.DASHBOARD_VIEW,
|
||||||
|
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||||
|
C.FINANCIALS_CASHFLOW_VIEW,
|
||||||
|
C.FINANCIALS_ACTUALS_VIEW,
|
||||||
|
C.FINANCIALS_BUDGETS_VIEW,
|
||||||
|
C.ASSESSMENTS_UNITS_VIEW,
|
||||||
|
C.ASSESSMENTS_GROUPS_VIEW,
|
||||||
|
C.PLANNING_BUDGETS_VIEW,
|
||||||
|
C.PLANNING_PROJECTS_VIEW,
|
||||||
|
C.PLANNING_SCENARIOS_VIEW,
|
||||||
|
C.PLANNING_INVESTMENTS_VIEW,
|
||||||
|
C.REFERENCE_VENDORS_VIEW,
|
||||||
|
C.TRANSACTIONS_VIEW,
|
||||||
|
C.REPORTS_VIEW,
|
||||||
|
],
|
||||||
|
};
|
||||||
7
frontend/src/permissions/index.ts
Normal file
7
frontend/src/permissions/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { CAPABILITIES, ALL_CAPABILITIES, CAPABILITY_AREAS } from './capabilities';
|
||||||
|
export type { Capability } from './capabilities';
|
||||||
|
export { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
||||||
|
export { resolveCapabilities } from './resolve-permissions';
|
||||||
|
export type { PermissionOverrides } from './resolve-permissions';
|
||||||
|
export { useHasCapability, useHasAnyCapability, useHasAllCapabilities, useCanEdit } from './useCapability';
|
||||||
|
export { CapabilityGate } from './CapabilityGate';
|
||||||
42
frontend/src/permissions/resolve-permissions.ts
Normal file
42
frontend/src/permissions/resolve-permissions.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { ALL_CAPABILITIES } from './capabilities';
|
||||||
|
import { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
||||||
|
|
||||||
|
export interface PermissionOverrides {
|
||||||
|
[role: string]: {
|
||||||
|
grant?: string[];
|
||||||
|
revoke?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve effective capabilities for a role, applying tenant overrides.
|
||||||
|
*
|
||||||
|
* Mirrors backend/src/common/permissions/resolve-permissions.ts.
|
||||||
|
*/
|
||||||
|
export function resolveCapabilities(
|
||||||
|
role: string,
|
||||||
|
overrides?: PermissionOverrides | null,
|
||||||
|
): Set<string> {
|
||||||
|
const defaults = DEFAULT_ROLE_CAPABILITIES[role] || [];
|
||||||
|
const result = new Set<string>(defaults);
|
||||||
|
|
||||||
|
if (overrides && overrides[role]) {
|
||||||
|
const roleOverride = overrides[role];
|
||||||
|
|
||||||
|
if (roleOverride.grant) {
|
||||||
|
for (const cap of roleOverride.grant) {
|
||||||
|
if (ALL_CAPABILITIES.has(cap)) {
|
||||||
|
result.add(cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roleOverride.revoke) {
|
||||||
|
for (const cap of roleOverride.revoke) {
|
||||||
|
result.delete(cap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
44
frontend/src/permissions/useCapability.ts
Normal file
44
frontend/src/permissions/useCapability.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useAuthStore } from '../stores/authStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user has a specific capability.
|
||||||
|
* Superadmins always return true.
|
||||||
|
*/
|
||||||
|
export function useHasCapability(capability: string): boolean {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const capabilities = useAuthStore((s) => s.currentOrg?.capabilities);
|
||||||
|
if (user?.isSuperadmin) return true;
|
||||||
|
return capabilities?.includes(capability) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user has ANY of the given capabilities.
|
||||||
|
* Superadmins always return true.
|
||||||
|
*/
|
||||||
|
export function useHasAnyCapability(...caps: string[]): boolean {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const capabilities = useAuthStore((s) => s.currentOrg?.capabilities);
|
||||||
|
if (user?.isSuperadmin) return true;
|
||||||
|
if (!capabilities) return false;
|
||||||
|
return caps.some((c) => capabilities.includes(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user has ALL of the given capabilities.
|
||||||
|
* Superadmins always return true.
|
||||||
|
*/
|
||||||
|
export function useHasAllCapabilities(...caps: string[]): boolean {
|
||||||
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const capabilities = useAuthStore((s) => s.currentOrg?.capabilities);
|
||||||
|
if (user?.isSuperadmin) return true;
|
||||||
|
if (!capabilities) return false;
|
||||||
|
return caps.every((c) => capabilities.includes(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific capability string matches the user's capability for edit actions.
|
||||||
|
* This replaces the old useIsReadOnly() for more granular checks.
|
||||||
|
*/
|
||||||
|
export function useCanEdit(editCapability: string): boolean {
|
||||||
|
return useHasCapability(editCapability);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ interface Organization {
|
|||||||
status?: string;
|
status?: string;
|
||||||
planLevel?: string;
|
planLevel?: string;
|
||||||
settings?: Record<string, any>;
|
settings?: Record<string, any>;
|
||||||
|
capabilities?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -119,7 +120,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ledgeriq-auth',
|
name: 'ledgeriq-auth',
|
||||||
version: 5,
|
version: 6,
|
||||||
migrate: () => ({
|
migrate: () => ({
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
183
load-tests/auth-dashboard-flow.js
Normal file
183
load-tests/auth-dashboard-flow.js
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* HOALedgerIQ – Auth + Dashboard Load Test
|
||||||
|
* Journey: Login → Token Refresh → Dashboard Reports → Profile → Logout
|
||||||
|
*
|
||||||
|
* Covers the highest-frequency production flow: a treasurer or admin
|
||||||
|
* opening the app, loading the dashboard, and reviewing financial reports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep, group } from 'k6';
|
||||||
|
import { SharedArray } from 'k6/data';
|
||||||
|
import { Trend, Rate, Counter } from 'k6/metrics';
|
||||||
|
|
||||||
|
// ── Custom metrics ──────────────────────────────────────────────────────────
|
||||||
|
const loginDuration = new Trend('login_duration', true);
|
||||||
|
const dashboardDuration = new Trend('dashboard_duration', true);
|
||||||
|
const refreshDuration = new Trend('refresh_duration', true);
|
||||||
|
const authErrorRate = new Rate('auth_error_rate');
|
||||||
|
const dashboardErrorRate = new Rate('dashboard_error_rate');
|
||||||
|
const tokenRefreshCount = new Counter('token_refresh_count');
|
||||||
|
|
||||||
|
// ── User pool ────────────────────────────────────────────────────────────────
|
||||||
|
const users = new SharedArray('users', function () {
|
||||||
|
return open('../config/user-pool.csv')
|
||||||
|
.split('\n')
|
||||||
|
.slice(1) // skip header row
|
||||||
|
.filter(line => line.trim())
|
||||||
|
.map(line => {
|
||||||
|
const [email, password, orgId, role] = line.split(',');
|
||||||
|
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Environment config ───────────────────────────────────────────────────────
|
||||||
|
const ENV = __ENV.TARGET_ENV || 'staging';
|
||||||
|
const envConfig = JSON.parse(open('../config/environments.json'))[ENV];
|
||||||
|
const BASE_URL = envConfig.baseUrl;
|
||||||
|
|
||||||
|
// ── Test options ─────────────────────────────────────────────────────────────
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
auth_dashboard: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
stages: [
|
||||||
|
{ duration: '2m', target: 20 }, // warm up
|
||||||
|
{ duration: '5m', target: 100 }, // ramp to target load
|
||||||
|
{ duration: '5m', target: 100 }, // sustained load
|
||||||
|
{ duration: '3m', target: 200 }, // peak spike
|
||||||
|
{ duration: '2m', target: 0 }, // ramp down
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
// Latency targets per environment (overridden by environments.json)
|
||||||
|
'login_duration': [`p(95)<${envConfig.thresholds.auth_p95}`],
|
||||||
|
'dashboard_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`],
|
||||||
|
'refresh_duration': [`p(95)<${envConfig.thresholds.refresh_p95}`],
|
||||||
|
'auth_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'dashboard_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function authHeaders(token) {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main scenario ────────────────────────────────────────────────────────────
|
||||||
|
export default function () {
|
||||||
|
const user = users[__VU % users.length];
|
||||||
|
let accessToken = null;
|
||||||
|
|
||||||
|
// ── 1. Login ────────────────────────────────────────────────────────────
|
||||||
|
group('auth:login', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/auth/login`,
|
||||||
|
JSON.stringify({ email: user.email, password: user.password }),
|
||||||
|
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
loginDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, {
|
||||||
|
'login 200': r => r.status === 200,
|
||||||
|
'has access_token': r => r.json('access_token') !== undefined,
|
||||||
|
'has orgId in body': r => r.json('user.orgId') !== undefined,
|
||||||
|
});
|
||||||
|
authErrorRate.add(!ok);
|
||||||
|
if (!ok) { sleep(1); return; }
|
||||||
|
|
||||||
|
accessToken = res.json('access_token');
|
||||||
|
// httpOnly cookie ledgeriq_rt is set automatically by the browser/k6 jar
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessToken) return;
|
||||||
|
sleep(1.5); // think time – user lands on dashboard
|
||||||
|
|
||||||
|
// ── 2. Load dashboard & key reports in parallel ─────────────────────────
|
||||||
|
group('dashboard:load', () => {
|
||||||
|
const requests = {
|
||||||
|
dashboard: ['GET', `${BASE_URL}/api/reports/dashboard`],
|
||||||
|
balance_sheet: ['GET', `${BASE_URL}/api/reports/balance-sheet`],
|
||||||
|
income_statement: ['GET', `${BASE_URL}/api/reports/income-statement`],
|
||||||
|
profile: ['GET', `${BASE_URL}/api/auth/profile`],
|
||||||
|
accounts: ['GET', `${BASE_URL}/api/accounts`],
|
||||||
|
};
|
||||||
|
|
||||||
|
const responses = http.batch(
|
||||||
|
Object.entries(requests).map(([name, [method, url]]) => ({
|
||||||
|
method, url,
|
||||||
|
params: { headers: authHeaders(accessToken), tags: { name } },
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
let allOk = true;
|
||||||
|
responses.forEach((res, i) => {
|
||||||
|
const name = Object.keys(requests)[i];
|
||||||
|
dashboardDuration.add(res.timings.duration, { endpoint: name });
|
||||||
|
const ok = check(res, {
|
||||||
|
[`${name} 200`]: r => r.status === 200,
|
||||||
|
[`${name} has body`]: r => r.body && r.body.length > 0,
|
||||||
|
});
|
||||||
|
if (!ok) allOk = false;
|
||||||
|
});
|
||||||
|
dashboardErrorRate.add(!allOk);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(2); // user reads the dashboard
|
||||||
|
|
||||||
|
// ── 3. Simulate token refresh (happens automatically in-app at 55min) ────
|
||||||
|
// In the load test we trigger it early to validate the refresh path under load
|
||||||
|
group('auth:refresh', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/auth/refresh`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
headers: authHeaders(accessToken),
|
||||||
|
tags: { name: 'refresh' },
|
||||||
|
// k6 sends the httpOnly cookie from the jar automatically
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
refreshDuration.add(res.timings.duration);
|
||||||
|
tokenRefreshCount.add(1);
|
||||||
|
const ok = check(res, {
|
||||||
|
'refresh 200': r => r.status === 200,
|
||||||
|
'new access_token': r => r.json('access_token') !== undefined,
|
||||||
|
});
|
||||||
|
authErrorRate.add(!ok);
|
||||||
|
if (ok) accessToken = res.json('access_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 4. Drill into one report (cash-flow forecast – typically slowest) ────
|
||||||
|
group('dashboard:drill', () => {
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/reports/cash-flow-forecast`,
|
||||||
|
{ headers: authHeaders(accessToken), tags: { name: 'cash_flow_forecast' } }
|
||||||
|
);
|
||||||
|
dashboardDuration.add(res.timings.duration, { endpoint: 'cash_flow_forecast' });
|
||||||
|
dashboardErrorRate.add(res.status !== 200);
|
||||||
|
check(res, { 'forecast 200': r => r.status === 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(2);
|
||||||
|
|
||||||
|
// ── 5. Logout ────────────────────────────────────────────────────────────
|
||||||
|
group('auth:logout', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/auth/logout`,
|
||||||
|
null,
|
||||||
|
{ headers: authHeaders(accessToken), tags: { name: 'logout' } }
|
||||||
|
);
|
||||||
|
check(res, { 'logout 200 or 204': r => r.status === 200 || r.status === 204 });
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
45
load-tests/baseline.json
Normal file
45
load-tests/baseline.json
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"description": "Baseline p50/p95/p99 latency targets per endpoint. Update after each cycle where improvements are confirmed. Claude Code will tighten k6 thresholds in environments.json to match.",
|
||||||
|
"last_updated": "YYYY-MM-DD",
|
||||||
|
"last_run_cycle": 0,
|
||||||
|
"units": "milliseconds"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"POST /api/auth/login": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/auth/refresh": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/auth/logout": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/auth/profile": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"reports": {
|
||||||
|
"GET /api/reports/dashboard": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/balance-sheet": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/income-statement": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/cash-flow": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/cash-flow-forecast": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/aging": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/reports/quarterly": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"accounts": {
|
||||||
|
"GET /api/accounts": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/accounts/trial-balance": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"journal_entries": {
|
||||||
|
"GET /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/journal-entries": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/journal-entries/:id/post": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"budgets": {
|
||||||
|
"GET /api/budgets/:year": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"GET /api/budgets/:year/vs-actual": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"invoices": {
|
||||||
|
"GET /api/invoices": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/invoices/generate-preview": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/invoices/generate-bulk": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
},
|
||||||
|
"payments": {
|
||||||
|
"GET /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null },
|
||||||
|
"POST /api/payments": { "p50": null, "p95": null, "p99": null, "error_rate": null }
|
||||||
|
}
|
||||||
|
}
|
||||||
259
load-tests/crud-flow.js
Normal file
259
load-tests/crud-flow.js
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
/**
|
||||||
|
* HOALedgerIQ – Core CRUD Workflow Load Test
|
||||||
|
* Journey: Login → Create Journal Entry → Post It → Create Invoice →
|
||||||
|
* Record Payment → View Accounts → Budget vs Actual → Logout
|
||||||
|
*
|
||||||
|
* This scenario exercises write-heavy paths gated by WriteAccessGuard
|
||||||
|
* and the TenantMiddleware schema-switch. Run this alongside
|
||||||
|
* auth-dashboard-flow.js to simulate a realistic mixed workload.
|
||||||
|
*
|
||||||
|
* Role used: treasurer (has full write access, most common power user)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'k6/http';
|
||||||
|
import { check, sleep, group } from 'k6';
|
||||||
|
import { SharedArray } from 'k6/data';
|
||||||
|
import { Trend, Rate } from 'k6/metrics';
|
||||||
|
import { uuidv4 } from 'https://jslib.k6.io/k6-utils/1.4.0/index.js';
|
||||||
|
|
||||||
|
// ── Custom metrics ──────────────────────────────────────────────────────────
|
||||||
|
const journalEntryDuration = new Trend('journal_entry_duration', true);
|
||||||
|
const invoiceDuration = new Trend('invoice_duration', true);
|
||||||
|
const paymentDuration = new Trend('payment_duration', true);
|
||||||
|
const accountsReadDuration = new Trend('accounts_read_duration', true);
|
||||||
|
const budgetDuration = new Trend('budget_vs_actual_duration',true);
|
||||||
|
const crudErrorRate = new Rate('crud_error_rate');
|
||||||
|
const writeGuardErrorRate = new Rate('write_guard_error_rate');
|
||||||
|
|
||||||
|
// ── User pool (treasurer + admin roles only for write access) ────────────────
|
||||||
|
const users = new SharedArray('users', function () {
|
||||||
|
return open('../config/user-pool.csv')
|
||||||
|
.split('\n')
|
||||||
|
.slice(1)
|
||||||
|
.filter(line => line.trim())
|
||||||
|
.map(line => {
|
||||||
|
const [email, password, orgId, role] = line.split(',');
|
||||||
|
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
|
||||||
|
})
|
||||||
|
.filter(u => ['treasurer', 'admin', 'president', 'manager'].includes(u.role));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Environment config ───────────────────────────────────────────────────────
|
||||||
|
const ENV = __ENV.TARGET_ENV || 'staging';
|
||||||
|
const envConfig = JSON.parse(open('../config/environments.json'))[ENV];
|
||||||
|
const BASE_URL = envConfig.baseUrl;
|
||||||
|
|
||||||
|
// ── Test options ─────────────────────────────────────────────────────────────
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
crud_workflow: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
stages: [
|
||||||
|
{ duration: '2m', target: 10 }, // warm up (writes need more care)
|
||||||
|
{ duration: '5m', target: 50 }, // ramp to target
|
||||||
|
{ duration: '5m', target: 50 }, // sustained
|
||||||
|
{ duration: '3m', target: 100 }, // peak
|
||||||
|
{ duration: '2m', target: 0 }, // ramp down
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
'journal_entry_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
|
||||||
|
'invoice_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
|
||||||
|
'payment_duration': [`p(95)<${envConfig.thresholds.write_p95}`],
|
||||||
|
'accounts_read_duration': [`p(95)<${envConfig.thresholds.read_p95}`],
|
||||||
|
'budget_vs_actual_duration': [`p(95)<${envConfig.thresholds.dashboard_p95}`],
|
||||||
|
'crud_error_rate': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'write_guard_error_rate': ['rate<0.001'], // write-guard failures should be near-zero
|
||||||
|
'http_req_failed': [`rate<${envConfig.thresholds.error_rate}`],
|
||||||
|
'http_req_duration': [`p(99)<${envConfig.thresholds.global_p99}`],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function jsonHeaders(token) {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentYear() {
|
||||||
|
return new Date().getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main scenario ────────────────────────────────────────────────────────────
|
||||||
|
export default function () {
|
||||||
|
const user = users[__VU % users.length];
|
||||||
|
let accessToken = null;
|
||||||
|
|
||||||
|
// ── 1. Login ────────────────────────────────────────────────────────────
|
||||||
|
group('auth:login', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/auth/login`,
|
||||||
|
JSON.stringify({ email: user.email, password: user.password }),
|
||||||
|
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'login' } }
|
||||||
|
);
|
||||||
|
const ok = check(res, {
|
||||||
|
'login 200': r => r.status === 200,
|
||||||
|
'has access_token': r => r.json('access_token') !== undefined,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
if (!ok) { sleep(1); return; }
|
||||||
|
accessToken = res.json('access_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessToken) return;
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 2. Read accounts (needed to pick valid account IDs for journal entry) ─
|
||||||
|
let debitAccountId = null;
|
||||||
|
let creditAccountId = null;
|
||||||
|
|
||||||
|
group('accounts:list', () => {
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/accounts`,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'accounts_list' } }
|
||||||
|
);
|
||||||
|
accountsReadDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, {
|
||||||
|
'accounts 200': r => r.status === 200,
|
||||||
|
'accounts non-empty': r => Array.isArray(r.json()) && r.json().length > 0,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
const accounts = res.json();
|
||||||
|
// Pick first two distinct accounts for the journal entry
|
||||||
|
debitAccountId = accounts[0]?.id;
|
||||||
|
creditAccountId = accounts[1]?.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!debitAccountId || !creditAccountId) { sleep(1); return; }
|
||||||
|
sleep(1.5);
|
||||||
|
|
||||||
|
// ── 3. Create journal entry (draft) ────────────────────────────────────
|
||||||
|
let journalEntryId = null;
|
||||||
|
|
||||||
|
group('journal:create', () => {
|
||||||
|
const payload = {
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
description: `Load test entry ${uuidv4().slice(0, 8)}`,
|
||||||
|
lines: [
|
||||||
|
{ accountId: debitAccountId, type: 'debit', amount: 100.00, description: 'Load test debit' },
|
||||||
|
{ accountId: creditAccountId, type: 'credit', amount: 100.00, description: 'Load test credit' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/journal-entries`,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'journal_create' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
journalEntryDuration.add(res.timings.duration);
|
||||||
|
// Watch for WriteAccessGuard rejections (403)
|
||||||
|
writeGuardErrorRate.add(res.status === 403);
|
||||||
|
const ok = check(res, {
|
||||||
|
'journal create 201': r => r.status === 201,
|
||||||
|
'journal has id': r => r.json('id') !== undefined,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
if (ok) journalEntryId = res.json('id');
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 4. Post the journal entry ────────────────────────────────────────────
|
||||||
|
if (journalEntryId) {
|
||||||
|
group('journal:post', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/journal-entries/${journalEntryId}/post`,
|
||||||
|
null,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'journal_post' } }
|
||||||
|
);
|
||||||
|
journalEntryDuration.add(res.timings.duration);
|
||||||
|
writeGuardErrorRate.add(res.status === 403);
|
||||||
|
const ok = check(res, { 'journal post 200': r => r.status === 200 });
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
});
|
||||||
|
sleep(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Generate invoice preview ─────────────────────────────────────────
|
||||||
|
let invoicePreviewOk = false;
|
||||||
|
group('invoice:preview', () => {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/invoices/generate-preview`,
|
||||||
|
JSON.stringify({ period: currentYear() }),
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'invoice_preview' } }
|
||||||
|
);
|
||||||
|
invoiceDuration.add(res.timings.duration);
|
||||||
|
invoicePreviewOk = check(res, { 'invoice preview 200': r => r.status === 200 });
|
||||||
|
crudErrorRate.add(!invoicePreviewOk);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(2); // user reviews invoice preview
|
||||||
|
|
||||||
|
// ── 6. Create a payment record ───────────────────────────────────────────
|
||||||
|
group('payment:create', () => {
|
||||||
|
const payload = {
|
||||||
|
amount: 150.00,
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
method: 'check',
|
||||||
|
description: `Load test payment ${uuidv4().slice(0, 8)}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE_URL}/api/payments`,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'payment_create' } }
|
||||||
|
);
|
||||||
|
paymentDuration.add(res.timings.duration);
|
||||||
|
writeGuardErrorRate.add(res.status === 403);
|
||||||
|
const ok = check(res, {
|
||||||
|
'payment create 201 or 200': r => r.status === 201 || r.status === 200,
|
||||||
|
});
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1.5);
|
||||||
|
|
||||||
|
// ── 7. Budget vs actual (typically the heaviest read query) ─────────────
|
||||||
|
group('budget:vs-actual', () => {
|
||||||
|
const year = currentYear();
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/budgets/${year}/vs-actual`,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'budget_vs_actual' } }
|
||||||
|
);
|
||||||
|
budgetDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, { 'budget vs-actual 200': r => r.status === 200 });
|
||||||
|
crudErrorRate.add(!ok);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 8. Trial balance read ────────────────────────────────────────────────
|
||||||
|
group('accounts:trial-balance', () => {
|
||||||
|
const res = http.get(
|
||||||
|
`${BASE_URL}/api/accounts/trial-balance`,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'trial_balance' } }
|
||||||
|
);
|
||||||
|
accountsReadDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'trial balance 200': r => r.status === 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// ── 9. Logout ────────────────────────────────────────────────────────────
|
||||||
|
group('auth:logout', () => {
|
||||||
|
http.post(
|
||||||
|
`${BASE_URL}/api/auth/logout`,
|
||||||
|
null,
|
||||||
|
{ headers: jsonHeaders(accessToken), tags: { name: 'logout' } }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
117
load-tests/cycle-template.md
Normal file
117
load-tests/cycle-template.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# HOALedgerIQ – Load Test Improvement Report
|
||||||
|
**Cycle:** 001
|
||||||
|
**Date:** YYYY-MM-DD
|
||||||
|
**Test window:** HH:MM – HH:MM UTC
|
||||||
|
**Environments:** Staging (`staging.hoaledgeriq.com`)
|
||||||
|
**Scenarios run:** `auth-dashboard-flow.js` + `crud-flow.js`
|
||||||
|
**Peak VUs:** 200 (dashboard) / 100 (CRUD)
|
||||||
|
**New Relic app:** `HOALedgerIQ_App`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
> _[One paragraph: what load the system handled, what broke first, at what VU threshold, and the estimated user-facing impact. Written by Claude Code from New Relic data.]_
|
||||||
|
|
||||||
|
**Threshold breaches this cycle:**
|
||||||
|
|
||||||
|
| Metric | Target | Actual | Status |
|
||||||
|
|--------|--------|--------|--------|
|
||||||
|
| login p95 | < 300ms | — | 🔴 / 🟢 |
|
||||||
|
| dashboard p95 | < 1000ms | — | 🔴 / 🟢 |
|
||||||
|
| budget vs-actual p95 | < 1000ms | — | 🔴 / 🟢 |
|
||||||
|
| journal entry write p95 | < 1200ms | — | 🔴 / 🟢 |
|
||||||
|
| error rate | < 1% | — | 🔴 / 🟢 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### 🔴 P0 – Fix Before Next Deploy
|
||||||
|
|
||||||
|
#### Finding 001 – [Short title]
|
||||||
|
- **Symptom:** _e.g., `GET /api/reports/cash-flow-forecast` p95 = 3,400ms at 100 VUs_
|
||||||
|
- **New Relic evidence:** _e.g., DatastoreSegment shows 47 sequential DB calls per request_
|
||||||
|
- **Root cause hypothesis:** _e.g., N+1 on `reserve_components` — each component triggers a separate `SELECT` for `monthly_actuals`_
|
||||||
|
- **File:** `backend/src/modules/reports/cash-flow.service.ts:83`
|
||||||
|
- **Recommended fix:**
|
||||||
|
```typescript
|
||||||
|
// BEFORE – N+1: one query per component
|
||||||
|
for (const component of components) {
|
||||||
|
const actuals = await this.actualsRepo.findBy({ componentId: component.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// AFTER – batch load with WHERE IN
|
||||||
|
const actuals = await this.actualsRepo.findBy({
|
||||||
|
componentId: In(components.map(c => c.id))
|
||||||
|
});
|
||||||
|
```
|
||||||
|
- **Expected improvement:** ~70% latency reduction on this endpoint
|
||||||
|
- **Effort:** Low (1–2 hours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟠 P1 – Fix Within This Sprint
|
||||||
|
|
||||||
|
#### Finding 002 – [Short title]
|
||||||
|
- **Symptom:**
|
||||||
|
- **New Relic evidence:**
|
||||||
|
- **Root cause hypothesis:**
|
||||||
|
- **File:**
|
||||||
|
- **Recommended fix:**
|
||||||
|
- **Expected improvement:**
|
||||||
|
- **Effort:**
|
||||||
|
|
||||||
|
#### Finding 003 – [Short title]
|
||||||
|
- _(same structure)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 P2 – Backlog
|
||||||
|
|
||||||
|
#### Finding 004 – [Short title]
|
||||||
|
- **Symptom:**
|
||||||
|
- **Root cause hypothesis:**
|
||||||
|
- **Recommended fix:**
|
||||||
|
- **Effort:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regression Net — Re-Test Criteria
|
||||||
|
|
||||||
|
After implementing P0 + P1 fixes, the next BlazeMeter run must pass these gates before merging to staging:
|
||||||
|
|
||||||
|
| Endpoint | Previous p95 | Target p95 | k6 Threshold |
|
||||||
|
|----------|-------------|------------|-------------|
|
||||||
|
| `GET /api/reports/cash-flow-forecast` | — | — | `p(95)<XXX` |
|
||||||
|
| `POST /api/journal-entries` | — | — | `p(95)<XXX` |
|
||||||
|
| `GET /api/budgets/:year/vs-actual` | — | — | `p(95)<XXX` |
|
||||||
|
|
||||||
|
> **Claude Code update command (run after confirming fixes):**
|
||||||
|
> ```bash
|
||||||
|
> claude "Update load-tests/analysis/baseline.json with the p95 values from
|
||||||
|
> load-tests/reports/cycle-001.md findings. Tighten the k6 thresholds in
|
||||||
|
> load-tests/config/environments.json staging block to match. Do not loosen
|
||||||
|
> any threshold that already passes."
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Baseline Delta
|
||||||
|
|
||||||
|
| Endpoint | Cycle 000 p95 | Cycle 001 p95 | Δ |
|
||||||
|
|----------|--------------|--------------|---|
|
||||||
|
| _(populated after first run)_ | — | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes & Observations
|
||||||
|
|
||||||
|
- _Any anomalies, flaky tests, or infrastructure events during the run_
|
||||||
|
- _Redis / BullMQ queue depth observations_
|
||||||
|
- _Rate limiter (Throttler) trip count — if >0, note which endpoints and at what VU count_
|
||||||
|
- _TenantMiddleware cache hit rate (if observable via New Relic custom attributes)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Generated by Claude Code. Source data in `load-tests/analysis/raw/`. Next cycle target: implement P0+P1, re-run at same peak VUs, update baselines._
|
||||||
38
load-tests/environments.json
Normal file
38
load-tests/environments.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"local": {
|
||||||
|
"baseUrl": "http://localhost:3000",
|
||||||
|
"thresholds": {
|
||||||
|
"auth_p95": 500,
|
||||||
|
"refresh_p95": 300,
|
||||||
|
"read_p95": 1000,
|
||||||
|
"write_p95": 1500,
|
||||||
|
"dashboard_p95": 1500,
|
||||||
|
"global_p99": 3000,
|
||||||
|
"error_rate": 0.05
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"staging": {
|
||||||
|
"baseUrl": "https://staging.hoaledgeriq.com",
|
||||||
|
"thresholds": {
|
||||||
|
"auth_p95": 300,
|
||||||
|
"refresh_p95": 200,
|
||||||
|
"read_p95": 800,
|
||||||
|
"write_p95": 1200,
|
||||||
|
"dashboard_p95": 1000,
|
||||||
|
"global_p99": 2000,
|
||||||
|
"error_rate": 0.01
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"baseUrl": "https://app.hoaledgeriq.com",
|
||||||
|
"thresholds": {
|
||||||
|
"auth_p95": 200,
|
||||||
|
"refresh_p95": 150,
|
||||||
|
"read_p95": 500,
|
||||||
|
"write_p95": 800,
|
||||||
|
"dashboard_p95": 700,
|
||||||
|
"global_p99": 1500,
|
||||||
|
"error_rate": 0.005
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
274
load-tests/nrql-queries.sql
Normal file
274
load-tests/nrql-queries.sql
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- HOALedgerIQ – New Relic NRQL Query Library
|
||||||
|
-- App name: HOALedgerIQ_App
|
||||||
|
-- Usage: Run in New Relic Query Builder. Replace time windows as needed.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 1: OVERVIEW HEALTH ────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- 1.1 Apdex score over last test window
|
||||||
|
SELECT apdex(duration, t: 0.5) AS 'Apdex'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 1.2 Overall throughput (requests per minute)
|
||||||
|
SELECT rate(count(*), 1 minute) AS 'RPM'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 1.3 Error rate over time
|
||||||
|
SELECT percentage(count(*), WHERE error IS true) AS 'Error %'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 2: LATENCY BY ENDPOINT ────────────────────────────────────────
|
||||||
|
|
||||||
|
-- 2.1 p50 / p95 / p99 latency by transaction name
|
||||||
|
SELECT percentile(duration, 50, 95, 99) AS 'ms'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
LIMIT 30
|
||||||
|
|
||||||
|
-- 2.2 Slowest endpoints (p95) during load test window
|
||||||
|
SELECT percentile(duration, 95) AS 'p95 ms'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY percentile(duration, 95) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 2.3 Auth endpoint latency breakdown
|
||||||
|
SELECT percentile(duration, 50, 95, 99)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND name LIKE '%auth%'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 2.4 Report endpoint latency (typically slowest reads)
|
||||||
|
SELECT percentile(duration, 50, 95, 99)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND name LIKE '%reports%'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 2.5 Write endpoint latency (journal-entries, payments, invoices)
|
||||||
|
SELECT percentile(duration, 50, 95, 99)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND (name LIKE '%journal-entries%' OR name LIKE '%payments%' OR name LIKE '%invoices%')
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 2.6 Latency heatmap over time for dashboard load
|
||||||
|
SELECT histogram(duration, width: 100, buckets: 20)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND name LIKE '%reports/dashboard%'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 3: DATABASE PERFORMANCE ──────────────────────────────────────
|
||||||
|
|
||||||
|
-- 3.1 Slowest database queries (top 20)
|
||||||
|
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
|
||||||
|
FROM DatastoreSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET statement
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY average(duration) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 3.2 Database call count by operation type
|
||||||
|
SELECT count(*)
|
||||||
|
FROM DatastoreSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET operation
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 3.3 N+1 detection – high-call-count queries
|
||||||
|
SELECT count(*) AS 'call count', average(duration) AS 'avg ms'
|
||||||
|
FROM DatastoreSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET statement
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY count(*) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 3.4 DB time as % of total transaction time (per endpoint)
|
||||||
|
SELECT average(databaseDuration) / average(duration) * 100 AS '% DB time'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND databaseDuration IS NOT NULL
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY average(databaseDuration) / average(duration) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 3.5 Connection pool pressure (slow queries that may indicate pool exhaustion)
|
||||||
|
SELECT count(*) AS 'slow queries (>500ms)'
|
||||||
|
FROM DatastoreSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND duration > 0.5
|
||||||
|
FACET statement
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 3.6 Multi-tenant schema switch overhead (TenantMiddleware)
|
||||||
|
SELECT average(duration) AS 'avg ms'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND name NOT LIKE '%auth/login%'
|
||||||
|
AND name NOT LIKE '%auth/refresh%'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY average(duration) DESC
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 4: ERROR ANALYSIS ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- 4.1 All errors by class and message
|
||||||
|
SELECT count(*), latest(errorMessage)
|
||||||
|
FROM TransactionError
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET errorClass, errorMessage
|
||||||
|
SINCE 1 hour ago
|
||||||
|
LIMIT 30
|
||||||
|
|
||||||
|
-- 4.2 Error rate by HTTP status code
|
||||||
|
SELECT count(*)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND httpResponseCode >= 400
|
||||||
|
FACET httpResponseCode
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 4.3 403 errors (WriteAccessGuard rejections under load)
|
||||||
|
SELECT count(*) AS '403 Forbidden'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND httpResponseCode = 403
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 4.4 429 errors (rate limiter – Throttler)
|
||||||
|
SELECT count(*) AS '429 Rate Limited'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND httpResponseCode = 429
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 4.5 500 errors by endpoint
|
||||||
|
SELECT count(*), latest(errorMessage)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND httpResponseCode = 500
|
||||||
|
FACET name, errorMessage
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 4.6 JWT / auth failures
|
||||||
|
SELECT count(*)
|
||||||
|
FROM TransactionError
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND (errorMessage LIKE '%jwt%' OR errorMessage LIKE '%token%' OR errorMessage LIKE '%unauthorized%')
|
||||||
|
FACET errorMessage
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 5: INFRASTRUCTURE (during test window) ───────────────────────
|
||||||
|
|
||||||
|
-- 5.1 CPU utilization
|
||||||
|
SELECT average(cpuPercent) AS 'CPU %'
|
||||||
|
FROM SystemSample
|
||||||
|
WHERE hostname LIKE '%hoaledgeriq%'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 5.2 Memory utilization
|
||||||
|
SELECT average(memoryUsedPercent) AS 'Memory %'
|
||||||
|
FROM SystemSample
|
||||||
|
WHERE hostname LIKE '%hoaledgeriq%'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
-- 5.3 Network I/O
|
||||||
|
SELECT average(transmitBytesPerSecond) AS 'TX bytes/s',
|
||||||
|
average(receiveBytesPerSecond) AS 'RX bytes/s'
|
||||||
|
FROM NetworkSample
|
||||||
|
WHERE hostname LIKE '%hoaledgeriq%'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES 1 minute
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 6: REDIS / BULLMQ ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
-- 6.1 External call latency (Redis)
|
||||||
|
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
|
||||||
|
FROM ExternalSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND (name LIKE '%redis%' OR host LIKE '%redis%')
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
-- 6.2 All external service latency
|
||||||
|
SELECT average(duration) AS 'avg ms', count(*) AS 'calls'
|
||||||
|
FROM ExternalSegment
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET host
|
||||||
|
SINCE 1 hour ago
|
||||||
|
ORDER BY average(duration) DESC
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 7: BASELINE COMPARISON ───────────────────────────────────────
|
||||||
|
|
||||||
|
-- 7.1 Compare this run vs last run (adjust SINCE/UNTIL for your windows)
|
||||||
|
SELECT percentile(duration, 95) AS 'p95 this run'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET name
|
||||||
|
SINCE '2025-01-01 10:00:00' UNTIL '2025-01-01 11:00:00'
|
||||||
|
-- Run again with previous window dates to compare
|
||||||
|
|
||||||
|
-- 7.2 Regression check – endpoints that crossed p95 threshold
|
||||||
|
SELECT percentile(duration, 95) AS 'p95 ms'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND percentile(duration, 95) > 800 -- adjust to your staging threshold
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
|
|
||||||
|
|
||||||
|
-- ── SECTION 8: TENANT-AWARE ANALYSIS ──────────────────────────────────────
|
||||||
|
|
||||||
|
-- 8.1 Performance by org (if orgId is in custom attributes)
|
||||||
|
SELECT percentile(duration, 95) AS 'p95 ms', count(*) AS 'requests'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET custom.orgId
|
||||||
|
SINCE 1 hour ago
|
||||||
|
LIMIT 20
|
||||||
|
|
||||||
|
-- 8.2 Transactions without orgId (potential TenantMiddleware misses)
|
||||||
|
SELECT count(*)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND custom.orgId IS NULL
|
||||||
|
AND name NOT LIKE '%auth/login%'
|
||||||
|
AND name NOT LIKE '%auth/register%'
|
||||||
|
AND name NOT LIKE '%health%'
|
||||||
|
FACET name
|
||||||
|
SINCE 1 hour ago
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user