1 Commits

Author SHA1 Message Date
JoeBot
dfd1bccb89 feat: add Playwright E2E and API regression test suite
Production-ready test infrastructure with Page Object Model pattern,
reusable fixtures for auth/DB/test-data, and example tests covering
login flow, dashboard, accounts CRUD API, and visual regression.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 12:40:05 -04:00
93 changed files with 1849 additions and 4550 deletions

28
.env.test.example Normal file
View File

@@ -0,0 +1,28 @@
# ─── Playwright E2E Test Environment ────────────────────────────────
# Copy to .env.test and fill in values for your local or CI setup.
# Base URL of the running application (nginx proxy)
# Local dev: http://localhost (Docker Compose nginx on port 80)
# Production: https://your-production-domain.com
BASE_URL=http://localhost
# ─── Test Database ──────────────────────────────────────────────────
# Direct Postgres connection for test data seeding/cleanup.
# Use the SAME database as Docker Compose postgres service.
# WARNING: Tests will create/delete data — never point at production.
TEST_DB_URL=postgresql://hoafinance:change_me@localhost:5432/hoafinance
# ─── Test User Credentials ──────────────────────────────────────────
# Pre-seeded user for authenticated test flows.
# The seed script (tests/fixtures/db.fixture.ts) creates this user.
TEST_USER_EMAIL=e2e-treasurer@test.hoaledgeriq.com
TEST_USER_PASSWORD=TestPass123!
TEST_USER_ROLE=treasurer
# ─── API Base URL ───────────────────────────────────────────────────
# Backend API base (through nginx). Usually same as BASE_URL + /api
API_BASE_URL=http://localhost/api
# ─── CI Settings ────────────────────────────────────────────────────
# CI=true is typically set by CI providers automatically.
# CI=true

View File

@@ -1,65 +0,0 @@
# ---------------------------------------------------------------------------
# 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

10
.gitignore vendored
View File

@@ -44,3 +44,13 @@ coverage/
# TypeScript
*.tsbuildinfo
# Playwright
/test-results/
/playwright-report/
/blob-report/
tests/.auth/
*-snapshots/
# Test environment
.env.test

349
TESTING_CONVENTIONS.md Normal file
View File

@@ -0,0 +1,349 @@
# Testing Conventions — HOA LedgerIQ E2E & API Tests
This document is the single source of truth for writing, organizing, and running Playwright-based E2E and API regression tests in this project.
---
## Architecture
| Component | Technology | Port |
|-----------|-----------|------|
| Reverse proxy | nginx | :80 |
| Backend API | NestJS 10 | :3000 (internal) |
| Frontend | React 18 + Vite | :5173 (internal) |
| Database | PostgreSQL 15 | :5432 |
| Cache | Redis 7 | :6379 |
| Test runner | Playwright | host |
Tests run on the **host machine** against the app running in **Docker Compose**. The `BASE_URL` defaults to `http://localhost` (nginx).
---
## Folder Structure
```
tests/
├── .auth/ # Stored auth state (gitignored)
│ └── user.json # Browser state from auth.setup.ts
├── fixtures/
│ ├── auth.fixture.ts # API login helpers, token management
│ ├── base.fixture.ts # Extended test object with typed fixtures
│ ├── db.fixture.ts # Postgres seed/cleanup via pg driver
│ └── test-data.ts # Shared constants (users, sample data)
├── page-objects/
│ ├── index.ts # Re-exports all page objects
│ ├── BasePage.ts # Abstract base with shared helpers
│ ├── LoginPage.ts # /login page
│ ├── DashboardPage.ts # /dashboard page
│ └── AccountsPage.ts # /accounts page
├── e2e/ # Browser-based end-to-end tests
│ ├── auth.spec.ts # Login/logout UI flows
│ ├── dashboard.spec.ts # Dashboard load + navigation
│ └── visual.spec.ts # Screenshot regression tests
├── api/ # API-only tests (no browser)
│ ├── auth.api.spec.ts # /api/auth/* endpoints
│ └── accounts.api.spec.ts # /api/accounts/* CRUD
└── auth.setup.ts # One-time auth setup project
```
---
## Naming Conventions
| What | Convention | Example |
|------|-----------|---------|
| E2E test files | `tests/e2e/<feature>.spec.ts` | `auth.spec.ts` |
| API test files | `tests/api/<resource>.api.spec.ts` | `accounts.api.spec.ts` |
| Page objects | `tests/page-objects/<PageName>.ts` | `LoginPage.ts` |
| Fixtures | `tests/fixtures/<purpose>.fixture.ts` | `db.fixture.ts` |
| Test data | `tests/fixtures/test-data.ts` | single file |
| Snapshot baselines | auto-generated in `*-snapshots/` dirs | `login-page.png` |
### Test descriptions
Use `test.describe('Feature or Endpoint')` and `test('should <behavior>')`:
```ts
test.describe('POST /api/auth/login', () => {
test('should return access token for valid credentials', async ({ request }) => {
// ...
});
});
```
---
## How to Write New Tests
### 1. E2E (browser) test
```ts
// tests/e2e/invoices.spec.ts
import { test, expect } from '../fixtures/base.fixture';
import { InvoicesPage } from '../page-objects';
test.describe('Invoices', () => {
let invoicesPage: InvoicesPage;
test.beforeEach(async ({ page }) => {
invoicesPage = new InvoicesPage(page);
await invoicesPage.goto();
});
test('should display invoice list', async () => {
await invoicesPage.assertOnPage();
// ... assertions
});
});
```
### 2. API test
```ts
// tests/api/payments.api.spec.ts
import { test, expect } from '@playwright/test';
import { apiLogin, apiSwitchOrg, authHeaders } from '../fixtures/auth.fixture';
import { TEST_USERS } from '../fixtures/test-data';
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
let accessToken: string;
test.beforeAll(async ({ request }) => {
const tokens = await apiLogin(request, TEST_USERS.treasurer);
if (tokens.organizations?.length > 0) {
const switched = await apiSwitchOrg(request, tokens.accessToken, (tokens.organizations[0] as any).id);
accessToken = switched.accessToken;
} else {
accessToken = tokens.accessToken;
}
});
test.describe('GET /api/payments', () => {
test('should return payments list', async ({ request }) => {
const response = await request.get(`${API_BASE}/payments`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
});
});
```
### 3. Visual regression test
```ts
test('invoices page should match baseline', async ({ page }) => {
await page.goto('/invoices');
await page.waitForLoadState('networkidle');
await page.waitForTimeout(500); // Let animations settle
await expect(page).toHaveScreenshot('invoices-page.png', {
fullPage: true,
mask: [page.locator('time')], // Mask dynamic dates
});
});
```
Update baselines: `npx playwright test --update-snapshots`
---
## How to Add New Page Objects
1. Create `tests/page-objects/MyPage.ts`:
```ts
import { type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class MyPage extends BasePage {
readonly path = '/my-path';
// Locators — prefer role/label selectors over CSS
get heading() {
return this.page.getByRole('heading', { name: /my page/i });
}
get createButton() {
return this.page.getByRole('button', { name: /create/i });
}
// Actions
override async waitForReady(): Promise<void> {
await this.page.waitForLoadState('networkidle');
await expect(this.heading).toBeVisible();
}
async createItem(name: string): Promise<void> {
await this.createButton.click();
await this.page.getByLabel(/name/i).fill(name);
await this.page.getByRole('button', { name: /save/i }).click();
}
}
```
2. Export from `tests/page-objects/index.ts`:
```ts
export { MyPage } from './MyPage';
```
### Page object rules
- Extend `BasePage` and set `readonly path`
- Override `waitForReady()` for page-specific loading
- Use **role/label locators** (not CSS selectors): `getByRole()`, `getByLabel()`, `getByText()`
- Expose **locators as getters** and **actions as methods**
- Keep assertions in test files, not page objects (except `assertOnPage()`)
---
## Authentication in Tests
### Pre-authenticated tests (default)
Most tests use stored auth state from `auth.setup.ts`. This runs once via the `auth-setup` Playwright project and saves browser state to `tests/.auth/user.json`.
Tests automatically get this state via `storageState` in `playwright.config.ts`.
### Unauthenticated tests
For testing the login flow itself, opt out:
```ts
test.use({ storageState: { cookies: [], origins: [] } });
```
### API tests
Use the `apiLogin()` and `authHeaders()` helpers:
```ts
import { apiLogin, authHeaders } from '../fixtures/auth.fixture';
const tokens = await apiLogin(request, TEST_USERS.treasurer);
const response = await request.get(url, {
headers: authHeaders(tokens.accessToken),
});
```
---
## Database Seeding & Cleanup
### When to use direct DB access
- Verifying backend wrote correct data
- Seeding complex state that's hard to create via API
- Cleanup after tests
### How
```ts
import { test } from '../fixtures/base.fixture';
test('should verify data', async ({ db }) => {
const result = await db.query('SELECT * FROM schema.table WHERE ...');
expect(result.rows.length).toBeGreaterThan(0);
});
```
### Cleanup convention
- Prefix all test-created data with `E2E_` (use `TEST_PREFIX` from test-data.ts)
- The `db.cleanup()` method deletes rows matching this prefix
- Call `db.cleanup()` in `test.afterAll` for write-path tests
---
## Running Tests
### Prerequisites
1. Docker Compose services running: `docker-compose up -d`
2. Test user seeded in the database (use the backend seed script or create manually)
3. Environment configured: `cp .env.test.example .env.test` and fill in values
### Commands
```bash
# Install Playwright (first time)
npx playwright install --with-deps
# Run all tests
npx playwright test
# Run only E2E tests
npx playwright test tests/e2e/
# Run only API tests
npx playwright test --project=api
# Run in specific browser
npx playwright test --project=chromium
# Run in headed mode (see the browser)
npx playwright test --headed
# Run a single test file
npx playwright test tests/e2e/auth.spec.ts
# Debug mode (step through tests)
npx playwright test --debug
# Update visual regression baselines
npx playwright test tests/e2e/visual.spec.ts --update-snapshots
# View HTML report
npx playwright show-report
# Run against production
BASE_URL=https://your-prod-domain.com npx playwright test --project=api
```
### npm scripts (from project root)
```bash
npm run test:e2e # All Playwright tests
npm run test:e2e:chromium # Chromium only
npm run test:e2e:api # API tests only
npm run test:e2e:headed # Headed mode
npm run test:e2e:debug # Debug mode
```
---
## Environment Variables
| Variable | Default | Purpose |
|----------|---------|---------|
| `BASE_URL` | `http://localhost` | App URL (nginx) |
| `API_BASE_URL` | `http://localhost/api` | Backend API base |
| `TEST_DB_URL` | `postgresql://hoafinance:change_me@localhost:5432/hoafinance` | Direct Postgres for seeding |
| `TEST_USER_EMAIL` | `e2e-treasurer@test.hoaledgeriq.com` | Test user email |
| `TEST_USER_PASSWORD` | `TestPass123!` | Test user password |
| `CI` | — | Set by CI providers; enables retries, single worker |
---
## Style Rules
1. **Import `test` from `../fixtures/base.fixture`** for tests needing DB or auth fixtures. Import from `@playwright/test` for basic tests.
2. **One `test.describe` per feature or endpoint** per file.
3. **No `page.waitForTimeout()` except in visual tests** — use `waitForLoadState`, `waitForURL`, or `waitForResponse` instead.
4. **No hardcoded URLs** — use `BASE_URL`, `API_BASE`, or page object paths.
5. **No test interdependencies** — each test should work in isolation (use `test.beforeEach` for setup).
6. **Clean up after write tests** — use `TEST_PREFIX` and `db.cleanup()`.
7. **API tests go in `tests/api/`**, E2E tests in `tests/e2e/`** — don't mix.
8. **Locators**: prefer `getByRole` > `getByLabel` > `getByText` > `getByTestId` > CSS selectors.
---
## Adding Tests for a New Feature (Quick Checklist)
- [ ] Create page object in `tests/page-objects/` if it's a new page
- [ ] Export it from `tests/page-objects/index.ts`
- [ ] Create `tests/e2e/<feature>.spec.ts` for UI flows
- [ ] Create `tests/api/<resource>.api.spec.ts` for API endpoints
- [ ] Add sample data constants to `tests/fixtures/test-data.ts` if needed
- [ ] Run `npx playwright test tests/e2e/<feature>.spec.ts` to verify
- [ ] Update visual baselines if the feature changes existing pages

View File

@@ -7,7 +7,6 @@ import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware';
import { WriteAccessGuard } from './common/guards/write-access.guard';
import { CapabilityGuard } from './common/guards/capability.guard';
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module';
@@ -35,7 +34,6 @@ import { BillingModule } from './modules/billing/billing.module';
import { EmailModule } from './modules/email/email.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';
@Module({
@@ -92,7 +90,6 @@ import { ScheduleModule } from '@nestjs/schedule';
EmailModule,
OnboardingModule,
IdeasModule,
ShadowAiModule,
ScheduleModule.forRoot(),
],
controllers: [AppController],
@@ -101,10 +98,6 @@ import { ScheduleModule } from '@nestjs/schedule';
provide: APP_GUARD,
useClass: WriteAccessGuard,
},
{
provide: APP_GUARD,
useClass: CapabilityGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: NoCacheInterceptor,

View File

@@ -1,14 +0,0 @@
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);

View File

@@ -1,83 +0,0 @@
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);
}
}

View File

@@ -1,65 +0,0 @@
/**
* 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));

View File

@@ -1,157 +0,0 @@
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,
],
};

View File

@@ -1,5 +0,0 @@
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';

View File

@@ -1,57 +0,0 @@
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();
}

View File

@@ -1,106 +0,0 @@
/**
* 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,
};
}

View File

@@ -3,7 +3,6 @@ import {
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { AccountsService } from './accounts.service';
import { CreateAccountDto } from './dto/create-account.dto';
import { UpdateAccountDto } from './dto/update-account.dto';
@@ -17,28 +16,24 @@ export class AccountsController {
@Get()
@ApiOperation({ summary: 'List all accounts' })
@RequireCapability('financials.accounts.view')
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
return this.accountsService.findAll(fundType, includeArchived === 'true');
}
@Get('trial-balance')
@ApiOperation({ summary: 'Get trial balance' })
@RequireCapability('financials.accounts.view')
getTrialBalance(@Query('asOfDate') asOfDate?: string) {
return this.accountsService.getTrialBalance(asOfDate);
}
@Put(':id/set-primary')
@ApiOperation({ summary: 'Set account as primary for its fund type' })
@RequireCapability('financials.accounts.edit')
setPrimary(@Param('id') id: string) {
return this.accountsService.setPrimary(id);
}
@Post('bulk-opening-balances')
@ApiOperation({ summary: 'Set opening balances for multiple accounts' })
@RequireCapability('financials.accounts.edit')
bulkSetOpeningBalances(
@Body() dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] },
) {
@@ -47,7 +42,6 @@ export class AccountsController {
@Post(':id/opening-balance')
@ApiOperation({ summary: 'Set opening balance for an account at a specific date' })
@RequireCapability('financials.accounts.edit')
setOpeningBalance(
@Param('id') id: string,
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
@@ -57,7 +51,6 @@ export class AccountsController {
@Post(':id/adjust-balance')
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
@RequireCapability('financials.accounts.edit')
adjustBalance(
@Param('id') id: string,
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
@@ -67,7 +60,6 @@ export class AccountsController {
@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 },
) {
@@ -76,21 +68,18 @@ export class AccountsController {
@Get(':id')
@ApiOperation({ summary: 'Get account by ID' })
@RequireCapability('financials.accounts.view')
findOne(@Param('id') id: string) {
return this.accountsService.findOne(id);
}
@Post()
@ApiOperation({ summary: 'Create a new account' })
@RequireCapability('financials.accounts.edit')
create(@Body() dto: CreateAccountDto) {
return this.accountsService.create(dto);
}
@Put(':id')
@ApiOperation({ summary: 'Update an account' })
@RequireCapability('financials.accounts.edit')
update(@Param('id') id: string, @Body() dto: UpdateAccountDto) {
return this.accountsService.update(id, dto);
}

View File

@@ -1,7 +1,6 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { AssessmentGroupsService } from './assessment-groups.service';
@ApiTags('assessment-groups')
@@ -12,30 +11,23 @@ export class AssessmentGroupsController {
constructor(private service: AssessmentGroupsService) {}
@Get()
@RequireCapability('assessments.groups.view')
findAll() { return this.service.findAll(); }
@Get('summary')
@RequireCapability('assessments.groups.view')
getSummary() { return this.service.getSummary(); }
@Get('default')
@RequireCapability('assessments.groups.view')
getDefault() { return this.service.getDefault(); }
@Get(':id')
@RequireCapability('assessments.groups.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@RequireCapability('assessments.groups.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('assessments.groups.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
@Put(':id/set-default')
@RequireCapability('assessments.groups.edit')
setDefault(@Param('id') id: string) { return this.service.setDefault(id); }
}

View File

@@ -17,7 +17,6 @@ import { EmailService } from '../email/email.service';
import { RegisterDto } from './dto/register.dto';
import { User } from '../users/entities/user.entity';
import { RefreshTokenService } from './refresh-token.service';
import { resolveCapabilitiesArray } from '../../common/permissions';
@Injectable()
export class AuthService {
@@ -163,12 +162,6 @@ export class AuthService {
// Generate new refresh token for org switch
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
const orgSettings = membership.organization.settings || {};
const capabilities = resolveCapabilitiesArray(
membership.role,
orgSettings.permissionOverrides,
);
return {
accessToken: this.jwtService.sign(payload),
refreshToken,
@@ -176,8 +169,7 @@ export class AuthService {
id: membership.organization.id,
name: membership.organization.name,
role: membership.role,
settings: orgSettings,
capabilities,
settings: membership.organization.settings || {},
},
};
}
@@ -476,16 +468,12 @@ export class AuthService {
hasSeenIntro: user.hasSeenIntro || false,
mfaEnabled: user.mfaEnabled || false,
},
organizations: orgs.map((uo) => {
const settings = uo.organization?.settings || {};
return {
id: uo.organizationId,
name: uo.organization?.name,
status: uo.organization?.status,
role: uo.role,
capabilities: resolveCapabilitiesArray(uo.role, settings.permissionOverrides),
};
}),
organizations: orgs.map((uo) => ({
id: uo.organizationId,
name: uo.organization?.name,
status: uo.organization?.status,
role: uo.role,
})),
};
}

View File

@@ -3,7 +3,6 @@ import { Response } from 'express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { BoardPlanningService } from './board-planning.service';
import { BoardPlanningProjectionService } from './board-planning-projection.service';
import { BudgetPlanningService } from './budget-planning.service';
@@ -23,32 +22,27 @@ export class BoardPlanningController {
@Get('scenarios')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listScenarios(@Query('type') type?: string) {
return this.service.listScenarios(type);
}
@Get('scenarios/:id')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getScenario(@Param('id') id: string) {
return this.service.getScenario(id);
}
@Post('scenarios')
@RequireCapability('planning.scenarios.edit')
createScenario(@Body() dto: any, @Req() req: any) {
return this.service.createScenario(dto, req.user.sub);
}
@Put('scenarios/:id')
@RequireCapability('planning.scenarios.edit')
updateScenario(@Param('id') id: string, @Body() dto: any) {
return this.service.updateScenario(id, dto);
}
@Delete('scenarios/:id')
@RequireCapability('planning.scenarios.edit')
deleteScenario(@Param('id') id: string) {
return this.service.deleteScenario(id);
}
@@ -57,31 +51,26 @@ export class BoardPlanningController {
@Get('scenarios/:scenarioId/investments')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listInvestments(@Param('scenarioId') scenarioId: string) {
return this.service.listInvestments(scenarioId);
}
@Post('scenarios/:scenarioId/investments')
@RequireCapability('planning.scenarios.edit')
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addInvestment(scenarioId, dto);
}
@Post('scenarios/:scenarioId/investments/from-recommendation')
@RequireCapability('planning.scenarios.edit')
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
}
@Put('investments/:id')
@RequireCapability('planning.scenarios.edit')
updateInvestment(@Param('id') id: string, @Body() dto: any) {
return this.service.updateInvestment(id, dto);
}
@Delete('investments/:id')
@RequireCapability('planning.scenarios.edit')
removeInvestment(@Param('id') id: string) {
return this.service.removeInvestment(id);
}
@@ -90,25 +79,21 @@ export class BoardPlanningController {
@Get('scenarios/:scenarioId/assessments')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listAssessments(@Param('scenarioId') scenarioId: string) {
return this.service.listAssessments(scenarioId);
}
@Post('scenarios/:scenarioId/assessments')
@RequireCapability('planning.scenarios.edit')
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addAssessment(scenarioId, dto);
}
@Put('assessments/:id')
@RequireCapability('planning.scenarios.edit')
updateAssessment(@Param('id') id: string, @Body() dto: any) {
return this.service.updateAssessment(id, dto);
}
@Delete('assessments/:id')
@RequireCapability('planning.scenarios.edit')
removeAssessment(@Param('id') id: string) {
return this.service.removeAssessment(id);
}
@@ -117,13 +102,11 @@ export class BoardPlanningController {
@Get('scenarios/:id/projection')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getProjection(@Param('id') id: string) {
return this.projection.getProjection(id);
}
@Post('scenarios/:id/projection/refresh')
@RequireCapability('planning.scenarios.edit')
refreshProjection(@Param('id') id: string) {
return this.projection.computeProjection(id);
}
@@ -132,7 +115,6 @@ export class BoardPlanningController {
@Get('compare')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
compareScenarios(@Query('ids') ids: string) {
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
return this.projection.compareScenarios(scenarioIds);
@@ -141,7 +123,6 @@ export class BoardPlanningController {
// ── Execute Investment ──
@Post('investments/:id/execute')
@RequireCapability('planning.scenarios.edit')
executeInvestment(
@Param('id') id: string,
@Body() dto: { executionDate: string },
@@ -154,51 +135,43 @@ export class BoardPlanningController {
@Get('budget-plans')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listBudgetPlans() {
return this.budgetPlanning.listPlans();
}
@Get('budget-plans/available-years')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getAvailableYears() {
return this.budgetPlanning.getAvailableYears();
}
@Get('budget-plans/:year')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getBudgetPlan(@Param('year') year: string) {
return this.budgetPlanning.getPlan(parseInt(year, 10));
}
@Post('budget-plans')
@RequireCapability('planning.scenarios.edit')
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);
}
@Put('budget-plans/:year/lines')
@RequireCapability('planning.scenarios.edit')
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
}
@Put('budget-plans/:year/inflation')
@RequireCapability('planning.scenarios.edit')
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
}
@Put('budget-plans/:year/status')
@RequireCapability('planning.scenarios.edit')
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
}
@Post('budget-plans/:year/import')
@RequireCapability('planning.scenarios.edit')
importBudgetPlanLines(
@Param('year') year: string,
@Body() lines: any[],
@@ -208,7 +181,6 @@ export class BoardPlanningController {
}
@Get('budget-plans/:year/template')
@RequireCapability('planning.scenarios.view')
async getBudgetPlanTemplate(
@Param('year') year: string,
@Res() res: Response,
@@ -222,7 +194,6 @@ export class BoardPlanningController {
}
@Delete('budget-plans/:year')
@RequireCapability('planning.scenarios.edit')
deleteBudgetPlan(@Param('year') year: string) {
return this.budgetPlanning.deletePlan(parseInt(year, 10));
}

View File

@@ -2,7 +2,6 @@ import { Controller, Get, Put, Post, Body, Param, Query, Res, UseGuards, ParseIn
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { BudgetsService } from './budgets.service';
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
@@ -15,7 +14,6 @@ export class BudgetsController {
@Post(':year/import')
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
@RequireCapability('financials.budgets.edit')
importBudget(
@Param('year', ParseIntPipe) year: number,
@Body() lines: any[],
@@ -25,7 +23,6 @@ export class BudgetsController {
@Get(':year/template')
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
@RequireCapability('financials.budgets.view')
async getTemplate(
@Param('year', ParseIntPipe) year: number,
@Res() res: Response,
@@ -40,7 +37,6 @@ export class BudgetsController {
@Get(':year/vs-actual')
@ApiOperation({ summary: 'Budget vs actual comparison' })
@RequireCapability('financials.budgets.view')
budgetVsActual(
@Param('year', ParseIntPipe) year: number,
@Query('month') month?: string,
@@ -50,14 +46,12 @@ export class BudgetsController {
@Get(':year')
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
@RequireCapability('financials.budgets.view')
findByYear(@Param('year', ParseIntPipe) year: number) {
return this.budgetsService.findByYear(year);
}
@Put(':year')
@ApiOperation({ summary: 'Upsert budgets for a fiscal year' })
@RequireCapability('financials.budgets.edit')
upsert(
@Param('year', ParseIntPipe) year: number,
@Body() budgets: UpsertBudgetDto[],

View File

@@ -1,7 +1,6 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { CapitalProjectsService } from './capital-projects.service';
@ApiTags('capital-projects')
@@ -12,18 +11,14 @@ export class CapitalProjectsController {
constructor(private service: CapitalProjectsService) {}
@Get()
@RequireCapability('planning.projects.view')
findAll() { return this.service.findAll(); }
@Get(':id')
@RequireCapability('planning.projects.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@RequireCapability('planning.projects.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('planning.projects.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
}

View File

@@ -6,6 +6,5 @@ import { HealthScoresScheduler } from './health-scores.scheduler';
@Module({
controllers: [HealthScoresController],
providers: [HealthScoresService, HealthScoresScheduler],
exports: [HealthScoresService],
})
export class HealthScoresModule {}

View File

@@ -180,7 +180,7 @@ export class HealthScoresService {
// ── Data Readiness Checks ──
async checkDataReadiness(qr: any, scoreType: string): Promise<string[]> {
private async checkDataReadiness(qr: any, scoreType: string): Promise<string[]> {
const missing: string[] = [];
if (scoreType === 'operating') {
@@ -249,7 +249,7 @@ export class HealthScoresService {
// ── Data Gathering ──
async gatherOperatingData(qr: any) {
private async gatherOperatingData(qr: any) {
const year = new Date().getFullYear();
const [accounts, budgets, assessments, cashFlow, recentTransactions, actualsMonths] = await Promise.all([
@@ -520,7 +520,7 @@ export class HealthScoresService {
};
}
async gatherReserveData(qr: any) {
private async gatherReserveData(qr: any) {
const year = new Date().getFullYear();
const currentMonth = new Date().getMonth(); // 0-indexed
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
@@ -787,7 +787,7 @@ export class HealthScoresService {
// ── AI Prompt Construction ──
buildOperatingPrompt(data: any): Array<{ role: string; content: string }> {
private buildOperatingPrompt(data: any): Array<{ role: string; content: string }> {
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.
@@ -927,7 +927,7 @@ Projected Year-End Cash: $${data.projectedYearEndCash.toFixed(0)}`;
];
}
buildReservePrompt(data: any): Array<{ role: string; content: string }> {
private buildReservePrompt(data: any): Array<{ role: string; content: string }> {
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.

View File

@@ -2,7 +2,6 @@ import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { InvestmentPlanningService } from './investment-planning.service';
@ApiTags('investment-planning')
@@ -14,28 +13,24 @@ export class InvestmentPlanningController {
@Get('snapshot')
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
@RequireCapability('planning.investments.view')
getSnapshot() {
return this.service.getFinancialSnapshot();
}
@Get('cd-rates')
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
@RequireCapability('planning.investments.view')
getCdRates() {
return this.service.getCdRates();
}
@Get('market-rates')
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
@RequireCapability('planning.investments.view')
getMarketRates() {
return this.service.getMarketRates();
}
@Get('saved-recommendation')
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
@RequireCapability('planning.investments.view')
getSavedRecommendation() {
return this.service.getSavedRecommendation();
}
@@ -43,7 +38,6 @@ export class InvestmentPlanningController {
@Post('recommendations')
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
@AllowViewer()
@RequireCapability('planning.investments.edit')
triggerRecommendations(@Req() req: any) {
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
}

View File

@@ -5,6 +5,5 @@ import { InvestmentPlanningService } from './investment-planning.service';
@Module({
controllers: [InvestmentPlanningController],
providers: [InvestmentPlanningService],
exports: [InvestmentPlanningService],
})
export class InvestmentPlanningModule {}

View File

@@ -877,7 +877,7 @@ export class InvestmentPlanningService {
// ── Private: AI Prompt Construction ──
buildPromptMessages(
private buildPromptMessages(
snapshot: any,
allRates: { cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] },
monthlyForecast: any,
@@ -1059,285 +1059,6 @@ 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 async callAI(messages: Array<{ role: string; content: string }>): Promise<AIResponse> {

View File

@@ -1,7 +1,6 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { InvestmentsService } from './investments.service';
@ApiTags('investments')
@@ -12,18 +11,14 @@ export class InvestmentsController {
constructor(private service: InvestmentsService) {}
@Get()
@RequireCapability('planning.investments.view')
findAll() { return this.service.findAll(); }
@Get(':id')
@RequireCapability('planning.investments.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@RequireCapability('planning.investments.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('planning.investments.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
}

View File

@@ -1,7 +1,6 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { InvoicesService } from './invoices.service';
@ApiTags('invoices')
@@ -12,27 +11,22 @@ export class InvoicesController {
constructor(private invoicesService: InvoicesService) {}
@Get()
@RequireCapability('transactions.view')
findAll() { return this.invoicesService.findAll(); }
@Get(':id')
@RequireCapability('transactions.view')
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
@Post('generate-preview')
@RequireCapability('transactions.edit')
generatePreview(@Body() dto: { month: number; year: number }) {
return this.invoicesService.generatePreview(dto);
}
@Post('generate-bulk')
@RequireCapability('transactions.edit')
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
return this.invoicesService.generateBulk(dto, req.user.sub);
}
@Post('apply-late-fees')
@RequireCapability('transactions.edit')
applyLateFees(@Body() dto: { grace_period_days: number; late_fee_amount: number }, @Request() req: any) {
return this.invoicesService.applyLateFees(dto, req.user.sub);
}

View File

@@ -3,7 +3,6 @@ import {
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { JournalEntriesService } from './journal-entries.service';
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
import { VoidJournalEntryDto } from './dto/void-journal-entry.dto';
@@ -17,7 +16,6 @@ export class JournalEntriesController {
@Get()
@ApiOperation({ summary: 'List journal entries' })
@RequireCapability('transactions.view')
findAll(
@Query('from') from?: string,
@Query('to') to?: string,
@@ -29,28 +27,24 @@ export class JournalEntriesController {
@Get(':id')
@ApiOperation({ summary: 'Get journal entry by ID' })
@RequireCapability('transactions.view')
findOne(@Param('id') id: string) {
return this.jeService.findOne(id);
}
@Post()
@ApiOperation({ summary: 'Create a journal entry' })
@RequireCapability('transactions.edit')
create(@Body() dto: CreateJournalEntryDto, @Request() req: any) {
return this.jeService.create(dto, req.user.sub);
}
@Post(':id/post')
@ApiOperation({ summary: 'Post (finalize) a journal entry' })
@RequireCapability('transactions.edit')
post(@Param('id') id: string, @Request() req: any) {
return this.jeService.post(id, req.user.sub);
}
@Post(':id/void')
@ApiOperation({ summary: 'Void a journal entry' })
@RequireCapability('transactions.edit')
void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) {
return this.jeService.void(id, req.user.sub, dto.reason);
}

View File

@@ -1,7 +1,6 @@
import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { MonthlyActualsService } from './monthly-actuals.service';
@ApiTags('monthly-actuals')
@@ -13,14 +12,12 @@ export class MonthlyActualsController {
@Get(':year/: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) {
return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month));
}
@Post(':year/:month')
@ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' })
@RequireCapability('financials.actuals.edit')
async save(
@Param('year') year: string,
@Param('month') month: string,

View File

@@ -3,8 +3,6 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { OrganizationsService } from './organizations.service';
import { CreateOrganizationDto } from './dto/create-organization.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { resolveCapabilitiesArray, ALL_CAPABILITIES } from '../../common/permissions';
@ApiTags('organizations')
@Controller('organizations')
@@ -25,87 +23,54 @@ export class OrganizationsController {
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')
@ApiOperation({ summary: 'Update settings for the current organization' })
@RequireCapability('settings.org.edit')
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
// Validate permissionOverrides if present
if (body.permissionOverrides) {
this.validatePermissionOverrides(body.permissionOverrides);
}
this.requireTenantAdmin(req);
return this.orgService.updateSettings(req.user.orgId, body);
}
// ── 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')
@ApiOperation({ summary: 'List members of current organization' })
@RequireCapability('settings.members.view')
async getMembers(@Request() req: any) {
this.requireTenantAdmin(req);
return this.orgService.getMembers(req.user.orgId);
}
@Post('members')
@ApiOperation({ summary: 'Add a member to the current organization' })
@RequireCapability('settings.members.manage')
async addMember(
@Request() req: any,
@Body() body: { email: string; firstName: string; lastName: string; password: string; role: string },
) {
this.requireTenantAdmin(req);
return this.orgService.addMember(req.user.orgId, body);
}
@Put('members/:id/role')
@ApiOperation({ summary: 'Update a member role' })
@RequireCapability('settings.members.manage')
async updateMemberRole(
@Request() req: any,
@Param('id') id: string,
@Body() body: { role: string },
) {
this.requireTenantAdmin(req);
return this.orgService.updateMemberRole(req.user.orgId, id, body.role);
}
@Delete('members/:id')
@ApiOperation({ summary: 'Remove a member from the organization' })
@RequireCapability('settings.members.manage')
async removeMember(@Request() req: any, @Param('id') id: string) {
this.requireTenantAdmin(req);
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}`);
}
}
}
}
}
}

View File

@@ -1,7 +1,6 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { PaymentsService } from './payments.service';
@ApiTags('payments')
@@ -12,24 +11,19 @@ export class PaymentsController {
constructor(private paymentsService: PaymentsService) {}
@Get()
@RequireCapability('transactions.view')
findAll() { return this.paymentsService.findAll(); }
@Get(':id')
@RequireCapability('transactions.view')
findOne(@Param('id') id: string) { return this.paymentsService.findOne(id); }
@Post()
@RequireCapability('transactions.edit')
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
@Put(':id')
@RequireCapability('transactions.edit')
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
return this.paymentsService.update(id, dto, req.user.sub);
}
@Delete(':id')
@RequireCapability('transactions.edit')
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
}

View File

@@ -2,7 +2,6 @@ import { Controller, Get, Post, Put, Body, Param, Res, UseGuards } from '@nestjs
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { ProjectsService } from './projects.service';
@ApiTags('projects')
@@ -13,11 +12,9 @@ export class ProjectsController {
constructor(private service: ProjectsService) {}
@Get()
@RequireCapability('planning.projects.view')
findAll() { return this.service.findAll(); }
@Get('export')
@RequireCapability('planning.projects.view')
async exportCSV(@Res() res: Response) {
const csv = await this.service.exportCSV();
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="projects.csv"' });
@@ -25,27 +22,21 @@ export class ProjectsController {
}
@Get('planning')
@RequireCapability('planning.projects.view')
findForPlanning() { return this.service.findForPlanning(); }
@Get(':id')
@RequireCapability('planning.projects.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post('import')
@RequireCapability('planning.projects.edit')
importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); }
@Post()
@RequireCapability('planning.projects.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('planning.projects.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
@Put(':id/planned-date')
@RequireCapability('planning.projects.edit')
updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) {
return this.service.updatePlannedDate(id, dto.planned_date);
}

View File

@@ -1,7 +1,6 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { ReportsService } from './reports.service';
@ApiTags('reports')
@@ -12,13 +11,11 @@ export class ReportsController {
constructor(private reportsService: ReportsService) {}
@Get('balance-sheet')
@RequireCapability('reports.view')
getBalanceSheet(@Query('as_of') asOf?: string) {
return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]);
}
@Get('income-statement')
@RequireCapability('reports.view')
getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) {
const now = new Date();
const defaultFrom = `${now.getFullYear()}-01-01`;
@@ -27,7 +24,6 @@ export class ReportsController {
}
@Get('cash-flow-sankey')
@RequireCapability('reports.view')
getCashFlowSankey(
@Query('year') year?: string,
@Query('source') source?: string,
@@ -41,7 +37,6 @@ export class ReportsController {
}
@Get('cash-flow')
@RequireCapability('reports.view')
getCashFlowStatement(
@Query('from') from?: string,
@Query('to') to?: string,
@@ -56,31 +51,26 @@ export class ReportsController {
}
@Get('aging')
@RequireCapability('reports.view')
getAgingReport() {
return this.reportsService.getAgingReport();
}
@Get('year-end')
@RequireCapability('reports.view')
getYearEndSummary(@Query('year') year?: string) {
return this.reportsService.getYearEndSummary(parseInt(year || '') || new Date().getFullYear());
}
@Get('dashboard')
@RequireCapability('reports.view')
getDashboardKPIs() {
return this.reportsService.getDashboardKPIs();
}
@Get('upcoming-investment-activities')
@RequireCapability('reports.view')
getUpcomingInvestmentActivities() {
return this.reportsService.getUpcomingInvestmentActivities();
}
@Get('cash-flow-forecast')
@RequireCapability('reports.view')
getCashFlowForecast(
@Query('startYear') startYear?: string,
@Query('months') months?: string,
@@ -91,7 +81,6 @@ export class ReportsController {
}
@Get('capital-planning')
@RequireCapability('reports.view')
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
return this.reportsService.getCapitalPlanningReport(
parseInt(startYear || '') || undefined,
@@ -99,7 +88,6 @@ export class ReportsController {
}
@Get('quarterly')
@RequireCapability('reports.view')
getQuarterlyFinancial(
@Query('year') year?: string,
@Query('quarter') quarter?: string,

View File

@@ -1,7 +1,6 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { ReserveComponentsService } from './reserve-components.service';
@ApiTags('reserve-components')
@@ -12,18 +11,14 @@ export class ReserveComponentsController {
constructor(private service: ReserveComponentsService) {}
@Get()
@RequireCapability('planning.projects.view')
findAll() { return this.service.findAll(); }
@Get(':id')
@RequireCapability('planning.projects.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@RequireCapability('planning.projects.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('planning.projects.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
}

View File

@@ -1,37 +0,0 @@
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;
}

View File

@@ -1,52 +0,0 @@
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;
}

View File

@@ -1,44 +0,0 @@
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;
}

View File

@@ -1,118 +0,0 @@
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;
}
}

View File

@@ -1,26 +0,0 @@
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();
}
}

View File

@@ -1,723 +0,0 @@
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();
}
}
}

View File

@@ -2,7 +2,6 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Res, UseGuards } from
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { UnitsService } from './units.service';
@ApiTags('units')
@@ -13,11 +12,9 @@ export class UnitsController {
constructor(private unitsService: UnitsService) {}
@Get()
@RequireCapability('assessments.units.view')
findAll() { return this.unitsService.findAll(); }
@Get('export')
@RequireCapability('assessments.units.view')
async exportCSV(@Res() res: Response) {
const csv = await this.unitsService.exportCSV();
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="units.csv"' });
@@ -25,22 +22,17 @@ export class UnitsController {
}
@Get(':id')
@RequireCapability('assessments.units.view')
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
@Post('import')
@RequireCapability('assessments.units.edit')
importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); }
@Post()
@RequireCapability('assessments.units.edit')
create(@Body() dto: any) { return this.unitsService.create(dto); }
@Put(':id')
@RequireCapability('assessments.units.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
@Delete(':id')
@RequireCapability('assessments.units.edit')
delete(@Param('id') id: string) { return this.unitsService.delete(id); }
}

View File

@@ -2,7 +2,6 @@ import { Controller, Get, Post, Put, Body, Param, Query, Res, UseGuards } from '
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { VendorsService } from './vendors.service';
@ApiTags('vendors')
@@ -13,11 +12,9 @@ export class VendorsController {
constructor(private vendorsService: VendorsService) {}
@Get()
@RequireCapability('reference.vendors.view')
findAll() { return this.vendorsService.findAll(); }
@Get('export')
@RequireCapability('reference.vendors.view')
async exportCSV(@Res() res: Response) {
const csv = await this.vendorsService.exportCSV();
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="vendors.csv"' });
@@ -25,24 +22,19 @@ export class VendorsController {
}
@Get('1099-data')
@RequireCapability('reference.vendors.view')
get1099Data(@Query('year') year: string) {
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
}
@Get(':id')
@RequireCapability('reference.vendors.view')
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
@Post('import')
@RequireCapability('reference.vendors.edit')
importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); }
@Post()
@RequireCapability('reference.vendors.edit')
create(@Body() dto: any) { return this.vendorsService.create(dto); }
@Put(':id')
@RequireCapability('reference.vendors.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); }
}

View File

@@ -58,7 +58,7 @@ CREATE TABLE shared.user_organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES shared.users(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', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')),
role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')),
is_active BOOLEAN DEFAULT TRUE,
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, organization_id)

View File

@@ -1,9 +0,0 @@
-- 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'));

View File

@@ -1,230 +0,0 @@
# 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 |

View File

@@ -1,275 +0,0 @@
# 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`

View File

@@ -30,7 +30,6 @@ import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
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 { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
import { MonthlyActualsPage } from './pages/monthly-actuals/MonthlyActualsPage';
@@ -42,7 +41,6 @@ import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentS
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
import { PricingPage } from './pages/pricing/PricingPage';
import { PermissionSettingsPage } from './pages/settings/PermissionSettingsPage';
import { OnboardingPage } from './pages/onboarding/OnboardingPage';
import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage';
@@ -137,7 +135,6 @@ export function App() {
>
<Route index element={<AdminPage />} />
<Route path="ideas" element={<AdminIdeasPage />} />
<Route path="shadow-ai" element={<AdminShadowAiPage />} />
</Route>
{/* Main app routes (require auth + org) */}
@@ -183,7 +180,6 @@ export function App() {
<Route path="settings" element={<SettingsPage />} />
<Route path="preferences" element={<UserPreferencesPage />} />
<Route path="org-members" element={<OrgMembersPage />} />
<Route path="settings/permissions" element={<PermissionSettingsPage />} />
</Route>
</Routes>
);

View File

@@ -77,9 +77,8 @@ export function AppLayout() {
navigate('/admin');
};
// Capability-based check: can this user manage members?
const capabilities = currentOrg?.capabilities || [];
const isTenantAdmin = user?.isSuperadmin || capabilities.includes('settings.members.manage');
// Tenant admins (president role) can manage org members
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
return (
<AppShell

View File

@@ -23,60 +23,57 @@ import {
IconBulb,
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
import { CAPABILITIES } from '../../permissions/capabilities';
const C = CAPABILITIES;
const navSections = [
{
items: [
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard', capability: C.DASHBOARD_VIEW },
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
],
},
{
label: 'Financials',
items: [
{ label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts', capability: C.FINANCIALS_ACCOUNTS_VIEW },
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow', capability: C.FINANCIALS_CASHFLOW_VIEW },
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals', capability: C.FINANCIALS_ACTUALS_VIEW },
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets', capability: C.FINANCIALS_BUDGETS_VIEW },
{ label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts' },
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' },
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets' },
],
},
{
label: 'Assessments',
items: [
{ label: 'Units / Homeowners', icon: IconHome, path: '/units', capability: C.ASSESSMENTS_UNITS_VIEW },
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups', capability: C.ASSESSMENTS_GROUPS_VIEW },
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
],
},
{
label: 'Board Planning',
items: [
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets', capability: C.PLANNING_BUDGETS_VIEW },
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets' },
{
label: 'Projects', icon: IconShieldCheck, path: '/projects', capability: C.PLANNING_PROJECTS_VIEW,
label: 'Projects', icon: IconShieldCheck, path: '/projects',
children: [
{ label: 'Capital Planning', path: '/capital-projects' },
],
},
{
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments', capability: C.PLANNING_SCENARIOS_VIEW,
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
},
{ 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', capability: C.PLANNING_SCENARIOS_VIEW },
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare', capability: C.PLANNING_SCENARIOS_VIEW },
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
],
},
{
label: 'Board Reference',
items: [
{ label: 'Vendors', icon: IconUsers, path: '/vendors', capability: C.REFERENCE_VENDORS_VIEW },
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
],
},
{
label: 'Transactions',
items: [
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions', capability: C.TRANSACTIONS_VIEW },
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
// Invoices and Payments hidden — see PARKING-LOT.md for future re-enablement
// { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
// { label: 'Payments', icon: IconCash, path: '/payments' },
@@ -89,7 +86,6 @@ const navSections = [
label: 'Reports',
icon: IconChartSankey,
tourId: 'nav-reports',
capability: C.REPORTS_VIEW,
children: [
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
{ label: 'Income Statement', path: '/reports/income-statement' },
@@ -118,15 +114,6 @@ export function Sidebar({ onNavigate }: SidebarProps) {
const organizations = useAuthStore((s) => s.organizations);
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) => {
navigate(path);
onNavigate?.();
@@ -153,13 +140,6 @@ export function Sidebar({ onNavigate }: SidebarProps) {
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 && (
<>
<Divider my="sm" />
@@ -177,10 +157,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
return (
<ScrollArea p="sm" data-tour="sidebar-nav">
{navSections.map((section, sIdx) => {
const visibleItems = section.items.filter((item: any) => hasCapability(item.capability));
if (visibleItems.length === 0) return null;
return (
{navSections.map((section, sIdx) => (
<div key={sIdx}>
{section.label && (
<>
@@ -190,7 +167,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
</Text>
</>
)}
{visibleItems.map((item: any) =>
{section.items.map((item: any) =>
item.children && !item.path ? (
// Collapsible group without a parent route (e.g. Reports)
<NavLink
@@ -246,8 +223,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
),
)}
</div>
);
})}
))}
{user?.isSuperadmin && (
<>
@@ -269,13 +245,6 @@ export function Sidebar({ onNavigate }: SidebarProps) {
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>

View File

@@ -41,7 +41,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
@@ -129,7 +129,7 @@ export function AccountsPage() {
const [showArchived, setShowArchived] = useState(false);
const [transferOpened, { open: openTransfer, close: closeTransfer }] = useDisclosure(false);
const queryClient = useQueryClient();
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT);
const isReadOnly = useIsReadOnly();
// ── Accounts query ──
const { data: accounts = [], isLoading } = useQuery<Account[]>({

View File

@@ -1,780 +0,0 @@
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>
);
}

View File

@@ -12,7 +12,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
interface AssessmentGroup {
id: string;
@@ -79,7 +79,7 @@ export function AssessmentGroupsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
const queryClient = useQueryClient();
const isReadOnly = !useCanEdit(CAPABILITIES.ASSESSMENTS_GROUPS_EDIT);
const isReadOnly = useIsReadOnly();
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
queryKey: ['assessment-groups'],

View File

@@ -11,7 +11,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface PlanLine {
@@ -87,7 +87,7 @@ const statusColors: Record<string, string> = {
export function BudgetPlanningPage() {
const queryClient = useQueryClient();
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_BUDGETS_EDIT);
const isReadOnly = useIsReadOnly();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';

View File

@@ -8,7 +8,7 @@ import { IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, IconArrowRight } f
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
interface BudgetLine {
@@ -40,7 +40,7 @@ export function BudgetsPage() {
const [editData, setEditData] = useState<BudgetLine[] | null>(null); // null = not editing
const queryClient = useQueryClient();
const navigate = useNavigate();
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_BUDGETS_EDIT);
const isReadOnly = useIsReadOnly();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';

View File

@@ -14,7 +14,7 @@ import {
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
// ---------------------------------------------------------------------------
// Types & constants
@@ -252,7 +252,7 @@ export function CapitalProjectsPage() {
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
const printModeRef = useRef(false);
const queryClient = useQueryClient();
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
const isReadOnly = useIsReadOnly();
// ---- Data fetching ----

View File

@@ -21,8 +21,7 @@ import {
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { useHasAnyCapability, CAPABILITIES } from '../../permissions';
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
import api from '../../services/api';
interface HealthScore {
@@ -351,11 +350,7 @@ interface DashboardData {
export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg);
const isReadOnly = !useHasAnyCapability(
CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT,
CAPABILITIES.FINANCIALS_BUDGETS_EDIT,
CAPABILITIES.FINANCIALS_ACTUALS_EDIT,
);
const isReadOnly = useIsReadOnly();
const queryClient = useQueryClient();
const navigate = useNavigate();

View File

@@ -43,7 +43,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
// ── Types ──
@@ -385,7 +385,7 @@ export function InvestmentPlanningPage() {
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
const [newScenarioName, setNewScenarioName] = useState('');
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_INVESTMENTS_EDIT);
const isReadOnly = useIsReadOnly();
// Load investment scenarios for the "Add to Plan" modal
const { data: investmentScenarios } = useQuery<any[]>({

View File

@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
interface Investment {
id: string; name: string; institution: string; account_number_last4: string;
@@ -26,7 +26,7 @@ export function InvestmentsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Investment | null>(null);
const queryClient = useQueryClient();
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_INVESTMENTS_EDIT);
const isReadOnly = useIsReadOnly();
const { data: investments = [], isLoading } = useQuery<Investment[]>({
queryKey: ['investments'],

View File

@@ -9,7 +9,7 @@ import { notifications } from '@mantine/notifications';
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
interface Invoice {
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 [previewLoading, setPreviewLoading] = useState(false);
const queryClient = useQueryClient();
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
const isReadOnly = useIsReadOnly();
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'],

View File

@@ -10,7 +10,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
@@ -69,7 +69,7 @@ export function MonthlyActualsPage() {
const [isEditing, setIsEditing] = useState(false);
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
const queryClient = useQueryClient();
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_ACTUALS_EDIT);
const isReadOnly = useIsReadOnly();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';

View File

@@ -12,10 +12,8 @@ import {
IconShieldCheck, IconInfoCircle,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
import { useCanEdit, useHasCapability, CAPABILITIES } from '../../permissions';
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
interface OrgMember {
id: string;
@@ -31,21 +29,19 @@ interface OrgMember {
const ROLE_OPTIONS = [
{ 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: 'board_member', label: 'Board Member' },
{ value: 'property_manager', label: 'Property Manager' },
{ value: 'viewer', label: 'Viewer (Read-Only)' },
];
const roleColors: Record<string, string> = {
president: 'red',
vice_president: 'grape',
treasurer: 'blue',
secretary: 'green',
member_at_large: 'violet',
manager: 'orange',
board_member: 'violet',
property_manager: 'orange',
viewer: 'gray',
admin: 'red',
};
@@ -56,9 +52,7 @@ export function OrgMembersPage() {
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
const queryClient = useQueryClient();
const { user, currentOrg } = useAuthStore();
const navigate = useNavigate();
const isReadOnly = !useCanEdit(CAPABILITIES.SETTINGS_MEMBERS_MANAGE);
const canManagePermissions = useHasCapability(CAPABILITIES.SETTINGS_PERMISSIONS_MANAGE);
const isReadOnly = useIsReadOnly();
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
queryKey: ['org-members'],
@@ -74,7 +68,7 @@ export function OrgMembersPage() {
firstName: '',
lastName: '',
password: '',
role: 'member_at_large',
role: 'board_member',
},
validate: {
email: (v) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Valid email required'),
@@ -86,7 +80,7 @@ export function OrgMembersPage() {
const editForm = useForm({
initialValues: {
role: 'member_at_large',
role: 'board_member',
},
});
@@ -169,18 +163,11 @@ export function OrgMembersPage() {
<Title order={2}>Organization Members</Title>
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
</div>
<Group>
{canManagePermissions && (
<Button variant="light" leftSection={<IconShieldCheck size={16} />} onClick={() => navigate('/settings/permissions')}>
Role Permissions
</Button>
)}
{!isReadOnly && (
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
Add Member
</Button>
)}
</Group>
{!isReadOnly && (
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
Add Member
</Button>
)}
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}>

View File

@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
interface Payment {
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 [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
const queryClient = useQueryClient();
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
const isReadOnly = useIsReadOnly();
const { data: payments = [], isLoading } = useQuery<Payment[]>({
queryKey: ['payments'],

View File

@@ -12,7 +12,7 @@ import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen, I
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { parseCSV, downloadBlob } from '../../utils/csv';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
// ---------------------------------------------------------------------------
// Types & constants
@@ -79,7 +79,7 @@ export function ProjectsPage() {
const [editing, setEditing] = useState<Project | null>(null);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
const isReadOnly = useIsReadOnly();
// ---- Data fetching ----

View File

@@ -11,7 +11,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
interface ReserveComponent {
id: string; name: string; category: string; description: string;
@@ -27,7 +27,7 @@ export function ReservesPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<ReserveComponent | null>(null);
const queryClient = useQueryClient();
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
const isReadOnly = useIsReadOnly();
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
queryKey: ['reserve-components'],

View File

@@ -1,250 +0,0 @@
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>
);
}

View File

@@ -237,7 +237,7 @@ export function SettingsPage() {
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Version</Text>
<Badge variant="light">2026.4.6</Badge>
<Badge variant="light">2026.4.2</Badge>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">API</Text>

View File

@@ -12,7 +12,7 @@ import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
interface JournalEntryLine {
id?: string;
@@ -49,7 +49,7 @@ export function TransactionsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [viewId, setViewId] = useState<string | null>(null);
const queryClient = useQueryClient();
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
const isReadOnly = useIsReadOnly();
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
queryKey: ['journal-entries'],

View File

@@ -10,7 +10,7 @@ import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload,
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { parseCSV, downloadBlob } from '../../utils/csv';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
interface Unit {
id: string;
@@ -43,7 +43,7 @@ export function UnitsPage() {
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = !useCanEdit(CAPABILITIES.ASSESSMENTS_UNITS_EDIT);
const isReadOnly = useIsReadOnly();
const { data: units = [], isLoading } = useQuery<Unit[]>({
queryKey: ['units'],

View File

@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload, IconUsers, IconBulb, IconRocket } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useCanEdit, CAPABILITIES } from '../../permissions';
import { useIsReadOnly } from '../../stores/authStore';
import { parseCSV, downloadBlob } from '../../utils/csv';
interface Vendor {
@@ -26,7 +26,7 @@ export function VendorsPage() {
const [search, setSearch] = useState('');
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const isReadOnly = !useCanEdit(CAPABILITIES.REFERENCE_VENDORS_EDIT);
const isReadOnly = useIsReadOnly();
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
queryKey: ['vendors'],

View File

@@ -1,22 +0,0 @@
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}</>;
}

View File

@@ -1,131 +0,0 @@
/**
* 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' },
],
},
];

View File

@@ -1,155 +0,0 @@
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,
],
};

View File

@@ -1,7 +0,0 @@
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';

View File

@@ -1,42 +0,0 @@
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;
}

View File

@@ -1,44 +0,0 @@
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);
}

View File

@@ -8,7 +8,6 @@ interface Organization {
status?: string;
planLevel?: string;
settings?: Record<string, any>;
capabilities?: string[];
}
interface User {
@@ -120,7 +119,7 @@ export const useAuthStore = create<AuthState>()(
}),
{
name: 'ledgeriq-auth',
version: 6,
version: 5,
migrate: () => ({
token: null,
user: null,

24
package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "hoa-ledgeriq-tests",
"version": "1.0.0",
"private": true,
"description": "Root package for Playwright E2E & API tests",
"scripts": {
"test:e2e": "npx playwright test",
"test:e2e:chromium": "npx playwright test --project=chromium",
"test:e2e:firefox": "npx playwright test --project=firefox",
"test:e2e:webkit": "npx playwright test --project=webkit",
"test:e2e:api": "npx playwright test --project=api",
"test:e2e:headed": "npx playwright test --headed",
"test:e2e:debug": "npx playwright test --debug",
"test:e2e:ui": "npx playwright test --ui",
"test:e2e:report": "npx playwright show-report",
"test:e2e:update-snapshots": "npx playwright test --update-snapshots"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
"dotenv": "^16.4.0",
"pg": "^8.13.0",
"@types/pg": "^8.11.0"
}
}

127
playwright.config.ts Normal file
View File

@@ -0,0 +1,127 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
/**
* Playwright configuration for HOA LedgerIQ E2E + API regression tests.
*
* Architecture: Docker Compose (nginx :80 -> backend :3000 + frontend :5173)
* - Local dev: `docker-compose up` then `npm run test:e2e`
* - CI: starts Docker services automatically
* - Production: set BASE_URL to skip webServer start
*/
// Load test-specific env from .env.test (falls back to .env)
require('dotenv').config({ path: path.resolve(__dirname, '.env.test') });
const BASE_URL = process.env.BASE_URL || 'http://localhost';
const IS_CI = !!process.env.CI;
// Skip auto-starting services when pointing at an external URL
const isExternalTarget =
BASE_URL !== 'http://localhost' && BASE_URL !== 'http://localhost:80';
export default defineConfig({
testDir: './tests',
testMatch: ['**/*.spec.ts'],
/* Run tests in parallel where safe */
fullyParallel: true,
/* Fail CI builds if test.only was left in */
forbidOnly: IS_CI,
/* Retry on CI to handle transient failures */
retries: IS_CI ? 2 : 0,
/* Limit parallel workers on CI */
workers: IS_CI ? 1 : undefined,
/* Reporter configuration */
reporter: IS_CI
? [['github'], ['html', { open: 'never' }]]
: [['list'], ['html', { open: 'on-failure' }]],
/* Shared settings for all projects */
use: {
baseURL: BASE_URL,
/* Collect trace on first retry for debugging */
trace: 'on-first-retry',
/* Screenshot on failure */
screenshot: 'only-on-failure',
/* Video on failure in CI */
video: IS_CI ? 'on-first-retry' : 'off',
/* Default timeout for actions (click, fill, etc.) */
actionTimeout: 10_000,
/* Navigation timeout */
navigationTimeout: 30_000,
},
/* Global test timeout */
timeout: 60_000,
/* Assertion timeout */
expect: {
timeout: 10_000,
toHaveScreenshot: {
maxDiffPixelRatio: 0.02,
},
},
/* Browser projects */
projects: [
/* Auth setup — runs once, stores auth state for other tests */
{
name: 'auth-setup',
testMatch: /auth\.setup\.ts/,
use: { ...devices['Desktop Chrome'] },
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['auth-setup'],
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['auth-setup'],
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
storageState: 'tests/.auth/user.json',
},
dependencies: ['auth-setup'],
},
/* API tests — no browser needed, runs in chromium for request context */
{
name: 'api',
testMatch: ['**/api/**/*.spec.ts'],
use: { ...devices['Desktop Chrome'] },
dependencies: [],
},
],
/* Start Docker services before tests when running locally */
...(!isExternalTarget && {
webServer: {
command: 'docker-compose up -d && sleep 5 && docker-compose exec backend echo "ready"',
url: BASE_URL,
reuseExistingServer: !IS_CI,
timeout: 120_000,
stdout: 'pipe',
stderr: 'pipe',
},
}),
});

View File

@@ -21,7 +21,6 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BACKUP_DIR="$PROJECT_DIR/backups"
KEEP_DAYS=0 # 0 = keep forever
FORCE_YES=false # skip interactive confirmations (for automation)
DB_USER="${POSTGRES_USER:-hoafinance}"
DB_NAME="${POSTGRES_DB:-hoafinance}"
COMPOSE_CMD="docker compose"
@@ -52,9 +51,9 @@ ensure_postgres_running() {
format_size() {
local bytes=$1
if (( bytes >= 1073741824 )); then printf "%d.%d GB" $((bytes / 1073741824)) $(( (bytes % 1073741824) * 10 / 1073741824 ))
elif (( bytes >= 1048576 )); then printf "%d.%d MB" $((bytes / 1048576)) $(( (bytes % 1048576) * 10 / 1048576 ))
elif (( bytes >= 1024 )); then printf "%d.%d KB" $((bytes / 1024)) $(( (bytes % 1024) * 10 / 1024 ))
if (( bytes >= 1073741824 )); then printf "%.1f GB" "$(echo "$bytes / 1073741824" | bc -l)"
elif (( bytes >= 1048576 )); then printf "%.1f MB" "$(echo "$bytes / 1048576" | bc -l)"
elif (( bytes >= 1024 )); then printf "%.1f KB" "$(echo "$bytes / 1024" | bc -l)"
else printf "%d B" "$bytes"
fi
}
@@ -122,12 +121,8 @@ do_restore() {
warn "This will DESTROY the current '${DB_NAME}' database and replace it"
warn "with the contents of: $(basename "$file")"
echo ""
if [ "$FORCE_YES" = true ]; then
info "Skipping confirmation (--yes flag set)"
else
read -rp "Type 'yes' to continue: " confirm
[ "$confirm" = "yes" ] || { info "Aborted."; exit 0; }
fi
read -rp "Type 'yes' to continue: " confirm
[ "$confirm" = "yes" ] || { info "Aborted."; exit 0; }
echo ""
info "Step 1/4 — Terminating active connections ..."
@@ -234,7 +229,6 @@ Usage:
Options:
--dir DIR Backup directory (default: ./backups)
--keep DAYS Auto-delete backups older than DAYS (default: keep all)
--yes, -y Skip interactive confirmation prompts (for automation)
Supported restore formats:
.dump.gz Custom-format pg_dump, gzipped (default backup format)
@@ -261,7 +255,6 @@ while [ $# -gt 0 ]; do
case "$1" in
--dir) BACKUP_DIR="$2"; shift 2 ;;
--keep) KEEP_DAYS="$2"; shift 2 ;;
--yes|-y) FORCE_YES=true; shift ;;
--help) usage ;;
*)
if [ "$COMMAND" = "restore" ] && [ -z "$RESTORE_FILE" ]; then

View File

@@ -1,425 +0,0 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# deploy-prod.sh — Production deployment script for HOA LedgerIQ
#
# Usage:
# ./scripts/deploy-prod.sh [--seed-existing]
#
# This script performs a full production deployment:
# 1. Takes a pre-upgrade database backup
# 2. Pulls the latest code from the main branch
# 3. Rebuilds and restarts Docker containers
# 4. Runs any pending database migrations (tracked in shared.schema_migrations)
# 5. Verifies the application is healthy
# 6. Takes a post-upgrade database backup
#
# On failure (migration error or health check), the script automatically:
# - Restores the pre-upgrade database backup
# - Reverts the code to the previous commit
# - Rebuilds containers from the reverted code
#
# Flags:
# --seed-existing Mark all existing migration files as applied without
# executing them. Use this ONLY on the first deployment
# against an existing database where migrations were
# previously applied manually.
#
# Environment:
# PROJECT_DIR Override the project directory (default: /opt/hoa-ledgeriq)
# POSTGRES_USER Database user (default: hoafinance)
# POSTGRES_DB Database name (default: hoafinance)
# ---------------------------------------------------------------------------
set -euo pipefail
# ---- Defaults ----
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="${PROJECT_DIR:-/opt/hoa-ledgeriq}"
COMPOSE_CMD="docker compose -f $PROJECT_DIR/docker-compose.yml -f $PROJECT_DIR/docker-compose.prod.yml"
DB_USER="${POSTGRES_USER:-hoafinance}"
DB_NAME="${POSTGRES_DB:-hoafinance}"
MIGRATION_DIR="$PROJECT_DIR/db/migrations"
HEALTH_URL="http://localhost:3000/api"
HEALTH_RETRIES=20
HEALTH_INTERVAL=5
HEALTH_START_WAIT=30
LOG_DIR="$PROJECT_DIR/logs"
LOG_FILE="$LOG_DIR/deploy-$(date +%Y%m%d_%H%M%S).log"
# State tracking
SEED_EXISTING=false
PREV_COMMIT=""
BACKUP_FILE=""
ROLLBACK_NEEDED=false
DEPLOY_SUCCESS=false
DEPLOY_START_TIME=""
# ---- Colors ----
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
# ---- Logging ----
log() { echo -e "$(date -Iseconds) ${CYAN}[DEPLOY]${NC} $*"; }
ok() { echo -e "$(date -Iseconds) ${GREEN}[OK]${NC} $*"; }
warn() { echo -e "$(date -Iseconds) ${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "$(date -Iseconds) ${RED}[ERROR]${NC} $*" >&2; }
die() { err "$@"; exit 1; }
# ---- Parse flags ----
while [ $# -gt 0 ]; do
case "$1" in
--seed-existing) SEED_EXISTING=true; shift ;;
--help|-h)
head -35 "$0" | tail -33
exit 0
;;
*) die "Unknown argument: $1" ;;
esac
done
# ---- Setup logging ----
mkdir -p "$LOG_DIR"
exec > >(tee -a "$LOG_FILE") 2>&1
# ---- Cleanup / Rollback trap ----
cleanup() {
if [ "$DEPLOY_SUCCESS" = true ]; then
return 0
fi
if [ "$ROLLBACK_NEEDED" = true ] && [ -n "$BACKUP_FILE" ]; then
echo ""
err "=========================================="
err " DEPLOYMENT FAILED — STARTING ROLLBACK"
err "=========================================="
echo ""
# Step 1: Restore the pre-upgrade database backup
log "Restoring database from pre-upgrade backup: $(basename "$BACKUP_FILE")"
if "$SCRIPT_DIR/db-backup.sh" restore --yes "$BACKUP_FILE"; then
ok "Database restored successfully"
else
err "DATABASE RESTORE FAILED — manual intervention required!"
err "Backup file: $BACKUP_FILE"
exit 1
fi
# Step 2: Revert code to previous commit
if [ -n "$PREV_COMMIT" ]; then
log "Reverting code to previous commit: $PREV_COMMIT"
cd "$PROJECT_DIR"
git reset --hard "$PREV_COMMIT"
ok "Code reverted to $PREV_COMMIT"
fi
# Step 3: Rebuild containers from old code
log "Rebuilding containers from reverted code ..."
cd "$PROJECT_DIR"
$COMPOSE_CMD up -d --build
ok "Containers rebuilt from previous version"
echo ""
err "Rollback complete. The system is restored to the pre-deployment state."
err "Review the deploy log for details: $LOG_FILE"
exit 1
elif [ "$ROLLBACK_NEEDED" = true ]; then
err "Rollback needed but no backup file available — manual intervention required!"
exit 1
fi
}
trap cleanup EXIT
# ====================================================================
# STEP 1: Pre-flight checks
# ====================================================================
log "============================================"
log " HOA LedgerIQ — Production Deployment"
log "============================================"
log "Project directory: $PROJECT_DIR"
log "Timestamp: $(date -Iseconds)"
DEPLOY_START_TIME=$(date +%s)
echo ""
cd "$PROJECT_DIR"
# Verify prerequisites
command -v git >/dev/null 2>&1 || die "git is not installed"
command -v docker >/dev/null 2>&1 || die "docker is not installed"
docker compose version >/dev/null 2>&1 || die "docker compose is not available"
# Verify we're in a git repo
[ -d ".git" ] || die "$PROJECT_DIR is not a git repository"
# Verify postgres is running
if ! $COMPOSE_CMD ps postgres 2>/dev/null | grep -q "running\|Up"; then
die "PostgreSQL container is not running. Start it with: $COMPOSE_CMD up -d postgres"
fi
# Store current commit for rollback
PREV_COMMIT=$(git rev-parse HEAD)
log "Current commit: $PREV_COMMIT"
# ====================================================================
# STEP 2: Pre-upgrade database backup
# ====================================================================
echo ""
log "--- Step 1/6: Pre-upgrade database backup ---"
BACKUP_OUTPUT=$("$SCRIPT_DIR/db-backup.sh" backup 2>&1)
echo "$BACKUP_OUTPUT"
# Extract the backup file path from the output (strip ANSI color codes first)
BACKUP_FILE=$(echo "$BACKUP_OUTPUT" | sed 's/\x1b\[[0-9;]*m//g' | grep -oP 'Backup complete: \K\S+' || true)
if [ -z "$BACKUP_FILE" ]; then
die "Failed to capture backup file path from db-backup.sh output"
fi
if [ ! -f "$BACKUP_FILE" ]; then
die "Backup file does not exist: $BACKUP_FILE"
fi
ok "Pre-upgrade backup saved: $(basename "$BACKUP_FILE")"
# From this point forward, rollback is possible
ROLLBACK_NEEDED=true
# ====================================================================
# STEP 3: Pull latest code
# ====================================================================
echo ""
log "--- Step 2/6: Pulling latest code from main ---"
git fetch origin main
git reset --hard origin/main
NEW_COMMIT=$(git rev-parse HEAD)
log "Updated to commit: $NEW_COMMIT"
if [ "$PREV_COMMIT" = "$NEW_COMMIT" ]; then
warn "No new commits — continuing anyway (migrations or rebuilds may still be needed)"
fi
# ====================================================================
# STEP 4: Rebuild and restart containers
# ====================================================================
echo ""
log "--- Step 3/6: Rebuilding and restarting containers ---"
$COMPOSE_CMD up -d --build
# Wait for postgres to be healthy before running migrations
log "Waiting for PostgreSQL to be healthy ..."
PG_RETRIES=30
PG_COUNT=0
while [ $PG_COUNT -lt $PG_RETRIES ]; do
if $COMPOSE_CMD exec -T postgres pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then
ok "PostgreSQL is ready"
break
fi
((PG_COUNT++))
if [ $PG_COUNT -eq $PG_RETRIES ]; then
die "PostgreSQL did not become healthy after $((PG_RETRIES * 2))s"
fi
sleep 2
done
# ====================================================================
# STEP 5: Run database migrations
# ====================================================================
echo ""
log "--- Step 4/6: Running database migrations ---"
# Helper: run SQL via psql in the postgres container
run_sql() {
$COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 --quiet "$@"
}
# Step 5a: Ensure the migration tracking table exists
log "Ensuring shared.schema_migrations table exists ..."
run_sql <<'SQL'
CREATE SCHEMA IF NOT EXISTS shared;
CREATE TABLE IF NOT EXISTS shared.schema_migrations (
id SERIAL PRIMARY KEY,
filename TEXT NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
checksum TEXT
);
SQL
ok "Migration tracking table ready"
# Helper: check if a migration has been applied (safe with set -u)
is_applied() {
local key="$1"
# Use a subshell test to avoid unbound variable with set -u on empty associative arrays
[[ -n "${APPLIED_MIGRATIONS[$key]:-}" ]]
}
# Step 5b: Get list of already-applied migrations
declare -A APPLIED_MIGRATIONS=()
while IFS= read -r fname; do
fname=$(echo "$fname" | xargs) # trim whitespace
[ -n "$fname" ] && APPLIED_MIGRATIONS["$fname"]=1
done < <(run_sql -t -c "SELECT filename FROM shared.schema_migrations ORDER BY filename;" 2>/dev/null || true)
APPLIED_COUNT=${#APPLIED_MIGRATIONS[@]}
log "Previously applied migrations: $APPLIED_COUNT"
# Step 5c: Scan migration directory for .sql files
MIGRATION_FILES=()
if [ -d "$MIGRATION_DIR" ]; then
while IFS= read -r f; do
MIGRATION_FILES+=("$(basename "$f")")
done < <(find "$MIGRATION_DIR" -name "*.sql" -type f | sort)
fi
TOTAL_MIGRATIONS=${#MIGRATION_FILES[@]}
log "Total migration files found: $TOTAL_MIGRATIONS"
# Step 5d: Handle --seed-existing (first deployment only)
if [ "$SEED_EXISTING" = true ]; then
if [ "$APPLIED_COUNT" -gt 0 ]; then
warn "--seed-existing flag set but $APPLIED_COUNT migrations are already tracked. Skipping seed."
else
log "Seeding migration tracking table with ${TOTAL_MIGRATIONS} existing migration files ..."
for filename in "${MIGRATION_FILES[@]}"; do
checksum=$(md5sum "$MIGRATION_DIR/$filename" | awk '{print $1}')
run_sql -c "INSERT INTO shared.schema_migrations (filename, checksum) VALUES ('$filename', '$checksum') ON CONFLICT (filename) DO NOTHING;"
log " Seeded: $filename"
done
ok "All existing migrations marked as applied (not executed)"
# Refresh the applied list
APPLIED_COUNT=$TOTAL_MIGRATIONS
for filename in "${MIGRATION_FILES[@]}"; do
APPLIED_MIGRATIONS["$filename"]=1
done
fi
fi
# Step 5e: Detect first-run without --seed-existing
if [ "$APPLIED_COUNT" -eq 0 ] && [ "$TOTAL_MIGRATIONS" -gt 0 ] && [ "$SEED_EXISTING" = false ]; then
warn "The migration tracking table is empty but $TOTAL_MIGRATIONS migration files exist."
warn "If these migrations were previously applied manually, re-run with --seed-existing"
warn "to register them without re-executing. Otherwise, all migrations will be applied."
warn ""
warn "Continuing in 10 seconds ... (Ctrl+C to abort)"
sleep 10
fi
# Step 5f: Apply pending migrations
PENDING_COUNT=0
APPLIED_THIS_RUN=0
for filename in "${MIGRATION_FILES[@]}"; do
if is_applied "$filename"; then
continue
fi
((PENDING_COUNT++))
done
if [ "$PENDING_COUNT" -eq 0 ]; then
ok "No pending migrations to apply"
else
log "$PENDING_COUNT pending migration(s) to apply"
echo ""
for filename in "${MIGRATION_FILES[@]}"; do
if is_applied "$filename"; then
continue
fi
checksum=$(md5sum "$MIGRATION_DIR/$filename" | awk '{print $1}')
log " Applying: $filename ..."
# Run the migration in a single transaction with error stopping
if cat "$MIGRATION_DIR/$filename" | $COMPOSE_CMD exec -T postgres psql \
-U "$DB_USER" \
-d "$DB_NAME" \
-v ON_ERROR_STOP=1 \
--single-transaction \
--quiet 2>&1; then
# Record successful migration
run_sql -c "INSERT INTO shared.schema_migrations (filename, checksum) VALUES ('$filename', '$checksum');"
ok " Applied: $filename"
((APPLIED_THIS_RUN++))
else
err "Migration FAILED: $filename"
err "Triggering automatic rollback ..."
exit 1 # trap will handle rollback
fi
done
echo ""
ok "Successfully applied $APPLIED_THIS_RUN migration(s)"
fi
# ====================================================================
# STEP 6: Health check
# ====================================================================
echo ""
log "--- Step 5/6: Verifying application health ---"
log "Waiting ${HEALTH_START_WAIT}s for backend to initialize (matches Docker start_period) ..."
sleep "$HEALTH_START_WAIT"
# Primary check: Docker's own container health status
# (docker-compose.prod.yml already defines a healthcheck using wget inside the container)
HEALTHY=false
for ((i=1; i<=HEALTH_RETRIES; i++)); do
CONTAINER_HEALTH=$($COMPOSE_CMD ps backend --format '{{.Health}}' 2>/dev/null || echo "unknown")
if [ "$CONTAINER_HEALTH" = "healthy" ]; then
HEALTHY=true
break
fi
# Also try a direct HTTP check from the host as a secondary signal
# Use wget (available on Ubuntu) since curl may not be installed
if wget -qO- --timeout=5 "$HEALTH_URL" >/dev/null 2>&1; then
HEALTHY=true
break
fi
log " Health check attempt $i/$HEALTH_RETRIES — container status: ${CONTAINER_HEALTH}, retrying in ${HEALTH_INTERVAL}s ..."
sleep "$HEALTH_INTERVAL"
done
if [ "$HEALTHY" = true ]; then
ok "Backend is healthy and responding"
else
# Log diagnostics before triggering rollback
err "Backend failed to respond after $((HEALTH_START_WAIT + HEALTH_RETRIES * HEALTH_INTERVAL))s"
warn "Container status: $($COMPOSE_CMD ps backend 2>/dev/null || echo 'unknown')"
warn "Recent backend logs:"
$COMPOSE_CMD logs --tail=20 backend 2>/dev/null || true
err "Triggering automatic rollback ..."
exit 1 # trap will handle rollback
fi
# ====================================================================
# STEP 7: Post-upgrade database backup
# ====================================================================
echo ""
log "--- Step 6/6: Post-upgrade database backup ---"
"$SCRIPT_DIR/db-backup.sh" backup
# ====================================================================
# Deployment complete
# ====================================================================
DEPLOY_SUCCESS=true
ROLLBACK_NEEDED=false
DEPLOY_END_TIME=$(date +%s)
DEPLOY_DURATION=$((DEPLOY_END_TIME - DEPLOY_START_TIME))
echo ""
log "============================================"
ok " DEPLOYMENT COMPLETE"
log "============================================"
log " Previous commit : $PREV_COMMIT"
log " Current commit : $NEW_COMMIT"
log " Migrations run : $APPLIED_THIS_RUN"
log " Duration : ${DEPLOY_DURATION}s"
log " Log file : $LOG_FILE"
log "============================================"
echo ""

View File

@@ -0,0 +1,140 @@
/**
* API regression tests for the Accounts endpoint.
*
* Tests CRUD operations on /api/accounts.
* Requires authentication — logs in first to get a Bearer token.
*/
import { test, expect } from '@playwright/test';
import { apiLogin, apiSwitchOrg, authHeaders } from '../fixtures/auth.fixture';
import { TEST_USERS, TEST_PREFIX } from '../fixtures/test-data';
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
let accessToken: string;
test.beforeAll(async ({ request }) => {
// Login and switch to test org
const tokens = await apiLogin(request, TEST_USERS.treasurer);
if (tokens.organizations?.length > 0) {
const orgId = (tokens.organizations[0] as any).id;
try {
const switched = await apiSwitchOrg(request, tokens.accessToken, orgId);
accessToken = switched.accessToken;
} catch {
accessToken = tokens.accessToken;
}
} else {
accessToken = tokens.accessToken;
}
});
test.describe('GET /api/accounts', () => {
test('should return accounts list', async ({ request }) => {
const response = await request.get(`${API_BASE}/accounts`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
const accounts = await response.json();
expect(Array.isArray(accounts)).toBe(true);
});
test('should return 401 without auth', async ({ request }) => {
const response = await request.get(`${API_BASE}/accounts`);
expect(response.status()).toBe(401);
});
test('should filter by fund type', async ({ request }) => {
const response = await request.get(`${API_BASE}/accounts?fundType=operating`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
const accounts = await response.json();
expect(Array.isArray(accounts)).toBe(true);
});
});
test.describe('Accounts CRUD', () => {
let createdAccountId: string;
test('should create a new account', async ({ request }) => {
const response = await request.post(`${API_BASE}/accounts`, {
headers: authHeaders(accessToken),
data: {
name: `${TEST_PREFIX}Operating Checking`,
number: '1099',
accountType: 'asset',
fundType: 'operating',
description: 'E2E test account — safe to delete',
},
});
expect(response.status()).toBe(201);
const account = await response.json();
expect(account).toHaveProperty('id');
expect(account.name).toBe(`${TEST_PREFIX}Operating Checking`);
createdAccountId = account.id;
});
test('should get the created account', async ({ request }) => {
test.skip(!createdAccountId, 'No account was created');
const response = await request.get(`${API_BASE}/accounts/${createdAccountId}`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
const account = await response.json();
expect(account.id).toBe(createdAccountId);
expect(account.name).toBe(`${TEST_PREFIX}Operating Checking`);
});
test('should update the account', async ({ request }) => {
test.skip(!createdAccountId, 'No account was created');
const response = await request.put(`${API_BASE}/accounts/${createdAccountId}`, {
headers: authHeaders(accessToken),
data: {
name: `${TEST_PREFIX}Updated Checking`,
description: 'Updated by E2E test',
},
});
expect(response.status()).toBe(200);
const account = await response.json();
expect(account.name).toBe(`${TEST_PREFIX}Updated Checking`);
});
test('should appear in accounts list', async ({ request }) => {
test.skip(!createdAccountId, 'No account was created');
const response = await request.get(`${API_BASE}/accounts`, {
headers: authHeaders(accessToken),
});
const accounts = await response.json();
const found = accounts.find((a: any) => a.id === createdAccountId);
expect(found).toBeTruthy();
expect(found.name).toBe(`${TEST_PREFIX}Updated Checking`);
});
});
test.describe('GET /api/accounts/trial-balance', () => {
test('should return trial balance data', async ({ request }) => {
const response = await request.get(`${API_BASE}/accounts/trial-balance`, {
headers: authHeaders(accessToken),
});
expect(response.status()).toBe(200);
const data = await response.json();
// Trial balance returns an object or array with debit/credit totals
expect(data).toBeDefined();
});
});

124
tests/api/auth.api.spec.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* API regression tests for authentication endpoints.
*
* Tests the NestJS auth controller directly via HTTP.
* No browser needed — uses Playwright's request context.
*/
import { test, expect } from '@playwright/test';
import { TEST_USERS } from '../fixtures/test-data';
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
test.describe('POST /api/auth/login', () => {
test('should return access token for valid credentials', async ({ request }) => {
const response = await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: TEST_USERS.treasurer.password,
},
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body).toHaveProperty('accessToken');
expect(body).toHaveProperty('user');
expect(body.user).toHaveProperty('email', TEST_USERS.treasurer.email);
expect(body).toHaveProperty('organizations');
expect(Array.isArray(body.organizations)).toBe(true);
});
test('should return 401 for invalid password', async ({ request }) => {
const response = await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: 'WrongPassword123!',
},
});
expect(response.status()).toBe(401);
});
test('should return 401 for non-existent user', async ({ request }) => {
const response = await request.post(`${API_BASE}/auth/login`, {
data: {
email: 'nonexistent@example.com',
password: 'AnyPassword123!',
},
});
expect(response.status()).toBe(401);
});
test('should set httpOnly refresh cookie on success', async ({ request }) => {
const response = await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: TEST_USERS.treasurer.password,
},
});
expect(response.status()).toBe(201);
// Check for Set-Cookie header with the refresh token
const setCookie = response.headers()['set-cookie'] || '';
expect(setCookie).toContain('ledgeriq_rt');
expect(setCookie).toContain('HttpOnly');
});
});
test.describe('GET /api/auth/profile', () => {
test('should return 401 without auth header', async ({ request }) => {
const response = await request.get(`${API_BASE}/auth/profile`);
expect(response.status()).toBe(401);
});
test('should return user profile with valid token', async ({ request }) => {
// Login first
const loginResponse = await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: TEST_USERS.treasurer.password,
},
});
const { accessToken } = await loginResponse.json();
// Fetch profile
const response = await request.get(`${API_BASE}/auth/profile`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(response.status()).toBe(200);
const profile = await response.json();
expect(profile).toHaveProperty('email', TEST_USERS.treasurer.email);
});
});
test.describe('POST /api/auth/logout', () => {
test('should return success and clear cookie', async ({ request }) => {
// Login first to get the cookie
await request.post(`${API_BASE}/auth/login`, {
data: {
email: TEST_USERS.treasurer.email,
password: TEST_USERS.treasurer.password,
},
});
// Logout
const response = await request.post(`${API_BASE}/auth/logout`);
expect(response.status()).toBe(200);
const body = await response.json();
expect(body).toEqual({ success: true });
});
});
test.describe('POST /api/auth/refresh', () => {
test('should return 400 without refresh cookie', async ({ request }) => {
// Create a fresh context without cookies
const response = await request.post(`${API_BASE}/auth/refresh`);
// Should fail — no refresh token cookie
expect([400, 401]).toContain(response.status());
});
});

50
tests/auth.setup.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Auth setup project — runs ONCE before all browser-based tests.
*
* Logs in via the UI, then saves the authenticated browser state
* (cookies + localStorage) to tests/.auth/user.json so that every
* subsequent test starts already logged in.
*
* This matches Playwright's recommended "global setup via project
* dependencies" pattern.
*/
import { test as setup, expect } from '@playwright/test';
import { TEST_USERS } from './fixtures/test-data';
import path from 'path';
const authFile = path.join(__dirname, '.auth', 'user.json');
setup('authenticate as test treasurer', async ({ page }) => {
// Navigate to login page
await page.goto('/login');
// Fill login form — uses Mantine component labels
await page.getByLabel('Email').fill(TEST_USERS.treasurer.email);
await page.getByLabel('Password').fill(TEST_USERS.treasurer.password);
// Submit
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for redirect away from login page.
// After login, the app redirects to /select-org (multi-org) or /dashboard.
await page.waitForURL(/\/(select-org|dashboard|admin|onboarding)/, {
timeout: 15_000,
});
// If we land on org selection, pick the first org
if (page.url().includes('/select-org')) {
// Click the first organization card/button
const orgButton = page.getByRole('button').first();
if (await orgButton.isVisible({ timeout: 3_000 }).catch(() => false)) {
await orgButton.click();
await page.waitForURL(/\/dashboard/, { timeout: 10_000 });
}
}
// Verify we're authenticated — page should not be on /login
expect(page.url()).not.toContain('/login');
// Save authenticated state
await page.context().storageState({ path: authFile });
});

97
tests/e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* E2E tests for the authentication flow.
*
* Tests login via UI (not reusing stored auth state).
* These tests use a fresh browser context per test.
*/
import { test, expect } from '@playwright/test';
import { LoginPage } from '../page-objects';
import { TEST_USERS } from '../fixtures/test-data';
// Don't use stored auth state — we're testing the login flow itself
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('Login Page', () => {
let loginPage: LoginPage;
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page);
await loginPage.goto();
});
test('should display login form', async () => {
await expect(loginPage.emailInput).toBeVisible();
await expect(loginPage.passwordInput).toBeVisible();
await expect(loginPage.signInButton).toBeVisible();
});
test('should show error for invalid credentials', async () => {
await loginPage.login('invalid@example.com', 'WrongPassword!');
// Wait for the error alert to appear
await loginPage.assertError(/invalid|unauthorized|failed/i);
await loginPage.assertStillOnLogin();
});
test('should show validation error for empty fields', async ({ page }) => {
// Try to submit with empty password
await loginPage.emailInput.fill('test@example.com');
await loginPage.signInButton.click();
// Should remain on login page (Mantine form validation)
await loginPage.assertStillOnLogin();
});
test('should login successfully with valid credentials', async () => {
await loginPage.loginAndWaitForRedirect(
TEST_USERS.treasurer.email,
TEST_USERS.treasurer.password,
);
// Should be redirected away from login
await expect(loginPage.page).not.toHaveURL(/\/login/);
});
test('should show register link', async () => {
await expect(loginPage.registerLink).toBeVisible();
});
test('should redirect unauthenticated users to login', async ({ page }) => {
// Try accessing a protected route directly
await page.goto('/dashboard');
// Should be redirected to login
await expect(page).toHaveURL(/\/login/);
});
});
test.describe('Logout', () => {
test('should clear auth state on logout', async ({ page }) => {
// First login
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.loginAndWaitForRedirect(
TEST_USERS.treasurer.email,
TEST_USERS.treasurer.password,
);
// Handle org selection if needed
if (page.url().includes('/select-org')) {
await page.getByRole('button').first().click();
await page.waitForURL(/\/dashboard/, { timeout: 10_000 });
}
// Look for logout in user menu/settings
const userMenu = page.locator('[class*="avatar"], [class*="Avatar"]').first();
if (await userMenu.isVisible({ timeout: 3_000 }).catch(() => false)) {
await userMenu.click();
}
const logoutButton = page.getByRole('button', { name: /logout|sign out/i }).first();
if (await logoutButton.isVisible({ timeout: 3_000 }).catch(() => false)) {
await logoutButton.click();
await expect(page).toHaveURL(/\/login/);
}
});
});

View File

@@ -0,0 +1,76 @@
/**
* E2E tests for the Dashboard page.
*
* Uses pre-authenticated state from auth.setup.ts.
* Verifies dashboard loads, displays data, and navigation works.
*/
import { test, expect } from '../fixtures/base.fixture';
import { DashboardPage } from '../page-objects';
test.describe('Dashboard', () => {
let dashboard: DashboardPage;
test.beforeEach(async ({ page }) => {
dashboard = new DashboardPage(page);
await dashboard.goto();
});
test('should load the dashboard page', async ({ page }) => {
await dashboard.assertLoaded();
await expect(page).toHaveURL(/\/dashboard/);
});
test('should display main content area', async ({ page }) => {
// Dashboard should have a visible main content area
await expect(page.locator('main')).toBeVisible();
});
test('should have sidebar navigation', async ({ page }) => {
// Verify key navigation links are present
const nav = page.locator('nav').first();
if (await nav.isVisible({ timeout: 5_000 }).catch(() => false)) {
await expect(nav).toBeVisible();
// Check for common navigation items
const links = ['Accounts', 'Transactions', 'Budgets'];
for (const linkName of links) {
const link = nav.getByRole('link', { name: new RegExp(linkName, 'i') });
if (await link.isVisible({ timeout: 2_000 }).catch(() => false)) {
await expect(link).toBeVisible();
}
}
}
});
test('should navigate to accounts page', async ({ page }) => {
const accountsLink = page.getByRole('link', { name: /accounts/i }).first();
if (await accountsLink.isVisible({ timeout: 5_000 }).catch(() => false)) {
await accountsLink.click();
await page.waitForLoadState('networkidle');
await expect(page).toHaveURL(/\/accounts/);
}
});
});
test.describe('Dashboard with DB verification', () => {
test('should reflect database state', async ({ page, db }) => {
// Query the database to get expected account count
const result = await db.query(
`SELECT COUNT(*) as count FROM e2e_test_hoa.accounts`,
).catch(() => ({ rows: [{ count: '0' }] }));
const expectedCount = parseInt(result.rows[0].count, 10);
// Navigate to dashboard
const dashboard = new DashboardPage(page);
await dashboard.goto();
await dashboard.assertLoaded();
// If accounts exist, the dashboard should show some financial data
if (expectedCount > 0) {
// Dashboard should contain some numeric content indicating balances
await expect(page.locator('main')).not.toBeEmpty();
}
});
});

53
tests/e2e/visual.spec.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* Visual regression tests using Playwright's toHaveScreenshot().
*
* These capture pixel-level snapshots and compare against baselines.
* First run creates the baseline images in tests/e2e/visual.spec.ts-snapshots/.
*
* Update baselines: npx playwright test tests/e2e/visual.spec.ts --update-snapshots
*/
import { test, expect } from '@playwright/test';
import { LoginPage } from '../page-objects';
// Use a clean context for login page screenshots
test.use({ storageState: { cookies: [], origins: [] } });
test.describe('Visual Regression', () => {
test('login page should match baseline', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.waitForReady();
// Wait for any animations to settle
await page.waitForTimeout(500);
await expect(page).toHaveScreenshot('login-page.png', {
fullPage: true,
// Mask dynamic content that changes between runs
mask: [
// Mask any CSRF tokens or dynamic ids in the DOM
page.locator('input[type="hidden"]'),
],
});
});
});
test.describe('Authenticated Visual Regression', () => {
test('dashboard should match baseline', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
// Wait for charts/data to render
await page.waitForTimeout(1_000);
await expect(page).toHaveScreenshot('dashboard.png', {
fullPage: true,
// Mask dates and dynamic numbers that change between runs
mask: [
page.locator('time'),
page.locator('[data-testid="current-date"]'),
],
});
});
});

108
tests/fixtures/auth.fixture.ts vendored Normal file
View File

@@ -0,0 +1,108 @@
/**
* Authentication fixture for E2E tests.
*
* Provides helpers to authenticate via the API and manage JWT tokens.
* The auth.setup.ts project uses this to create persistent auth state
* that other tests reuse (avoiding login in every test).
*/
import { type APIRequestContext, type BrowserContext } from '@playwright/test';
import { TEST_USERS } from './test-data';
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
export interface AuthTokens {
accessToken: string;
user: Record<string, unknown>;
organizations: Array<Record<string, unknown>>;
}
/**
* Login via the API and return tokens.
* Uses Playwright's request context (no browser needed).
*/
export async function apiLogin(
request: APIRequestContext,
credentials: { email: string; password: string } = TEST_USERS.treasurer,
): Promise<AuthTokens> {
const response = await request.post(`${API_BASE}/auth/login`, {
data: {
email: credentials.email,
password: credentials.password,
},
});
if (!response.ok()) {
const body = await response.text();
throw new Error(`Login failed (${response.status()}): ${body}`);
}
return response.json();
}
/**
* Switch to a specific organization after login.
*/
export async function apiSwitchOrg(
request: APIRequestContext,
accessToken: string,
organizationId: string,
): Promise<AuthTokens> {
const response = await request.post(`${API_BASE}/auth/switch-org`, {
data: { organizationId },
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok()) {
const body = await response.text();
throw new Error(`Switch org failed (${response.status()}): ${body}`);
}
return response.json();
}
/**
* Create an authenticated API request context with a Bearer token.
* Useful for API-level tests that don't need a browser.
*/
export function authHeaders(accessToken: string) {
return {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
};
}
/**
* Inject auth state into a browser context's localStorage.
* Matches the frontend's Zustand authStore shape.
*/
export async function injectAuthState(
context: BrowserContext,
tokens: AuthTokens,
orgId?: string,
): Promise<void> {
const baseURL = process.env.BASE_URL || 'http://localhost';
// The frontend uses Zustand with persist middleware in localStorage
// under key 'auth-storage'. We inject it so the frontend thinks
// the user is already logged in.
const selectedOrg = orgId
? tokens.organizations.find((o: any) => o.id === orgId)
: tokens.organizations[0];
const authState = {
state: {
token: tokens.accessToken,
user: tokens.user,
organizations: tokens.organizations,
currentOrg: selectedOrg
? { id: (selectedOrg as any).id, name: (selectedOrg as any).name, role: (selectedOrg as any).role }
: null,
},
version: 0,
};
await context.addInitScript((authData) => {
window.localStorage.setItem('auth-storage', JSON.stringify(authData));
}, authState);
}

87
tests/fixtures/base.fixture.ts vendored Normal file
View File

@@ -0,0 +1,87 @@
/**
* Base Playwright fixture that combines auth + DB helpers.
*
* Extends the default `test` object so every test file can
* import from here and get typed access to fixtures.
*
* Usage:
* import { test, expect } from '../fixtures/base.fixture';
* test('my test', async ({ authedPage, apiContext }) => { ... });
*/
import { test as base, type Page, type APIRequestContext } from '@playwright/test';
import { apiLogin, apiSwitchOrg, authHeaders, type AuthTokens } from './auth.fixture';
import { testDb } from './db.fixture';
import { TEST_USERS } from './test-data';
const API_BASE = process.env.API_BASE_URL || 'http://localhost/api';
type TestFixtures = {
/** Pre-authenticated API request context with Bearer token */
authedRequest: APIRequestContext & { _tokens: AuthTokens };
/** Access token string for manual header construction */
accessToken: string;
};
type WorkerFixtures = {
/** Shared database connection (one per worker) */
db: typeof testDb;
};
export const test = base.extend<TestFixtures, WorkerFixtures>({
/**
* Worker-scoped database connection.
* Connects once per worker, disconnects when the worker exits.
*/
db: [
async ({}, use) => {
await testDb.connect();
await use(testDb);
await testDb.disconnect();
},
{ scope: 'worker' },
],
/**
* Per-test authenticated API request context.
* Logs in as the default test user and attaches the Bearer token.
*/
authedRequest: async ({ playwright }, use) => {
const requestContext = await playwright.request.newContext({
baseURL: API_BASE,
});
const tokens = await apiLogin(requestContext, TEST_USERS.treasurer);
// If user belongs to orgs, switch to the first one
let finalTokens = tokens;
if (tokens.organizations?.length > 0) {
const orgId = (tokens.organizations[0] as any).id;
try {
finalTokens = await apiSwitchOrg(requestContext, tokens.accessToken, orgId);
} catch {
// switch-org may not be needed if token already scoped
finalTokens = tokens;
}
}
// Create a new context with the auth header baked in
const authedContext = await playwright.request.newContext({
baseURL: API_BASE,
extraHTTPHeaders: authHeaders(finalTokens.accessToken),
});
// Attach tokens for tests that need them
(authedContext as any)._tokens = finalTokens;
await use(authedContext as any);
await authedContext.dispose();
await requestContext.dispose();
},
accessToken: async ({ authedRequest }, use) => {
await use((authedRequest as any)._tokens.accessToken);
},
});
export { expect } from '@playwright/test';

124
tests/fixtures/db.fixture.ts vendored Normal file
View File

@@ -0,0 +1,124 @@
/**
* Database fixture for E2E tests.
*
* Provides helpers to seed and clean up test data in Postgres.
* Uses the `pg` driver directly (same driver the backend uses)
* to avoid coupling tests to the backend's TypeORM setup.
*
* Usage in tests:
* import { testDb } from '../fixtures/db.fixture';
* test.beforeAll(async () => { await testDb.connect(); });
* test.afterAll(async () => { await testDb.cleanup(); await testDb.disconnect(); });
*/
import { Pool, type PoolClient } from 'pg';
import { TEST_PREFIX } from './test-data';
const TEST_DB_URL =
process.env.TEST_DB_URL ||
'postgresql://hoafinance:change_me@localhost:5432/hoafinance';
class TestDatabase {
private pool: Pool | null = null;
/** Connect to the test database */
async connect(): Promise<void> {
this.pool = new Pool({ connectionString: TEST_DB_URL, max: 5 });
// Verify connectivity
const client = await this.pool.connect();
try {
await client.query('SELECT 1');
} finally {
client.release();
}
}
/** Get a client from the pool */
async getClient(): Promise<PoolClient> {
if (!this.pool) throw new Error('TestDatabase not connected');
return this.pool.connect();
}
/** Execute a query */
async query(sql: string, params?: unknown[]) {
if (!this.pool) throw new Error('TestDatabase not connected');
return this.pool.query(sql, params);
}
/**
* Clean up all test-created data.
* Removes rows that match the TEST_PREFIX in name/description/memo fields.
* This runs within a transaction so it's atomic.
*/
async cleanup(): Promise<void> {
if (!this.pool) return;
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// Clean up test data from tenant schemas.
// We look for the e2e test org schema if it exists.
const schemas = await client.query(
`SELECT schema_name FROM shared.organizations
WHERE name LIKE '${TEST_PREFIX}%' OR schema_name = 'e2e_test_hoa'`,
);
for (const row of schemas.rows) {
const schema = row.schema_name;
// Delete test journal entries, accounts, etc. in tenant schema
await client.query(
`DELETE FROM "${schema}".journal_entries WHERE memo LIKE $1`,
[`${TEST_PREFIX}%`],
).catch(() => {}); // Table may not exist
await client.query(
`DELETE FROM "${schema}".accounts WHERE name LIKE $1`,
[`${TEST_PREFIX}%`],
).catch(() => {});
}
// Clean up test users from shared schema
await client.query(
`DELETE FROM shared.users WHERE email LIKE 'e2e-%@test.hoaledgeriq.com'`,
).catch(() => {});
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
console.error('Test cleanup failed:', err);
} finally {
client.release();
}
}
/**
* Seed the minimum data needed for authenticated test flows:
* - A test organization (if not exists)
* - A test user with known credentials (if not exists)
* - Associates user to org with the given role
*
* This is idempotent — safe to call multiple times.
*/
async seedTestUser(user: {
email: string;
password: string;
fullName: string;
role: string;
}): Promise<void> {
if (!this.pool) throw new Error('TestDatabase not connected');
// This is done via API calls in auth.setup.ts instead of direct SQL,
// because the backend handles password hashing, schema creation, etc.
// This method is available for advanced scenarios that need direct DB access.
console.log(`[TestDB] Seed user ${user.email} — use API-based seeding in auth.setup.ts`);
}
/** Disconnect from the database */
async disconnect(): Promise<void> {
if (this.pool) {
await this.pool.end();
this.pool = null;
}
}
}
export const testDb = new TestDatabase();

48
tests/fixtures/test-data.ts vendored Normal file
View File

@@ -0,0 +1,48 @@
/**
* Shared test data constants for E2E and API tests.
*
* Credentials match what the DB seed fixture creates.
* Money values are in cents (matching the backend convention).
*/
export const TEST_USERS = {
treasurer: {
email: process.env.TEST_USER_EMAIL || 'e2e-treasurer@test.hoaledgeriq.com',
password: process.env.TEST_USER_PASSWORD || 'TestPass123!',
role: 'treasurer',
fullName: 'E2E Test Treasurer',
},
viewer: {
email: 'e2e-viewer@test.hoaledgeriq.com',
password: 'TestPass123!',
role: 'viewer',
fullName: 'E2E Test Viewer',
},
} as const;
export const TEST_ORG = {
name: 'E2E Test HOA',
schemaName: 'e2e_test_hoa',
} as const;
/** Sample account used for CRUD tests */
export const SAMPLE_ACCOUNT = {
name: 'E2E Operating Checking',
accountType: 'asset',
fundType: 'operating',
number: '1010',
description: 'E2E test operating account',
} as const;
/** Sample journal entry for write-path tests */
export const SAMPLE_JOURNAL_ENTRY = {
date: new Date().toISOString().split('T')[0],
memo: 'E2E test journal entry',
lines: [
{ accountId: '', debit: 100_00, credit: 0 },
{ accountId: '', credit: 100_00, debit: 0 },
],
} as const;
/** Unique prefix for test-created data — makes cleanup queries simple */
export const TEST_PREFIX = 'E2E_' as const;

View File

@@ -0,0 +1,82 @@
/**
* Page object for the Accounts page (/accounts).
*
* Maps to: frontend/src/pages/accounts/AccountsPage.tsx
* Data: GET /api/accounts, POST /api/accounts, PUT /api/accounts/:id
*/
import { type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class AccountsPage extends BasePage {
readonly path = '/accounts';
// ─── Locators ────────────────────────────────────────────────
get heading() {
return this.page.getByRole('heading', { name: /accounts|chart of accounts/i }).first();
}
get addAccountButton() {
return this.page.getByRole('button', { name: /add|create|new/i }).first();
}
/** Account name input in the create/edit modal */
get nameInput() {
return this.page.getByLabel(/name/i).first();
}
/** Account number input */
get numberInput() {
return this.page.getByLabel(/number/i).first();
}
/** Account type select */
get typeSelect() {
return this.page.getByLabel(/type/i).first();
}
/** Save/submit button in modal */
get saveButton() {
return this.page.getByRole('button', { name: /save|create|submit/i }).first();
}
/** Cancel button in modal */
get cancelButton() {
return this.page.getByRole('button', { name: /cancel/i }).first();
}
// ─── Actions ─────────────────────────────────────────────────
override async waitForReady(): Promise<void> {
await this.page.waitForLoadState('networkidle');
// Wait for the accounts list or heading to be visible
await expect(this.page.locator('main')).toBeVisible();
}
/** Get the count of visible account rows */
async getAccountCount(): Promise<number> {
return this.tableRows.count();
}
/** Find an account row by name */
accountRow(name: string) {
return this.tableBody.locator('tr').filter({ hasText: name });
}
/** Assert an account exists in the table */
async assertAccountExists(name: string): Promise<void> {
await expect(this.accountRow(name)).toBeVisible();
}
/** Assert an account does NOT exist in the table */
async assertAccountNotExists(name: string): Promise<void> {
await expect(this.accountRow(name)).not.toBeVisible();
}
/** Open the create account modal */
async openCreateModal(): Promise<void> {
await this.addAccountButton.click();
await expect(this.nameInput).toBeVisible();
}
}

View File

@@ -0,0 +1,73 @@
/**
* Base page object with shared helpers.
*
* All page objects extend this class to inherit common navigation,
* waiting, and assertion patterns.
*/
import { type Page, type Locator, expect } from '@playwright/test';
export abstract class BasePage {
constructor(protected readonly page: Page) {}
/** The path segment for this page (e.g. '/dashboard', '/accounts') */
abstract readonly path: string;
/** Navigate to this page */
async goto(): Promise<void> {
await this.page.goto(this.path);
await this.waitForReady();
}
/**
* Override in subclasses to wait for page-specific readiness signals.
* Default: waits for network idle.
*/
async waitForReady(): Promise<void> {
await this.page.waitForLoadState('networkidle');
}
/** Assert the page URL contains the expected path */
async assertOnPage(): Promise<void> {
await expect(this.page).toHaveURL(new RegExp(this.path));
}
/** Get the page title text (Mantine AppShell header or h1) */
async getPageHeading(): Promise<string> {
const heading = this.page.getByRole('heading', { level: 1 }).first();
return heading.innerText();
}
/** Wait for a Mantine notification to appear with the given text */
async waitForNotification(text: string | RegExp): Promise<void> {
await expect(
this.page.locator('.mantine-Notification-root').filter({ hasText: text }),
).toBeVisible({ timeout: 10_000 });
}
/** Click a navigation link in the sidebar */
async navigateTo(linkText: string): Promise<void> {
await this.page.getByRole('link', { name: linkText }).click();
await this.page.waitForLoadState('networkidle');
}
/** Get a Mantine table body locator */
get tableBody(): Locator {
return this.page.locator('tbody');
}
/** Get all table rows */
get tableRows(): Locator {
return this.tableBody.locator('tr');
}
/** Wait for API response on a specific endpoint pattern */
async waitForApi(urlPattern: string | RegExp): Promise<void> {
await this.page.waitForResponse(
(response) =>
(typeof urlPattern === 'string'
? response.url().includes(urlPattern)
: urlPattern.test(response.url())) && response.status() < 400,
);
}
}

View File

@@ -0,0 +1,56 @@
/**
* Page object for the Dashboard (/dashboard).
*
* Maps to: frontend/src/pages/dashboard/DashboardPage.tsx
* Data: GET /api/reports/dashboard
*/
import { type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class DashboardPage extends BasePage {
readonly path = '/dashboard';
// ─── Locators ────────────────────────────────────────────────
/** Main dashboard heading */
get heading() {
return this.page.getByRole('heading', { name: /dashboard/i }).first();
}
/** Account balance summary cards */
get balanceCards() {
return this.page.locator('[class*="Card"]').filter({ hasText: /balance|total/i });
}
/** Sidebar navigation */
get sidebar() {
return this.page.locator('nav').first();
}
/** Sidebar links */
sidebarLink(name: string) {
return this.sidebar.getByRole('link', { name });
}
// ─── Actions ─────────────────────────────────────────────────
/** Wait for dashboard data to load */
override async waitForReady(): Promise<void> {
await this.page.waitForLoadState('networkidle');
// Dashboard typically loads report data
await expect(this.page.locator('main')).toBeVisible();
}
/** Assert the dashboard has loaded with content */
async assertLoaded(): Promise<void> {
await this.assertOnPage();
await expect(this.page.locator('main')).not.toBeEmpty();
}
/** Navigate to a section via sidebar */
async navigateToSection(section: string): Promise<void> {
await this.sidebarLink(section).click();
await this.page.waitForLoadState('networkidle');
}
}

View File

@@ -0,0 +1,85 @@
/**
* Page object for the Login page (/login).
*
* Maps to: frontend/src/pages/auth/LoginPage.tsx
* Auth: POST /api/auth/login (Passport local strategy)
*/
import { type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
export class LoginPage extends BasePage {
readonly path = '/login';
// ─── Locators (resilient, role/label based) ──────────────────
get emailInput() {
return this.page.getByLabel('Email');
}
get passwordInput() {
return this.page.getByLabel('Password');
}
get signInButton() {
return this.page.getByRole('button', { name: 'Sign in' });
}
get passkeyButton() {
return this.page.getByRole('button', { name: /passkey/i });
}
get errorAlert() {
return this.page.getByRole('alert');
}
get registerLink() {
return this.page.getByRole('link', { name: /register/i });
}
// ─── MFA locators ────────────────────────────────────────────
get mfaHeading() {
return this.page.getByText('Two-Factor Authentication');
}
get mfaPinInput() {
return this.page.locator('.mantine-PinInput-input').first();
}
get mfaVerifyButton() {
return this.page.getByRole('button', { name: 'Verify' });
}
// ─── Actions ─────────────────────────────────────────────────
/** Fill and submit login credentials */
async login(email: string, password: string): Promise<void> {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.signInButton.click();
}
/** Login and wait for successful redirect */
async loginAndWaitForRedirect(
email: string,
password: string,
): Promise<void> {
await this.login(email, password);
await this.page.waitForURL(/\/(select-org|dashboard|admin|onboarding)/, {
timeout: 15_000,
});
}
/** Assert an error message is displayed */
async assertError(message: string | RegExp): Promise<void> {
await expect(this.errorAlert).toContainText(message);
}
/** Assert we're still on the login page */
async assertStillOnLogin(): Promise<void> {
await expect(this.page).toHaveURL(/\/login/);
}
override async waitForReady(): Promise<void> {
await expect(this.signInButton).toBeVisible();
}
}

View File

@@ -0,0 +1,10 @@
/**
* Re-export all page objects for convenient imports.
*
* Usage: import { LoginPage, DashboardPage } from '../page-objects';
*/
export { BasePage } from './BasePage';
export { LoginPage } from './LoginPage';
export { DashboardPage } from './DashboardPage';
export { AccountsPage } from './AccountsPage';