Compare commits
20 Commits
claude/com
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4df796e977 | |||
| a7e3f80eda | |||
| 19bd19b0c4 | |||
| 3e7463cf46 | |||
| cefcc296fb | |||
| 2aad137bd7 | |||
| f5bea7cdc2 | |||
| e06ca74d1d | |||
| 5144da4680 | |||
| 95c83a57b6 | |||
| 83115c9b5c | |||
| c57dd3e155 | |||
| afe5633b0a | |||
| 43b10869f0 | |||
| f76c67f51a | |||
| 5fec296569 | |||
| c981676bc7 | |||
|
|
bd174fc22b | ||
| 827eef4f49 | |||
|
|
4797669591 |
@@ -1,28 +0,0 @@
|
||||
# ─── 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
|
||||
65
.gitea/workflows/deploy.yml
Normal file
65
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,65 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Production Deployment Workflow for HOA LedgerIQ
|
||||
#
|
||||
# Trigger: Manual only (workflow_dispatch) — production deploys are intentional.
|
||||
# Runner: Self-hosted on the production server at /opt/hoa-ledgeriq.
|
||||
#
|
||||
# This workflow does NOT use actions/checkout. The runner operates directly
|
||||
# on the production directory. The deploy script itself handles git pull.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
seed_existing:
|
||||
description: "Mark existing migrations as applied without running them (first deployment only)"
|
||||
required: false
|
||||
default: "false"
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: /opt/hoa-ledgeriq
|
||||
|
||||
steps:
|
||||
- name: Pre-deploy info
|
||||
run: |
|
||||
echo "## Pre-Deploy Info" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Server:** $(hostname)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Directory:** $(pwd)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Current commit:** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Branch:** $(git branch --show-current || echo 'detached')" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Triggered by:** ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Seed existing:** ${{ inputs.seed_existing }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Started at:** $(date -Iseconds)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Run deployment
|
||||
run: |
|
||||
DEPLOY_FLAGS=""
|
||||
if [ "${{ inputs.seed_existing }}" = "true" ]; then
|
||||
DEPLOY_FLAGS="--seed-existing"
|
||||
fi
|
||||
bash scripts/deploy-prod.sh $DEPLOY_FLAGS
|
||||
env:
|
||||
TERM: xterm
|
||||
|
||||
- name: Deployment result
|
||||
if: always()
|
||||
run: |
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Deployment Result" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
echo "- **Status:** Successful" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit:** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- **Status:** FAILED (auto-rollback triggered)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit (after rollback):** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Check the deploy log on the server for details" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "- **Completed at:** $(date -Iseconds)" >> $GITHUB_STEP_SUMMARY
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -44,13 +44,3 @@ coverage/
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
tests/.auth/
|
||||
*-snapshots/
|
||||
|
||||
# Test environment
|
||||
.env.test
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
# 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
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -34,6 +35,7 @@ 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({
|
||||
@@ -90,6 +92,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
EmailModule,
|
||||
OnboardingModule,
|
||||
IdeasModule,
|
||||
ShadowAiModule,
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
controllers: [AppController],
|
||||
@@ -98,6 +101,10 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
provide: APP_GUARD,
|
||||
useClass: WriteAccessGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: CapabilityGuard,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: NoCacheInterceptor,
|
||||
|
||||
14
backend/src/common/decorators/capability.decorator.ts
Normal file
14
backend/src/common/decorators/capability.decorator.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const CAPABILITIES_KEY = 'required_capabilities';
|
||||
|
||||
/**
|
||||
* Decorator to require specific capabilities on an endpoint.
|
||||
* User must have ALL listed capabilities to access the endpoint.
|
||||
*
|
||||
* Usage:
|
||||
* @RequireCapability('financials.accounts.edit')
|
||||
* @RequireCapability('financials.accounts.view', 'financials.accounts.edit')
|
||||
*/
|
||||
export const RequireCapability = (...capabilities: string[]) =>
|
||||
SetMetadata(CAPABILITIES_KEY, capabilities);
|
||||
83
backend/src/common/guards/capability.guard.ts
Normal file
83
backend/src/common/guards/capability.guard.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { CAPABILITIES_KEY } from '../decorators/capability.decorator';
|
||||
import { resolveCapabilities } from '../permissions';
|
||||
|
||||
@Injectable()
|
||||
export class CapabilityGuard implements CanActivate {
|
||||
// Cache org settings (including permissionOverrides) per orgId
|
||||
private settingsCache = new Map<string, { settings: Record<string, any>; cachedAt: number }>();
|
||||
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
||||
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const requiredCapabilities = this.reflector.getAllAndOverride<string[]>(CAPABILITIES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
// No capabilities required — pass through (backward compatible)
|
||||
if (!requiredCapabilities || requiredCapabilities.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
// No authenticated user — let other guards handle auth
|
||||
if (!user) return true;
|
||||
|
||||
// Superadmins bypass all capability checks
|
||||
if (user.isSuperadmin) return true;
|
||||
|
||||
const role = user.role;
|
||||
const orgId = user.orgId;
|
||||
|
||||
if (!role || !orgId) return true;
|
||||
|
||||
// Get org settings (with caching)
|
||||
const settings = await this.getOrgSettings(orgId);
|
||||
const userCapabilities = resolveCapabilities(role, settings?.permissionOverrides);
|
||||
|
||||
// User must have ALL required capabilities
|
||||
const hasAll = requiredCapabilities.every((cap) => userCapabilities.has(cap));
|
||||
if (!hasAll) {
|
||||
throw new ForbiddenException(
|
||||
'You do not have the required permissions for this action.',
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async getOrgSettings(orgId: string): Promise<Record<string, any> | null> {
|
||||
const cached = this.settingsCache.get(orgId);
|
||||
if (cached && Date.now() - cached.cachedAt < CapabilityGuard.CACHE_TTL) {
|
||||
return cached.settings;
|
||||
}
|
||||
try {
|
||||
const result = await this.dataSource.query(
|
||||
`SELECT settings FROM shared.organizations WHERE id = $1`,
|
||||
[orgId],
|
||||
);
|
||||
if (result.length > 0) {
|
||||
const settings = result[0].settings || {};
|
||||
this.settingsCache.set(orgId, { settings, cachedAt: Date.now() });
|
||||
return settings;
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — fall through to use defaults only
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Clear cached settings for an org (call after settings update) */
|
||||
clearCache(orgId: string) {
|
||||
this.settingsCache.delete(orgId);
|
||||
}
|
||||
}
|
||||
65
backend/src/common/permissions/capabilities.ts
Normal file
65
backend/src/common/permissions/capabilities.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Capability taxonomy for the HOA Financial Platform.
|
||||
*
|
||||
* Pattern: {area}.{feature}.{action}
|
||||
* Actions: view, edit, approve, manage
|
||||
*
|
||||
* Add new capabilities here when new features are built.
|
||||
* The default role matrix in ./default-role-capabilities.ts must also be updated.
|
||||
*/
|
||||
export const CAPABILITIES = {
|
||||
// Dashboard
|
||||
DASHBOARD_VIEW: 'dashboard.view',
|
||||
|
||||
// Financials
|
||||
FINANCIALS_ACCOUNTS_VIEW: 'financials.accounts.view',
|
||||
FINANCIALS_ACCOUNTS_EDIT: 'financials.accounts.edit',
|
||||
FINANCIALS_CASHFLOW_VIEW: 'financials.cashflow.view',
|
||||
FINANCIALS_CASHFLOW_EDIT: 'financials.cashflow.edit',
|
||||
FINANCIALS_ACTUALS_VIEW: 'financials.actuals.view',
|
||||
FINANCIALS_ACTUALS_EDIT: 'financials.actuals.edit',
|
||||
FINANCIALS_BUDGETS_VIEW: 'financials.budgets.view',
|
||||
FINANCIALS_BUDGETS_EDIT: 'financials.budgets.edit',
|
||||
FINANCIALS_BUDGETS_APPROVE: 'financials.budgets.approve',
|
||||
|
||||
// Assessments
|
||||
ASSESSMENTS_UNITS_VIEW: 'assessments.units.view',
|
||||
ASSESSMENTS_UNITS_EDIT: 'assessments.units.edit',
|
||||
ASSESSMENTS_GROUPS_VIEW: 'assessments.groups.view',
|
||||
ASSESSMENTS_GROUPS_EDIT: 'assessments.groups.edit',
|
||||
|
||||
// Board Planning
|
||||
PLANNING_BUDGETS_VIEW: 'planning.budgets.view',
|
||||
PLANNING_BUDGETS_EDIT: 'planning.budgets.edit',
|
||||
PLANNING_PROJECTS_VIEW: 'planning.projects.view',
|
||||
PLANNING_PROJECTS_EDIT: 'planning.projects.edit',
|
||||
PLANNING_SCENARIOS_VIEW: 'planning.scenarios.view',
|
||||
PLANNING_SCENARIOS_EDIT: 'planning.scenarios.edit',
|
||||
PLANNING_SCENARIOS_APPROVE: 'planning.scenarios.approve',
|
||||
PLANNING_INVESTMENTS_VIEW: 'planning.investments.view',
|
||||
PLANNING_INVESTMENTS_EDIT: 'planning.investments.edit',
|
||||
|
||||
// Board Reference
|
||||
REFERENCE_VENDORS_VIEW: 'reference.vendors.view',
|
||||
REFERENCE_VENDORS_EDIT: 'reference.vendors.edit',
|
||||
|
||||
// Transactions
|
||||
TRANSACTIONS_VIEW: 'transactions.view',
|
||||
TRANSACTIONS_EDIT: 'transactions.edit',
|
||||
TRANSACTIONS_APPROVE: 'transactions.approve',
|
||||
|
||||
// Reports
|
||||
REPORTS_VIEW: 'reports.view',
|
||||
|
||||
// Settings & Administration
|
||||
SETTINGS_ORG_VIEW: 'settings.org.view',
|
||||
SETTINGS_ORG_EDIT: 'settings.org.edit',
|
||||
SETTINGS_MEMBERS_VIEW: 'settings.members.view',
|
||||
SETTINGS_MEMBERS_MANAGE: 'settings.members.manage',
|
||||
SETTINGS_PERMISSIONS_MANAGE: 'settings.permissions.manage',
|
||||
} as const;
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
|
||||
|
||||
/** Set of all valid capability strings, for validation */
|
||||
export const ALL_CAPABILITIES = new Set<string>(Object.values(CAPABILITIES));
|
||||
157
backend/src/common/permissions/default-role-capabilities.ts
Normal file
157
backend/src/common/permissions/default-role-capabilities.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { CAPABILITIES, Capability } from './capabilities';
|
||||
|
||||
const C = CAPABILITIES;
|
||||
|
||||
/**
|
||||
* Default capability sets per role.
|
||||
*
|
||||
* These represent sensible defaults for a typical HOA. Tenant admins can
|
||||
* customize per-role capabilities via permission overrides in org settings.
|
||||
*
|
||||
* Roles not listed here (e.g. unknown future roles) get zero capabilities.
|
||||
*/
|
||||
export const DEFAULT_ROLE_CAPABILITIES: Record<string, readonly Capability[]> = {
|
||||
president: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
|
||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
|
||||
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
|
||||
C.REPORTS_VIEW,
|
||||
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
|
||||
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
|
||||
C.SETTINGS_PERMISSIONS_MANAGE,
|
||||
],
|
||||
|
||||
admin: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
|
||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
|
||||
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
|
||||
C.REPORTS_VIEW,
|
||||
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
|
||||
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
|
||||
C.SETTINGS_PERMISSIONS_MANAGE,
|
||||
],
|
||||
|
||||
vice_president: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||
C.FINANCIALS_CASHFLOW_VIEW,
|
||||
C.FINANCIALS_ACTUALS_VIEW,
|
||||
C.FINANCIALS_BUDGETS_VIEW,
|
||||
C.ASSESSMENTS_UNITS_VIEW,
|
||||
C.ASSESSMENTS_GROUPS_VIEW,
|
||||
C.PLANNING_BUDGETS_VIEW,
|
||||
C.PLANNING_PROJECTS_VIEW,
|
||||
C.PLANNING_SCENARIOS_VIEW,
|
||||
C.PLANNING_INVESTMENTS_VIEW,
|
||||
C.REFERENCE_VENDORS_VIEW,
|
||||
C.TRANSACTIONS_VIEW,
|
||||
C.REPORTS_VIEW,
|
||||
C.SETTINGS_ORG_VIEW,
|
||||
C.SETTINGS_MEMBERS_VIEW,
|
||||
],
|
||||
|
||||
treasurer: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT,
|
||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT,
|
||||
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
|
||||
C.REPORTS_VIEW,
|
||||
C.SETTINGS_MEMBERS_VIEW,
|
||||
],
|
||||
|
||||
secretary: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||
C.FINANCIALS_CASHFLOW_VIEW,
|
||||
C.FINANCIALS_ACTUALS_VIEW,
|
||||
C.FINANCIALS_BUDGETS_VIEW,
|
||||
C.ASSESSMENTS_UNITS_VIEW,
|
||||
C.ASSESSMENTS_GROUPS_VIEW,
|
||||
C.PLANNING_BUDGETS_VIEW,
|
||||
C.PLANNING_PROJECTS_VIEW,
|
||||
C.PLANNING_SCENARIOS_VIEW,
|
||||
C.PLANNING_INVESTMENTS_VIEW,
|
||||
C.REFERENCE_VENDORS_VIEW,
|
||||
C.REPORTS_VIEW,
|
||||
],
|
||||
|
||||
member_at_large: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||
C.FINANCIALS_CASHFLOW_VIEW,
|
||||
C.FINANCIALS_ACTUALS_VIEW,
|
||||
C.FINANCIALS_BUDGETS_VIEW,
|
||||
C.ASSESSMENTS_UNITS_VIEW,
|
||||
C.ASSESSMENTS_GROUPS_VIEW,
|
||||
C.PLANNING_BUDGETS_VIEW,
|
||||
C.PLANNING_PROJECTS_VIEW,
|
||||
C.PLANNING_SCENARIOS_VIEW,
|
||||
C.PLANNING_INVESTMENTS_VIEW,
|
||||
C.REFERENCE_VENDORS_VIEW,
|
||||
C.REPORTS_VIEW,
|
||||
],
|
||||
|
||||
manager: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||
C.FINANCIALS_CASHFLOW_VIEW,
|
||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||
C.FINANCIALS_BUDGETS_VIEW,
|
||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||
C.ASSESSMENTS_GROUPS_VIEW,
|
||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
|
||||
C.REPORTS_VIEW,
|
||||
],
|
||||
|
||||
homeowner: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.REPORTS_VIEW,
|
||||
],
|
||||
|
||||
viewer: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||
C.FINANCIALS_CASHFLOW_VIEW,
|
||||
C.FINANCIALS_ACTUALS_VIEW,
|
||||
C.FINANCIALS_BUDGETS_VIEW,
|
||||
C.ASSESSMENTS_UNITS_VIEW,
|
||||
C.ASSESSMENTS_GROUPS_VIEW,
|
||||
C.PLANNING_BUDGETS_VIEW,
|
||||
C.PLANNING_PROJECTS_VIEW,
|
||||
C.PLANNING_SCENARIOS_VIEW,
|
||||
C.PLANNING_INVESTMENTS_VIEW,
|
||||
C.REFERENCE_VENDORS_VIEW,
|
||||
C.TRANSACTIONS_VIEW,
|
||||
C.REPORTS_VIEW,
|
||||
],
|
||||
};
|
||||
5
backend/src/common/permissions/index.ts
Normal file
5
backend/src/common/permissions/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { CAPABILITIES, ALL_CAPABILITIES } from './capabilities';
|
||||
export type { Capability } from './capabilities';
|
||||
export { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
||||
export { resolveCapabilities, resolveCapabilitiesArray } from './resolve-permissions';
|
||||
export type { PermissionOverrides } from './resolve-permissions';
|
||||
57
backend/src/common/permissions/resolve-permissions.ts
Normal file
57
backend/src/common/permissions/resolve-permissions.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { ALL_CAPABILITIES } from './capabilities';
|
||||
import { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
||||
|
||||
export interface PermissionOverrides {
|
||||
[role: string]: {
|
||||
grant?: string[];
|
||||
revoke?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve effective capabilities for a role, applying tenant overrides.
|
||||
*
|
||||
* 1. Start with default capabilities for the role
|
||||
* 2. Add any granted capabilities from overrides
|
||||
* 3. Remove any revoked capabilities from overrides
|
||||
*
|
||||
* Unknown capabilities in grant/revoke are silently ignored (they may
|
||||
* come from an older version of the overrides).
|
||||
*/
|
||||
export function resolveCapabilities(
|
||||
role: string,
|
||||
overrides?: PermissionOverrides | null,
|
||||
): Set<string> {
|
||||
const defaults = DEFAULT_ROLE_CAPABILITIES[role] || [];
|
||||
const result = new Set<string>(defaults);
|
||||
|
||||
if (overrides && overrides[role]) {
|
||||
const roleOverride = overrides[role];
|
||||
|
||||
if (roleOverride.grant) {
|
||||
for (const cap of roleOverride.grant) {
|
||||
if (ALL_CAPABILITIES.has(cap)) {
|
||||
result.add(cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (roleOverride.revoke) {
|
||||
for (const cap of roleOverride.revoke) {
|
||||
result.delete(cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: resolve to a sorted array (for API responses).
|
||||
*/
|
||||
export function resolveCapabilitiesArray(
|
||||
role: string,
|
||||
overrides?: PermissionOverrides | null,
|
||||
): string[] {
|
||||
return Array.from(resolveCapabilities(role, overrides)).sort();
|
||||
}
|
||||
106
backend/src/common/utils/ai-caller.ts
Normal file
106
backend/src/common/utils/ai-caller.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Shared utility for calling OpenAI-compatible chat completion APIs.
|
||||
* Used by both production AI features and shadow AI benchmarking.
|
||||
*/
|
||||
|
||||
export interface AICallerParams {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface AICallerResult {
|
||||
content: string;
|
||||
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||
responseTimeMs: number;
|
||||
rawResponse: string;
|
||||
}
|
||||
|
||||
export async function callOpenAICompatible(params: AICallerParams): Promise<AICallerResult> {
|
||||
const { apiUrl, apiKey, model, messages, temperature, maxTokens, timeoutMs = 600000 } = params;
|
||||
|
||||
const requestBody = {
|
||||
model,
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
};
|
||||
|
||||
const bodyString = JSON.stringify(requestBody);
|
||||
const startTime = Date.now();
|
||||
|
||||
const { URL } = await import('url');
|
||||
const https = await import('https');
|
||||
|
||||
const aiResult = await new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||
// Normalize: strip trailing slash and /chat/completions if user included it
|
||||
let baseUrl = apiUrl.replace(/\/+$/, '');
|
||||
if (baseUrl.endsWith('/chat/completions')) {
|
||||
baseUrl = baseUrl.slice(0, -'/chat/completions'.length);
|
||||
}
|
||||
const url = new URL(`${baseUrl}/chat/completions`);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
|
||||
},
|
||||
timeout: timeoutMs,
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
resolve({ status: res.statusCode!, body: data });
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => reject(err));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error(`Request timed out after ${timeoutMs / 1000}s`));
|
||||
});
|
||||
|
||||
req.write(bodyString);
|
||||
req.end();
|
||||
});
|
||||
|
||||
const responseTimeMs = Date.now() - startTime;
|
||||
|
||||
if (aiResult.status >= 400) {
|
||||
throw new Error(`AI API returned ${aiResult.status}: ${aiResult.body}`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(aiResult.body);
|
||||
const content = data.choices?.[0]?.message?.content || null;
|
||||
|
||||
if (!content) {
|
||||
throw new Error('AI model returned empty content');
|
||||
}
|
||||
|
||||
// Clean response: strip markdown fences and thinking blocks
|
||||
let cleaned = content.trim();
|
||||
if (cleaned.startsWith('```')) {
|
||||
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
|
||||
}
|
||||
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>\s*/g, '').trim();
|
||||
|
||||
const usage = data.usage || undefined;
|
||||
|
||||
return {
|
||||
content: cleaned,
|
||||
usage,
|
||||
responseTimeMs,
|
||||
rawResponse: content,
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
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';
|
||||
@@ -16,24 +17,28 @@ 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 }[] },
|
||||
) {
|
||||
@@ -42,6 +47,7 @@ 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 },
|
||||
@@ -51,6 +57,7 @@ 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 },
|
||||
@@ -60,6 +67,7 @@ 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 },
|
||||
) {
|
||||
@@ -68,18 +76,21 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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')
|
||||
@@ -11,23 +12,30 @@ 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); }
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 {
|
||||
@@ -162,6 +163,12 @@ 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,
|
||||
@@ -169,7 +176,8 @@ export class AuthService {
|
||||
id: membership.organization.id,
|
||||
name: membership.organization.name,
|
||||
role: membership.role,
|
||||
settings: membership.organization.settings || {},
|
||||
settings: orgSettings,
|
||||
capabilities,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -468,12 +476,16 @@ export class AuthService {
|
||||
hasSeenIntro: user.hasSeenIntro || false,
|
||||
mfaEnabled: user.mfaEnabled || false,
|
||||
},
|
||||
organizations: orgs.map((uo) => ({
|
||||
id: uo.organizationId,
|
||||
name: uo.organization?.name,
|
||||
status: uo.organization?.status,
|
||||
role: uo.role,
|
||||
})),
|
||||
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),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -22,27 +23,32 @@ 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);
|
||||
}
|
||||
@@ -51,26 +57,31 @@ 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);
|
||||
}
|
||||
@@ -79,21 +90,25 @@ 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);
|
||||
}
|
||||
@@ -102,11 +117,13 @@ 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);
|
||||
}
|
||||
@@ -115,6 +132,7 @@ 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);
|
||||
@@ -123,6 +141,7 @@ export class BoardPlanningController {
|
||||
// ── Execute Investment ──
|
||||
|
||||
@Post('investments/:id/execute')
|
||||
@RequireCapability('planning.scenarios.edit')
|
||||
executeInvestment(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: { executionDate: string },
|
||||
@@ -135,43 +154,51 @@ 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[],
|
||||
@@ -181,6 +208,7 @@ export class BoardPlanningController {
|
||||
}
|
||||
|
||||
@Get('budget-plans/:year/template')
|
||||
@RequireCapability('planning.scenarios.view')
|
||||
async getBudgetPlanTemplate(
|
||||
@Param('year') year: string,
|
||||
@Res() res: Response,
|
||||
@@ -194,6 +222,7 @@ export class BoardPlanningController {
|
||||
}
|
||||
|
||||
@Delete('budget-plans/:year')
|
||||
@RequireCapability('planning.scenarios.edit')
|
||||
deleteBudgetPlan(@Param('year') year: string) {
|
||||
return this.budgetPlanning.deletePlan(parseInt(year, 10));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Put, Post, Body, Param, Query, Res, UseGuards, ParseIn
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { 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';
|
||||
|
||||
@@ -14,6 +15,7 @@ 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[],
|
||||
@@ -23,6 +25,7 @@ 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,
|
||||
@@ -37,6 +40,7 @@ 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,
|
||||
@@ -46,12 +50,14 @@ 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[],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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')
|
||||
@@ -11,14 +12,18 @@ 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); }
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@ import { HealthScoresScheduler } from './health-scores.scheduler';
|
||||
@Module({
|
||||
controllers: [HealthScoresController],
|
||||
providers: [HealthScoresService, HealthScoresScheduler],
|
||||
exports: [HealthScoresService],
|
||||
})
|
||||
export class HealthScoresModule {}
|
||||
|
||||
@@ -180,7 +180,7 @@ export class HealthScoresService {
|
||||
|
||||
// ── Data Readiness Checks ──
|
||||
|
||||
private async checkDataReadiness(qr: any, scoreType: string): Promise<string[]> {
|
||||
async checkDataReadiness(qr: any, scoreType: string): Promise<string[]> {
|
||||
const missing: string[] = [];
|
||||
|
||||
if (scoreType === 'operating') {
|
||||
@@ -249,7 +249,7 @@ export class HealthScoresService {
|
||||
|
||||
// ── Data Gathering ──
|
||||
|
||||
private async gatherOperatingData(qr: any) {
|
||||
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 {
|
||||
};
|
||||
}
|
||||
|
||||
private async gatherReserveData(qr: any) {
|
||||
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 ──
|
||||
|
||||
private buildOperatingPrompt(data: any): Array<{ role: string; content: string }> {
|
||||
buildOperatingPrompt(data: any): Array<{ role: string; content: string }> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const 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)}`;
|
||||
];
|
||||
}
|
||||
|
||||
private buildReservePrompt(data: any): Array<{ role: string; content: string }> {
|
||||
buildReservePrompt(data: any): Array<{ role: string; content: string }> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const 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.
|
||||
|
||||
@@ -2,6 +2,7 @@ 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')
|
||||
@@ -13,24 +14,28 @@ 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();
|
||||
}
|
||||
@@ -38,6 +43,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@ import { InvestmentPlanningService } from './investment-planning.service';
|
||||
@Module({
|
||||
controllers: [InvestmentPlanningController],
|
||||
providers: [InvestmentPlanningService],
|
||||
exports: [InvestmentPlanningService],
|
||||
})
|
||||
export class InvestmentPlanningModule {}
|
||||
|
||||
@@ -877,7 +877,7 @@ export class InvestmentPlanningService {
|
||||
|
||||
// ── Private: AI Prompt Construction ──
|
||||
|
||||
private buildPromptMessages(
|
||||
buildPromptMessages(
|
||||
snapshot: any,
|
||||
allRates: { cd: MarketRate[]; money_market: MarketRate[]; high_yield_savings: MarketRate[] },
|
||||
monthlyForecast: any,
|
||||
@@ -1059,6 +1059,285 @@ Based on this complete financial picture INCLUDING the 12-month cash flow foreca
|
||||
];
|
||||
}
|
||||
|
||||
// ── Schema-Based Prompt Building (for shadow AI benchmarking) ──
|
||||
|
||||
/**
|
||||
* Build investment recommendation prompt messages for a specific tenant schema.
|
||||
* Bypasses request-scoped TenantService by using DataSource directly.
|
||||
*/
|
||||
async buildPromptForSchema(schemaName: string): Promise<Array<{ role: string; content: string }>> {
|
||||
const qr = this.dataSource.createQueryRunner();
|
||||
try {
|
||||
await qr.connect();
|
||||
await qr.query(`SET search_path TO "${schemaName}"`);
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||
const monthLabels = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
|
||||
// ── Gather financial snapshot data ──
|
||||
const [accountBalances, investmentAccounts, budgets, projects] = await Promise.all([
|
||||
qr.query(`
|
||||
SELECT a.id, a.account_number, a.name, a.account_type, a.fund_type, a.interest_rate,
|
||||
CASE
|
||||
WHEN a.account_type IN ('asset', 'expense')
|
||||
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||
END as balance
|
||||
FROM accounts a
|
||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
|
||||
AND je.is_posted = true AND je.is_void = false
|
||||
WHERE a.is_active = true AND a.account_type IN ('asset', 'liability', 'equity')
|
||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type, a.interest_rate
|
||||
ORDER BY a.account_number
|
||||
`),
|
||||
qr.query(`
|
||||
SELECT id, name, institution, investment_type, fund_type,
|
||||
principal, interest_rate, maturity_date, purchase_date, current_value
|
||||
FROM investment_accounts WHERE is_active = true
|
||||
ORDER BY maturity_date NULLS LAST
|
||||
`),
|
||||
qr.query(
|
||||
`SELECT b.fund_type, a.account_type, a.name, a.account_number,
|
||||
(b.jan + b.feb + b.mar + b.apr + b.may + b.jun +
|
||||
b.jul + b.aug + b.sep + b.oct + b.nov + b.dec_amt) as annual_total
|
||||
FROM budgets b JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1 ORDER BY a.account_type, a.account_number`,
|
||||
[year],
|
||||
),
|
||||
qr.query(`
|
||||
SELECT name, estimated_cost, target_year, target_month, fund_source,
|
||||
status, priority, current_fund_balance, funded_percentage
|
||||
FROM projects WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress')
|
||||
ORDER BY target_year, target_month NULLS LAST, priority
|
||||
`),
|
||||
]);
|
||||
|
||||
// Cash flow context
|
||||
const [opCashResult, resCashResult, assessmentIncome] = await Promise.all([
|
||||
qr.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`),
|
||||
qr.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM accounts a
|
||||
JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`),
|
||||
qr.query(`
|
||||
SELECT COALESCE(SUM(ag.regular_assessment *
|
||||
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active')), 0) as monthly_assessment_income
|
||||
FROM assessment_groups ag WHERE ag.is_active = true
|
||||
`),
|
||||
]);
|
||||
|
||||
const operatingCash = accountBalances
|
||||
.filter((a: any) => a.fund_type === 'operating' && a.account_type === 'asset')
|
||||
.reduce((sum: number, a: any) => sum + parseFloat(a.balance || '0'), 0);
|
||||
const reserveCash = accountBalances
|
||||
.filter((a: any) => a.fund_type === 'reserve' && a.account_type === 'asset')
|
||||
.reduce((sum: number, a: any) => sum + parseFloat(a.balance || '0'), 0);
|
||||
const operatingInvestments = investmentAccounts
|
||||
.filter((i: any) => i.fund_type === 'operating')
|
||||
.reduce((sum: number, i: any) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||
const reserveInvestments = investmentAccounts
|
||||
.filter((i: any) => i.fund_type === 'reserve')
|
||||
.reduce((sum: number, i: any) => sum + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||
|
||||
const snapshot = {
|
||||
summary: {
|
||||
operating_cash: operatingCash,
|
||||
reserve_cash: reserveCash,
|
||||
operating_investments: operatingInvestments,
|
||||
reserve_investments: reserveInvestments,
|
||||
total_operating: operatingCash + operatingInvestments,
|
||||
total_reserve: reserveCash + reserveInvestments,
|
||||
total_all: operatingCash + reserveCash + operatingInvestments + reserveInvestments,
|
||||
},
|
||||
account_balances: accountBalances,
|
||||
investment_accounts: investmentAccounts,
|
||||
budgets,
|
||||
projects,
|
||||
cash_flow_context: {
|
||||
current_operating_cash: parseFloat(opCashResult[0]?.total || '0'),
|
||||
current_reserve_cash: parseFloat(resCashResult[0]?.total || '0'),
|
||||
budget_summary: await qr.query(
|
||||
`SELECT b.fund_type, a.account_type,
|
||||
SUM(b.jan + b.feb + b.mar + b.apr + b.may + b.jun +
|
||||
b.jul + b.aug + b.sep + b.oct + b.nov + b.dec_amt) as annual_total
|
||||
FROM budgets b JOIN accounts a ON a.id = b.account_id
|
||||
WHERE b.fiscal_year = $1 GROUP BY b.fund_type, a.account_type`,
|
||||
[year],
|
||||
),
|
||||
monthly_assessment_income: parseFloat(assessmentIncome[0]?.monthly_assessment_income || '0'),
|
||||
},
|
||||
};
|
||||
|
||||
// ── Build monthly forecast ──
|
||||
const [opCashRows2, resCashRows2, opInvRows, resInvRows] = await Promise.all([
|
||||
qr.query(`SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM accounts a JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true GROUP BY a.id
|
||||
) sub`),
|
||||
qr.query(`SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
|
||||
FROM accounts a JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true GROUP BY a.id
|
||||
) sub`),
|
||||
qr.query(`SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true`),
|
||||
qr.query(`SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true`),
|
||||
]);
|
||||
|
||||
let runOpCash = parseFloat(opCashRows2[0]?.total || '0');
|
||||
let runResCash = parseFloat(resCashRows2[0]?.total || '0');
|
||||
let runOpInv = parseFloat(opInvRows[0]?.total || '0');
|
||||
let runResInv = parseFloat(resInvRows[0]?.total || '0');
|
||||
|
||||
const assessmentGroups = await qr.query(`
|
||||
SELECT ag.frequency, ag.regular_assessment, ag.special_assessment,
|
||||
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active') as unit_count
|
||||
FROM assessment_groups ag WHERE ag.is_active = true
|
||||
`);
|
||||
|
||||
const getAssessmentIncome = (month: number): { operating: number; reserve: number } => {
|
||||
let operating = 0, reserve = 0;
|
||||
for (const g of assessmentGroups) {
|
||||
const units = parseInt(g.unit_count) || 0;
|
||||
const regular = parseFloat(g.regular_assessment) || 0;
|
||||
const special = parseFloat(g.special_assessment) || 0;
|
||||
const freq = g.frequency || 'monthly';
|
||||
let applies = false;
|
||||
if (freq === 'monthly') applies = true;
|
||||
else if (freq === 'quarterly') applies = [1,4,7,10].includes(month);
|
||||
else if (freq === 'annual') applies = month === 1;
|
||||
if (applies) { operating += regular * units; reserve += special * units; }
|
||||
}
|
||||
return { operating, reserve };
|
||||
};
|
||||
|
||||
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
|
||||
for (const yr of [year, year + 1]) {
|
||||
const budgetRows = await qr.query(
|
||||
`SELECT b.fund_type, a.account_type,
|
||||
b.jan, b.feb, b.mar, b.apr, b.may, b.jun, b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
|
||||
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1`, [yr],
|
||||
);
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const key = `${yr}-${m + 1}`;
|
||||
if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||
for (const row of budgetRows) {
|
||||
const amt = parseFloat(row[monthNames[m]]) || 0;
|
||||
if (amt === 0) continue;
|
||||
const isOp = row.fund_type === 'operating';
|
||||
if (row.account_type === 'income') { if (isOp) budgetsByYearMonth[key].opIncome += amt; else budgetsByYearMonth[key].resIncome += amt; }
|
||||
else if (row.account_type === 'expense') { if (isOp) budgetsByYearMonth[key].opExpense += amt; else budgetsByYearMonth[key].resExpense += amt; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maturities = await qr.query(`
|
||||
SELECT fund_type, current_value, maturity_date, interest_rate, purchase_date
|
||||
FROM investment_accounts WHERE is_active = true AND maturity_date IS NOT NULL AND maturity_date > CURRENT_DATE
|
||||
`);
|
||||
const maturityIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||
for (const inv of maturities) {
|
||||
const d = new Date(inv.maturity_date);
|
||||
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
|
||||
if (!maturityIndex[key]) maturityIndex[key] = { operating: 0, reserve: 0 };
|
||||
const val = parseFloat(inv.current_value) || 0;
|
||||
const rate = parseFloat(inv.interest_rate) || 0;
|
||||
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
|
||||
const matDate = new Date(inv.maturity_date);
|
||||
const daysHeld = Math.max((matDate.getTime() - purchaseDate.getTime()) / 86400000, 1);
|
||||
const interestEarned = val * (rate / 100) * (daysHeld / 365);
|
||||
const maturityTotal = val + interestEarned;
|
||||
if (inv.fund_type === 'operating') maturityIndex[key].operating += maturityTotal;
|
||||
else maturityIndex[key].reserve += maturityTotal;
|
||||
}
|
||||
|
||||
const projectExpenses = await qr.query(`
|
||||
SELECT estimated_cost, target_year, target_month, fund_source
|
||||
FROM projects WHERE is_active = true AND status IN ('planned', 'in_progress')
|
||||
AND target_year IS NOT NULL AND estimated_cost > 0
|
||||
`);
|
||||
const projectIndex: Record<string, { operating: number; reserve: number }> = {};
|
||||
for (const p of projectExpenses) {
|
||||
const yr2 = parseInt(p.target_year);
|
||||
const mo = parseInt(p.target_month) || 6;
|
||||
const key = `${yr2}-${mo}`;
|
||||
if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 };
|
||||
const cost = parseFloat(p.estimated_cost) || 0;
|
||||
if (p.fund_source === 'operating') projectIndex[key].operating += cost;
|
||||
else projectIndex[key].reserve += cost;
|
||||
}
|
||||
|
||||
const datapoints: any[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const fYear = year + Math.floor((currentMonth - 1 + i) / 12);
|
||||
const fMonth = ((currentMonth - 1 + i) % 12) + 1;
|
||||
const key = `${fYear}-${fMonth}`;
|
||||
const label = `${monthLabels[fMonth - 1]} ${fYear}`;
|
||||
const assessments = getAssessmentIncome(fMonth);
|
||||
const budget = budgetsByYearMonth[key] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||
const maturity = maturityIndex[key] || { operating: 0, reserve: 0 };
|
||||
const project = projectIndex[key] || { operating: 0, reserve: 0 };
|
||||
const opIncomeMonth = budget.opIncome > 0 ? budget.opIncome : assessments.operating;
|
||||
const resIncomeMonth = budget.resIncome > 0 ? budget.resIncome : assessments.reserve;
|
||||
runOpCash += opIncomeMonth - budget.opExpense - project.operating + maturity.operating;
|
||||
runResCash += resIncomeMonth - budget.resExpense - project.reserve + maturity.reserve;
|
||||
if (maturity.operating > 0) runOpInv = Math.max(0, runOpInv - (maturity.operating * 0.96));
|
||||
if (maturity.reserve > 0) runResInv = Math.max(0, runResInv - (maturity.reserve * 0.96));
|
||||
datapoints.push({
|
||||
month: label,
|
||||
operating_cash: Math.round(runOpCash * 100) / 100,
|
||||
operating_investments: Math.round(runOpInv * 100) / 100,
|
||||
reserve_cash: Math.round(runResCash * 100) / 100,
|
||||
reserve_investments: Math.round(runResInv * 100) / 100,
|
||||
op_income: Math.round(opIncomeMonth * 100) / 100,
|
||||
op_expense: Math.round(budget.opExpense * 100) / 100,
|
||||
res_income: Math.round(resIncomeMonth * 100) / 100,
|
||||
res_expense: Math.round(budget.resExpense * 100) / 100,
|
||||
project_cost_op: Math.round(project.operating * 100) / 100,
|
||||
project_cost_res: Math.round(project.reserve * 100) / 100,
|
||||
maturity_op: Math.round(maturity.operating * 100) / 100,
|
||||
maturity_res: Math.round(maturity.reserve * 100) / 100,
|
||||
});
|
||||
}
|
||||
|
||||
const assessmentSchedule = assessmentGroups.map((g: any) => ({
|
||||
frequency: g.frequency || 'monthly',
|
||||
regular_per_unit: parseFloat(g.regular_assessment) || 0,
|
||||
special_per_unit: parseFloat(g.special_assessment) || 0,
|
||||
units: parseInt(g.unit_count) || 0,
|
||||
total_regular: (parseFloat(g.regular_assessment) || 0) * (parseInt(g.unit_count) || 0),
|
||||
total_special: (parseFloat(g.special_assessment) || 0) * (parseInt(g.unit_count) || 0),
|
||||
}));
|
||||
|
||||
const monthlyForecast = { datapoints, assessment_schedule: assessmentSchedule };
|
||||
const allRates = await this.getMarketRates();
|
||||
|
||||
return this.buildPromptMessages(snapshot, allRates, monthlyForecast);
|
||||
} finally {
|
||||
await qr.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private: AI API Call ──
|
||||
|
||||
private async callAI(messages: Array<{ role: string; content: string }>): Promise<AIResponse> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||
import { 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')
|
||||
@@ -11,14 +12,18 @@ 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); }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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')
|
||||
@@ -11,22 +12,27 @@ 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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -16,6 +17,7 @@ export class JournalEntriesController {
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List journal entries' })
|
||||
@RequireCapability('transactions.view')
|
||||
findAll(
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
@@ -27,24 +29,28 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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')
|
||||
@@ -12,12 +13,14 @@ 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,
|
||||
|
||||
@@ -3,6 +3,8 @@ 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')
|
||||
@@ -23,54 +25,87 @@ 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>) {
|
||||
this.requireTenantAdmin(req);
|
||||
// Validate permissionOverrides if present
|
||||
if (body.permissionOverrides) {
|
||||
this.validatePermissionOverrides(body.permissionOverrides);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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')
|
||||
@@ -11,19 +12,24 @@ 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); }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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')
|
||||
@@ -12,9 +13,11 @@ 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"' });
|
||||
@@ -22,21 +25,27 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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')
|
||||
@@ -11,11 +12,13 @@ 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`;
|
||||
@@ -24,6 +27,7 @@ export class ReportsController {
|
||||
}
|
||||
|
||||
@Get('cash-flow-sankey')
|
||||
@RequireCapability('reports.view')
|
||||
getCashFlowSankey(
|
||||
@Query('year') year?: string,
|
||||
@Query('source') source?: string,
|
||||
@@ -37,6 +41,7 @@ export class ReportsController {
|
||||
}
|
||||
|
||||
@Get('cash-flow')
|
||||
@RequireCapability('reports.view')
|
||||
getCashFlowStatement(
|
||||
@Query('from') from?: string,
|
||||
@Query('to') to?: string,
|
||||
@@ -51,26 +56,31 @@ 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,
|
||||
@@ -81,6 +91,7 @@ export class ReportsController {
|
||||
}
|
||||
|
||||
@Get('capital-planning')
|
||||
@RequireCapability('reports.view')
|
||||
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
|
||||
return this.reportsService.getCapitalPlanningReport(
|
||||
parseInt(startYear || '') || undefined,
|
||||
@@ -88,6 +99,7 @@ export class ReportsController {
|
||||
}
|
||||
|
||||
@Get('quarterly')
|
||||
@RequireCapability('reports.view')
|
||||
getQuarterlyFinancial(
|
||||
@Query('year') year?: string,
|
||||
@Query('quarter') quarter?: string,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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')
|
||||
@@ -11,14 +12,18 @@ 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); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'shared', name: 'shadow_ai_models' })
|
||||
export class ShadowAiModel {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 10, unique: true })
|
||||
slot: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ name: 'api_url', type: 'varchar', length: 500 })
|
||||
apiUrl: string;
|
||||
|
||||
@Column({ name: 'api_key', type: 'varchar', length: 500 })
|
||||
apiKey: string;
|
||||
|
||||
@Column({ name: 'model_name', type: 'varchar', length: 200 })
|
||||
modelName: string;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { ShadowRun } from './shadow-run.entity';
|
||||
|
||||
@Entity({ schema: 'shared', name: 'shadow_run_results' })
|
||||
export class ShadowRunResult {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'run_id', type: 'uuid' })
|
||||
runId: string;
|
||||
|
||||
@Column({ name: 'model_role', type: 'varchar', length: 20 })
|
||||
modelRole: string;
|
||||
|
||||
@Column({ name: 'model_name', type: 'varchar', length: 200 })
|
||||
modelName: string;
|
||||
|
||||
@Column({ name: 'api_url', type: 'varchar', length: 500 })
|
||||
apiUrl: string;
|
||||
|
||||
@Column({ name: 'raw_response', type: 'text', nullable: true })
|
||||
rawResponse: string;
|
||||
|
||||
@Column({ name: 'parsed_response', type: 'jsonb', nullable: true })
|
||||
parsedResponse: any;
|
||||
|
||||
@Column({ name: 'response_time_ms', type: 'integer', nullable: true })
|
||||
responseTimeMs: number;
|
||||
|
||||
@Column({ name: 'token_usage', type: 'jsonb', nullable: true })
|
||||
tokenUsage: any;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'pending' })
|
||||
status: string;
|
||||
|
||||
@Column({ name: 'error_message', type: 'text', nullable: true })
|
||||
errorMessage: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@ManyToOne(() => ShadowRun, (run) => run.results, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'run_id' })
|
||||
run: ShadowRun;
|
||||
}
|
||||
44
backend/src/modules/shadow-ai/entities/shadow-run.entity.ts
Normal file
44
backend/src/modules/shadow-ai/entities/shadow-run.entity.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { ShadowRunResult } from './shadow-run-result.entity';
|
||||
|
||||
@Entity({ schema: 'shared', name: 'shadow_runs' })
|
||||
export class ShadowRun {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'tenant_id', type: 'uuid' })
|
||||
tenantId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 30 })
|
||||
feature: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'running' })
|
||||
status: string;
|
||||
|
||||
@Column({ name: 'triggered_by', type: 'uuid', nullable: true })
|
||||
triggeredBy: string;
|
||||
|
||||
@Column({ name: 'prompt_messages', type: 'jsonb' })
|
||||
promptMessages: any;
|
||||
|
||||
@Column({ name: 'started_at', type: 'timestamptz', default: () => 'NOW()' })
|
||||
startedAt: Date;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@OneToMany(() => ShadowRunResult, (result) => result.run, { eager: true })
|
||||
results: ShadowRunResult[];
|
||||
|
||||
// Virtual field populated via JOIN
|
||||
tenantName?: string;
|
||||
}
|
||||
118
backend/src/modules/shadow-ai/shadow-ai.controller.ts
Normal file
118
backend/src/modules/shadow-ai/shadow-ai.controller.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Req,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { ShadowAiService } from './shadow-ai.service';
|
||||
|
||||
@ApiTags('admin/shadow-ai')
|
||||
@Controller('admin/shadow-ai')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ShadowAiController {
|
||||
constructor(
|
||||
private shadowAiService: ShadowAiService,
|
||||
private usersService: UsersService,
|
||||
) {}
|
||||
|
||||
private async requireSuperadmin(req: any) {
|
||||
const user = await this.usersService.findById(req.user.userId || req.user.sub);
|
||||
if (!user?.isSuperadmin) {
|
||||
throw new ForbiddenException('Superadmin access required');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// ── Model Configuration ──
|
||||
|
||||
@Get('models')
|
||||
async getModels(@Req() req: any) {
|
||||
await this.requireSuperadmin(req);
|
||||
return this.shadowAiService.getModels();
|
||||
}
|
||||
|
||||
@Put('models/:slot')
|
||||
async upsertModel(
|
||||
@Req() req: any,
|
||||
@Param('slot') slot: string,
|
||||
@Body() body: { name: string; apiUrl: string; apiKey: string; modelName: string; isActive?: boolean },
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
if (!['A', 'B'].includes(slot)) {
|
||||
throw new BadRequestException('Slot must be A or B');
|
||||
}
|
||||
if (!body.name || !body.apiUrl || !body.apiKey || !body.modelName) {
|
||||
throw new BadRequestException('name, apiUrl, apiKey, and modelName are required');
|
||||
}
|
||||
return this.shadowAiService.upsertModel(slot, body);
|
||||
}
|
||||
|
||||
@Delete('models/:slot')
|
||||
async deleteModel(@Req() req: any, @Param('slot') slot: string) {
|
||||
await this.requireSuperadmin(req);
|
||||
if (!['A', 'B'].includes(slot)) {
|
||||
throw new BadRequestException('Slot must be A or B');
|
||||
}
|
||||
return this.shadowAiService.deleteModel(slot);
|
||||
}
|
||||
|
||||
// ── Shadow Runs ──
|
||||
|
||||
@Post('runs')
|
||||
async triggerRun(
|
||||
@Req() req: any,
|
||||
@Body() body: { tenantId: string; feature: string },
|
||||
) {
|
||||
const user = await this.requireSuperadmin(req);
|
||||
const validFeatures = ['operating_health', 'reserve_health', 'investment_recommendations'];
|
||||
if (!validFeatures.includes(body.feature)) {
|
||||
throw new BadRequestException(`Feature must be one of: ${validFeatures.join(', ')}`);
|
||||
}
|
||||
if (!body.tenantId) {
|
||||
throw new BadRequestException('tenantId is required');
|
||||
}
|
||||
return this.shadowAiService.triggerRun(
|
||||
body.tenantId,
|
||||
body.feature as any,
|
||||
user.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('runs')
|
||||
async getRunHistory(
|
||||
@Req() req: any,
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('feature') feature?: string,
|
||||
) {
|
||||
await this.requireSuperadmin(req);
|
||||
return this.shadowAiService.getRunHistory({
|
||||
page: page ? parseInt(page) : undefined,
|
||||
limit: limit ? parseInt(limit) : undefined,
|
||||
tenantId,
|
||||
feature,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('runs/:id')
|
||||
async getRunDetail(@Req() req: any, @Param('id') id: string) {
|
||||
await this.requireSuperadmin(req);
|
||||
const detail = await this.shadowAiService.getRunDetail(id);
|
||||
if (!detail) throw new NotFoundException('Shadow run not found');
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
26
backend/src/modules/shadow-ai/shadow-ai.module.ts
Normal file
26
backend/src/modules/shadow-ai/shadow-ai.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module, OnModuleInit } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ShadowAiController } from './shadow-ai.controller';
|
||||
import { ShadowAiService } from './shadow-ai.service';
|
||||
import { ShadowAiModel } from './entities/shadow-ai-model.entity';
|
||||
import { ShadowRun } from './entities/shadow-run.entity';
|
||||
import { ShadowRunResult } from './entities/shadow-run-result.entity';
|
||||
import { HealthScoresModule } from '../health-scores/health-scores.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ShadowAiModel, ShadowRun, ShadowRunResult]),
|
||||
HealthScoresModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [ShadowAiController],
|
||||
providers: [ShadowAiService],
|
||||
})
|
||||
export class ShadowAiModule implements OnModuleInit {
|
||||
constructor(private shadowAiService: ShadowAiService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.shadowAiService.ensureTables();
|
||||
}
|
||||
}
|
||||
723
backend/src/modules/shadow-ai/shadow-ai.service.ts
Normal file
723
backend/src/modules/shadow-ai/shadow-ai.service.ts
Normal file
@@ -0,0 +1,723 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { HealthScoresService } from '../health-scores/health-scores.service';
|
||||
import { callOpenAICompatible } from '../../common/utils/ai-caller';
|
||||
|
||||
type Feature = 'operating_health' | 'reserve_health' | 'investment_recommendations';
|
||||
|
||||
interface ModelConfig {
|
||||
role: string;
|
||||
name: string;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
modelName: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ShadowAiService {
|
||||
private readonly logger = new Logger(ShadowAiService.name);
|
||||
|
||||
constructor(
|
||||
private dataSource: DataSource,
|
||||
private configService: ConfigService,
|
||||
private healthScoresService: HealthScoresService,
|
||||
) {}
|
||||
|
||||
// ── Model Configuration CRUD ──
|
||||
|
||||
async getModels() {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT id, slot, name, api_url, api_key, model_name, is_active, created_at, updated_at
|
||||
FROM shared.shadow_ai_models ORDER BY slot`,
|
||||
);
|
||||
return rows.map((r: any) => ({
|
||||
...r,
|
||||
api_key: r.api_key ? `****${r.api_key.slice(-4)}` : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async upsertModel(slot: string, dto: { name: string; apiUrl: string; apiKey: string; modelName: string; isActive?: boolean }) {
|
||||
const isActive = dto.isActive !== undefined ? dto.isActive : true;
|
||||
|
||||
// Check if model exists for this slot
|
||||
const existing = await this.dataSource.query(
|
||||
`SELECT id, api_key FROM shared.shadow_ai_models WHERE slot = $1`,
|
||||
[slot],
|
||||
);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// If apiKey is masked (starts with ****), keep the existing key
|
||||
const apiKey = dto.apiKey.startsWith('****') ? existing[0].api_key : dto.apiKey;
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.shadow_ai_models
|
||||
SET name = $1, api_url = $2, api_key = $3, model_name = $4, is_active = $5, updated_at = NOW()
|
||||
WHERE slot = $6`,
|
||||
[dto.name, dto.apiUrl, apiKey, dto.modelName, isActive, slot],
|
||||
);
|
||||
} else {
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.shadow_ai_models (slot, name, api_url, api_key, model_name, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[slot, dto.name, dto.apiUrl, dto.apiKey, dto.modelName, isActive],
|
||||
);
|
||||
}
|
||||
|
||||
return { slot, status: 'saved' };
|
||||
}
|
||||
|
||||
async deleteModel(slot: string) {
|
||||
await this.dataSource.query(
|
||||
`DELETE FROM shared.shadow_ai_models WHERE slot = $1`,
|
||||
[slot],
|
||||
);
|
||||
return { slot, status: 'deleted' };
|
||||
}
|
||||
|
||||
// ── Shadow Run Execution ──
|
||||
|
||||
async triggerRun(tenantId: string, feature: Feature, userId: string) {
|
||||
// Look up tenant schema
|
||||
const orgs = await this.dataSource.query(
|
||||
`SELECT schema_name, name FROM shared.organizations WHERE id = $1`,
|
||||
[tenantId],
|
||||
);
|
||||
if (!orgs.length) throw new Error('Tenant not found');
|
||||
const schemaName = orgs[0].schema_name;
|
||||
|
||||
// Build prompt messages for the feature
|
||||
const messages = await this.buildPromptMessages(schemaName, feature);
|
||||
|
||||
// Create shadow run record
|
||||
const runRows = await this.dataSource.query(
|
||||
`INSERT INTO shared.shadow_runs (tenant_id, feature, status, triggered_by, prompt_messages, started_at)
|
||||
VALUES ($1, $2, 'running', $3, $4, NOW())
|
||||
RETURNING id`,
|
||||
[tenantId, feature, userId, JSON.stringify(messages)],
|
||||
);
|
||||
const runId = runRows[0].id;
|
||||
|
||||
// Get model configs
|
||||
const modelConfigs = await this.getModelConfigs();
|
||||
|
||||
// Create pending result rows
|
||||
for (const config of modelConfigs) {
|
||||
await this.dataSource.query(
|
||||
`INSERT INTO shared.shadow_run_results (run_id, model_role, model_name, api_url, status)
|
||||
VALUES ($1, $2, $3, $4, 'pending')`,
|
||||
[runId, config.role, config.modelName, config.apiUrl],
|
||||
);
|
||||
}
|
||||
|
||||
// Fire-and-forget: run all models in parallel
|
||||
this.executeModels(runId, messages, modelConfigs, feature).catch((err) => {
|
||||
this.logger.error(`Shadow run ${runId} failed: ${err.message}`);
|
||||
});
|
||||
|
||||
return { runId, status: 'running' };
|
||||
}
|
||||
|
||||
// ── Run History ──
|
||||
|
||||
async getRunHistory(query: { page?: number; limit?: number; tenantId?: string; feature?: string }) {
|
||||
const page = query.page || 1;
|
||||
const limit = Math.min(query.limit || 20, 100);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let where = '';
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (query.tenantId) {
|
||||
where += ` AND sr.tenant_id = $${paramIdx++}`;
|
||||
params.push(query.tenantId);
|
||||
}
|
||||
if (query.feature) {
|
||||
where += ` AND sr.feature = $${paramIdx++}`;
|
||||
params.push(query.feature);
|
||||
}
|
||||
|
||||
const [rows, countRows] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT sr.id, sr.tenant_id, sr.feature, sr.status, sr.started_at, sr.completed_at, sr.created_at,
|
||||
o.name as tenant_name,
|
||||
(SELECT COUNT(*) FROM shared.shadow_run_results rr WHERE rr.run_id = sr.id) as result_count,
|
||||
(SELECT COUNT(*) FROM shared.shadow_run_results rr WHERE rr.run_id = sr.id AND rr.status = 'success') as success_count
|
||||
FROM shared.shadow_runs sr
|
||||
LEFT JOIN shared.organizations o ON o.id = sr.tenant_id
|
||||
WHERE 1=1 ${where}
|
||||
ORDER BY sr.created_at DESC
|
||||
LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
|
||||
[...params, limit, offset],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT COUNT(*) as total FROM shared.shadow_runs sr WHERE 1=1 ${where}`,
|
||||
params,
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
runs: rows,
|
||||
total: parseInt(countRows[0]?.total || '0'),
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
async getRunDetail(runId: string) {
|
||||
const [runs, results] = await Promise.all([
|
||||
this.dataSource.query(
|
||||
`SELECT sr.*, o.name as tenant_name
|
||||
FROM shared.shadow_runs sr
|
||||
LEFT JOIN shared.organizations o ON o.id = sr.tenant_id
|
||||
WHERE sr.id = $1`,
|
||||
[runId],
|
||||
),
|
||||
this.dataSource.query(
|
||||
`SELECT * FROM shared.shadow_run_results
|
||||
WHERE run_id = $1
|
||||
ORDER BY CASE model_role
|
||||
WHEN 'production' THEN 1
|
||||
WHEN 'alternate_a' THEN 2
|
||||
WHEN 'alternate_b' THEN 3
|
||||
END`,
|
||||
[runId],
|
||||
),
|
||||
]);
|
||||
|
||||
if (!runs.length) return null;
|
||||
|
||||
return {
|
||||
...runs[0],
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Private Helpers ──
|
||||
|
||||
private async buildPromptMessages(
|
||||
schemaName: string,
|
||||
feature: Feature,
|
||||
): Promise<Array<{ role: string; content: string }>> {
|
||||
if (feature === 'operating_health' || feature === 'reserve_health') {
|
||||
const qr = this.dataSource.createQueryRunner();
|
||||
try {
|
||||
await qr.connect();
|
||||
await qr.query(`SET search_path TO "${schemaName}"`);
|
||||
|
||||
const scoreType = feature === 'operating_health' ? 'operating' : 'reserve';
|
||||
const data = scoreType === 'operating'
|
||||
? await this.healthScoresService.gatherOperatingData(qr)
|
||||
: await this.healthScoresService.gatherReserveData(qr);
|
||||
|
||||
return scoreType === 'operating'
|
||||
? this.healthScoresService.buildOperatingPrompt(data)
|
||||
: this.healthScoresService.buildReservePrompt(data);
|
||||
} finally {
|
||||
await qr.release();
|
||||
}
|
||||
}
|
||||
|
||||
// investment_recommendations — build prompt directly via DataSource
|
||||
return this.buildInvestmentPromptForSchema(schemaName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build investment recommendation prompts for a given tenant schema.
|
||||
* Self-contained: uses DataSource directly, no request-scoped dependencies.
|
||||
*/
|
||||
private async buildInvestmentPromptForSchema(schemaName: string): Promise<Array<{ role: string; content: string }>> {
|
||||
const qr = this.dataSource.createQueryRunner();
|
||||
try {
|
||||
await qr.connect();
|
||||
await qr.query(`SET search_path TO "${schemaName}"`);
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
|
||||
const monthLabels = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
|
||||
// ── Financial snapshot ──
|
||||
const [accountBalances, investmentAccounts, budgets, projects] = await Promise.all([
|
||||
qr.query(`
|
||||
SELECT a.id, a.account_number, a.name, a.account_type, a.fund_type, a.interest_rate,
|
||||
CASE WHEN a.account_type IN ('asset', 'expense')
|
||||
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
|
||||
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
|
||||
END as balance
|
||||
FROM accounts a
|
||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||
WHERE a.is_active = true AND a.account_type IN ('asset', 'liability', 'equity')
|
||||
GROUP BY a.id, a.account_number, a.name, a.account_type, a.fund_type, a.interest_rate ORDER BY a.account_number
|
||||
`),
|
||||
qr.query(`SELECT id, name, institution, investment_type, fund_type, principal, interest_rate, maturity_date, purchase_date, current_value
|
||||
FROM investment_accounts WHERE is_active = true ORDER BY maturity_date NULLS LAST`),
|
||||
qr.query(`SELECT b.fund_type, a.account_type, a.name, a.account_number,
|
||||
(b.jan+b.feb+b.mar+b.apr+b.may+b.jun+b.jul+b.aug+b.sep+b.oct+b.nov+b.dec_amt) as annual_total
|
||||
FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1 ORDER BY a.account_type, a.account_number`, [year]),
|
||||
qr.query(`SELECT name, estimated_cost, target_year, target_month, fund_source, status, priority, current_fund_balance, funded_percentage
|
||||
FROM projects WHERE is_active = true AND status IN ('planned','approved','in_progress') ORDER BY target_year, target_month NULLS LAST, priority`),
|
||||
]);
|
||||
|
||||
const [opCashResult, resCashResult, budgetSummary, assessmentIncome] = await Promise.all([
|
||||
qr.query(`SELECT COALESCE(SUM(sub.bal),0) as total FROM (SELECT COALESCE(SUM(jel.debit),0)-COALESCE(SUM(jel.credit),0) as bal FROM accounts a JOIN journal_entry_lines jel ON jel.account_id=a.id JOIN journal_entries je ON je.id=jel.journal_entry_id AND je.is_posted=true AND je.is_void=false WHERE a.account_type='asset' AND a.fund_type='operating' AND a.is_active=true GROUP BY a.id) sub`),
|
||||
qr.query(`SELECT COALESCE(SUM(sub.bal),0) as total FROM (SELECT COALESCE(SUM(jel.debit),0)-COALESCE(SUM(jel.credit),0) as bal FROM accounts a JOIN journal_entry_lines jel ON jel.account_id=a.id JOIN journal_entries je ON je.id=jel.journal_entry_id AND je.is_posted=true AND je.is_void=false WHERE a.account_type='asset' AND a.fund_type='reserve' AND a.is_active=true GROUP BY a.id) sub`),
|
||||
qr.query(`SELECT b.fund_type, a.account_type, SUM(b.jan+b.feb+b.mar+b.apr+b.may+b.jun+b.jul+b.aug+b.sep+b.oct+b.nov+b.dec_amt) as annual_total FROM budgets b JOIN accounts a ON a.id=b.account_id WHERE b.fiscal_year=$1 GROUP BY b.fund_type, a.account_type`, [year]),
|
||||
qr.query(`SELECT COALESCE(SUM(ag.regular_assessment*(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id=ag.id AND u.status='active')),0) as monthly_assessment_income FROM assessment_groups ag WHERE ag.is_active=true`),
|
||||
]);
|
||||
|
||||
const operatingCash = accountBalances.filter((a: any) => a.fund_type === 'operating' && a.account_type === 'asset').reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
|
||||
const reserveCash = accountBalances.filter((a: any) => a.fund_type === 'reserve' && a.account_type === 'asset').reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
|
||||
const operatingInvestments = investmentAccounts.filter((i: any) => i.fund_type === 'operating').reduce((s: number, i: any) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||
const reserveInvestments = investmentAccounts.filter((i: any) => i.fund_type === 'reserve').reduce((s: number, i: any) => s + parseFloat(i.current_value || i.principal || '0'), 0);
|
||||
|
||||
const snapshot = {
|
||||
summary: { operating_cash: operatingCash, reserve_cash: reserveCash, operating_investments: operatingInvestments, reserve_investments: reserveInvestments,
|
||||
total_operating: operatingCash + operatingInvestments, total_reserve: reserveCash + reserveInvestments, total_all: operatingCash + reserveCash + operatingInvestments + reserveInvestments },
|
||||
account_balances: accountBalances, investment_accounts: investmentAccounts, budgets, projects,
|
||||
cash_flow_context: {
|
||||
current_operating_cash: parseFloat(opCashResult[0]?.total || '0'), current_reserve_cash: parseFloat(resCashResult[0]?.total || '0'),
|
||||
budget_summary: budgetSummary, monthly_assessment_income: parseFloat(assessmentIncome[0]?.monthly_assessment_income || '0'),
|
||||
},
|
||||
};
|
||||
|
||||
// ── 12-month forecast ──
|
||||
const [opInvRows, resInvRows] = await Promise.all([
|
||||
qr.query(`SELECT COALESCE(SUM(current_value),0) as total FROM investment_accounts WHERE fund_type='operating' AND is_active=true`),
|
||||
qr.query(`SELECT COALESCE(SUM(current_value),0) as total FROM investment_accounts WHERE fund_type='reserve' AND is_active=true`),
|
||||
]);
|
||||
let runOpCash = parseFloat(opCashResult[0]?.total || '0'), runResCash = parseFloat(resCashResult[0]?.total || '0');
|
||||
let runOpInv = parseFloat(opInvRows[0]?.total || '0'), runResInv = parseFloat(resInvRows[0]?.total || '0');
|
||||
|
||||
const assessmentGroups = await qr.query(`SELECT ag.frequency, ag.regular_assessment, ag.special_assessment,
|
||||
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id=ag.id AND u.status='active') as unit_count FROM assessment_groups ag WHERE ag.is_active=true`);
|
||||
const getAssessmentInc = (month: number) => {
|
||||
let op = 0, res = 0;
|
||||
for (const g of assessmentGroups) {
|
||||
const units = parseInt(g.unit_count) || 0, reg = parseFloat(g.regular_assessment) || 0, spec = parseFloat(g.special_assessment) || 0;
|
||||
const freq = g.frequency || 'monthly';
|
||||
let applies = freq === 'monthly' || (freq === 'quarterly' && [1,4,7,10].includes(month)) || (freq === 'annual' && month === 1);
|
||||
if (applies) { op += reg * units; res += spec * units; }
|
||||
}
|
||||
return { operating: op, reserve: res };
|
||||
};
|
||||
|
||||
const budgetsByYM: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
|
||||
for (const yr of [year, year + 1]) {
|
||||
const bRows = await qr.query(`SELECT b.fund_type, a.account_type, b.jan,b.feb,b.mar,b.apr,b.may,b.jun,b.jul,b.aug,b.sep,b.oct,b.nov,b.dec_amt FROM budgets b JOIN accounts a ON a.id=b.account_id WHERE b.fiscal_year=$1`, [yr]);
|
||||
for (let m = 0; m < 12; m++) {
|
||||
const k = `${yr}-${m+1}`;
|
||||
if (!budgetsByYM[k]) budgetsByYM[k] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||
for (const r of bRows) {
|
||||
const amt = parseFloat(r[monthNames[m]]) || 0;
|
||||
if (!amt) continue;
|
||||
const isOp = r.fund_type === 'operating';
|
||||
if (r.account_type === 'income') { if (isOp) budgetsByYM[k].opIncome += amt; else budgetsByYM[k].resIncome += amt; }
|
||||
else if (r.account_type === 'expense') { if (isOp) budgetsByYM[k].opExpense += amt; else budgetsByYM[k].resExpense += amt; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maturities = await qr.query(`SELECT fund_type, current_value, maturity_date, interest_rate, purchase_date FROM investment_accounts WHERE is_active=true AND maturity_date IS NOT NULL AND maturity_date>CURRENT_DATE`);
|
||||
const matIdx: Record<string, { operating: number; reserve: number }> = {};
|
||||
for (const inv of maturities) {
|
||||
const d = new Date(inv.maturity_date), k = `${d.getFullYear()}-${d.getMonth()+1}`;
|
||||
if (!matIdx[k]) matIdx[k] = { operating: 0, reserve: 0 };
|
||||
const val = parseFloat(inv.current_value) || 0, rate = parseFloat(inv.interest_rate) || 0;
|
||||
const pDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
|
||||
const days = Math.max((d.getTime() - pDate.getTime()) / 86400000, 1);
|
||||
const total = val + val * (rate/100) * (days/365);
|
||||
if (inv.fund_type === 'operating') matIdx[k].operating += total; else matIdx[k].reserve += total;
|
||||
}
|
||||
|
||||
const projExp = await qr.query(`SELECT estimated_cost, target_year, target_month, fund_source FROM projects WHERE is_active=true AND status IN ('planned','in_progress') AND target_year IS NOT NULL AND estimated_cost>0`);
|
||||
const projIdx: Record<string, { operating: number; reserve: number }> = {};
|
||||
for (const p of projExp) {
|
||||
const k = `${parseInt(p.target_year)}-${parseInt(p.target_month)||6}`;
|
||||
if (!projIdx[k]) projIdx[k] = { operating: 0, reserve: 0 };
|
||||
const c = parseFloat(p.estimated_cost) || 0;
|
||||
if (p.fund_source === 'operating') projIdx[k].operating += c; else projIdx[k].reserve += c;
|
||||
}
|
||||
|
||||
const datapoints: any[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const fY = year + Math.floor((currentMonth-1+i)/12), fM = ((currentMonth-1+i)%12)+1;
|
||||
const k = `${fY}-${fM}`, label = `${monthLabels[fM-1]} ${fY}`;
|
||||
const asmt = getAssessmentInc(fM), bud = budgetsByYM[k] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||
const mat = matIdx[k] || { operating: 0, reserve: 0 }, proj = projIdx[k] || { operating: 0, reserve: 0 };
|
||||
const opInc = bud.opIncome > 0 ? bud.opIncome : asmt.operating, resInc = bud.resIncome > 0 ? bud.resIncome : asmt.reserve;
|
||||
runOpCash += opInc - bud.opExpense - proj.operating + mat.operating;
|
||||
runResCash += resInc - bud.resExpense - proj.reserve + mat.reserve;
|
||||
if (mat.operating > 0) runOpInv = Math.max(0, runOpInv - mat.operating * 0.96);
|
||||
if (mat.reserve > 0) runResInv = Math.max(0, runResInv - mat.reserve * 0.96);
|
||||
datapoints.push({ month: label, operating_cash: Math.round(runOpCash*100)/100, operating_investments: Math.round(runOpInv*100)/100,
|
||||
reserve_cash: Math.round(runResCash*100)/100, reserve_investments: Math.round(runResInv*100)/100,
|
||||
op_income: Math.round(opInc*100)/100, op_expense: Math.round(bud.opExpense*100)/100,
|
||||
res_income: Math.round(resInc*100)/100, res_expense: Math.round(bud.resExpense*100)/100,
|
||||
project_cost_op: Math.round(proj.operating*100)/100, project_cost_res: Math.round(proj.reserve*100)/100,
|
||||
maturity_op: Math.round(mat.operating*100)/100, maturity_res: Math.round(mat.reserve*100)/100 });
|
||||
}
|
||||
|
||||
const asmtSchedule = assessmentGroups.map((g: any) => ({
|
||||
frequency: g.frequency || 'monthly', regular_per_unit: parseFloat(g.regular_assessment) || 0,
|
||||
special_per_unit: parseFloat(g.special_assessment) || 0, units: parseInt(g.unit_count) || 0,
|
||||
total_regular: (parseFloat(g.regular_assessment) || 0) * (parseInt(g.unit_count) || 0),
|
||||
total_special: (parseFloat(g.special_assessment) || 0) * (parseInt(g.unit_count) || 0),
|
||||
}));
|
||||
|
||||
// ── Market rates from shared schema ──
|
||||
const fetchLatest = async (rateType: string) =>
|
||||
qr.query(`SELECT bank_name, apy, min_deposit, term, term_months, rate_type, fetched_at
|
||||
FROM shared.cd_rates WHERE rate_type=$1 AND fetched_at=(SELECT MAX(fetched_at) FROM shared.cd_rates WHERE rate_type=$1)
|
||||
ORDER BY apy DESC LIMIT 25`, [rateType]);
|
||||
const [cdRates, mmRates, hysRates] = await Promise.all([fetchLatest('cd'), fetchLatest('money_market'), fetchLatest('high_yield_savings')]);
|
||||
const allRates = { cd: cdRates, money_market: mmRates, high_yield_savings: hysRates };
|
||||
|
||||
// ── Build prompt (replicates InvestmentPlanningService.buildPromptMessages) ──
|
||||
const { summary, investment_accounts: invAccts, cash_flow_context: cfc } = snapshot;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const systemPrompt = `You are a financial advisor specializing in HOA (Homeowners Association) reserve fund management and conservative investment strategy. You provide fiduciary-grade investment recommendations.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. HOAs are legally required to maintain adequate reserves. NEVER recommend depleting reserve funds below safe levels.
|
||||
2. HOA investments must be conservative ONLY: CDs, money market accounts, treasury bills, and high-yield savings. NO stocks, bonds, mutual funds, or speculative instruments.
|
||||
3. Liquidity is paramount: always ensure enough cash to cover at least 3 months of operating expenses AND any capital project expenses due within the next 12 months.
|
||||
4. CD laddering is the preferred strategy for reserve funds — it balances yield with regular liquidity access.
|
||||
5. Operating funds should remain highly liquid (money market or high-yield savings only).
|
||||
6. Respect the separation between operating funds and reserve funds. Never suggest commingling.
|
||||
7. Base your recommendations ONLY on the available market rates (CDs, Money Market, High Yield Savings) provided. Do not reference rates or banks not in the provided data.
|
||||
8. CRITICAL: Use the 12-MONTH CASH FLOW FORECAST to understand future liquidity. The forecast includes projected income (regular assessments AND special assessments collected from homeowners), budgeted expenses, investment maturities, and capital project costs. Do NOT flag liquidity shortfalls if the forecast shows sufficient income arriving before the expense is due.
|
||||
9. When recommending money market or high yield savings accounts, focus on their liquidity advantages for operating funds. When recommending CDs, focus on their higher yields for longer-term reserve fund placement.
|
||||
10. Compare current account rates against available market rates. If better rates are available, suggest specific moves with the potential additional interest income that could be earned.
|
||||
|
||||
RESPONSE FORMAT:
|
||||
Respond with ONLY valid JSON (no markdown, no code fences) matching this exact schema:
|
||||
{
|
||||
"recommendations": [
|
||||
{
|
||||
"type": "cd_ladder" | "new_investment" | "reallocation" | "maturity_action" | "liquidity_warning" | "general",
|
||||
"priority": "high" | "medium" | "low",
|
||||
"title": "Short action title (under 60 chars)",
|
||||
"summary": "One sentence summary of the recommendation",
|
||||
"details": "Detailed explanation with specific dollar amounts and timeframes",
|
||||
"fund_type": "operating" | "reserve" | "both",
|
||||
"suggested_amount": 50000.00,
|
||||
"suggested_term": "12 months",
|
||||
"suggested_rate": 4.50,
|
||||
"bank_name": "Bank name from market rates (if applicable)",
|
||||
"rationale": "Financial reasoning for why this makes sense",
|
||||
"components": [
|
||||
{
|
||||
"label": "Component label (e.g. '6-Month CD at Marcus')",
|
||||
"amount": 6600.00,
|
||||
"term_months": 6,
|
||||
"rate": 4.05,
|
||||
"bank_name": "Marcus",
|
||||
"investment_type": "cd"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"overall_assessment": "2-3 sentence overview of the HOA's current investment position and opportunities",
|
||||
"risk_notes": ["Array of risk items or concerns to flag for the board"]
|
||||
}
|
||||
|
||||
IMPORTANT ABOUT COMPONENTS:
|
||||
- For cd_ladder recommendations, you MUST include a "components" array with each individual CD as a separate component. Each component should have its own label, amount, term_months, rate, and bank_name. The suggested_amount should be the total of all component amounts.
|
||||
- For other multi-part strategies (e.g. splitting funds across multiple accounts), also include a "components" array.
|
||||
- For simple single-investment recommendations, omit the "components" field entirely.
|
||||
|
||||
IMPORTANT: Provide 3-7 actionable recommendations. Prioritize high-priority items (liquidity risks, maturing investments) before optimization opportunities. Include specific dollar amounts wherever possible. When there are opportunities for better rates on existing positions, quantify the additional annual interest that could be earned.`;
|
||||
|
||||
const investmentsList = invAccts.length === 0 ? 'No current investments.'
|
||||
: invAccts.map((i: any) => `- ${i.name} | Type: ${i.investment_type} | Fund: ${i.fund_type} | Principal: $${parseFloat(i.principal).toFixed(2)} | Rate: ${parseFloat(i.interest_rate||'0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`).join('\n');
|
||||
const budgetLines = budgets.length === 0 ? 'No budget data available.'
|
||||
: budgets.map((b: any) => `- ${b.name} (${b.account_number}) | ${b.account_type}/${b.fund_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr`).join('\n');
|
||||
const projectLines = projects.length === 0 ? 'No upcoming capital projects.'
|
||||
: projects.map((p: any) => `- ${p.name} | Cost: $${parseFloat(p.estimated_cost).toFixed(2)} | Target: ${p.target_year||'?'}/${p.target_month||'?'} | Fund: ${p.fund_source} | Status: ${p.status} | Funded: ${parseFloat(p.funded_percentage||'0').toFixed(1)}%`).join('\n');
|
||||
const budgetSummaryLines = (cfc.budget_summary || []).length === 0 ? 'No budget summary available.'
|
||||
: cfc.budget_summary.map((b: any) => `- ${b.fund_type} ${b.account_type}: $${parseFloat(b.annual_total).toFixed(2)}/yr (~$${(parseFloat(b.annual_total)/12).toFixed(2)}/mo)`).join('\n');
|
||||
|
||||
const formatRates = (rates: any[], label: string) => rates.length === 0
|
||||
? `No ${label} rate data available. Rate fetcher may not have been run yet.`
|
||||
: rates.map((r: any) => `- ${r.bank_name} | APY: ${parseFloat(String(r.apy)).toFixed(2)}%${r.term !== 'N/A' ? ` | Term: ${r.term}` : ''} | Min Deposit: ${r.min_deposit ? '$'+parseFloat(String(r.min_deposit)).toLocaleString() : 'N/A'}`).join('\n');
|
||||
|
||||
const asmtLines = asmtSchedule.length === 0 ? 'No assessment schedule available.'
|
||||
: asmtSchedule.map((a: any) => `- ${a.frequency} collection | ${a.units} units | Regular: $${a.regular_per_unit.toFixed(2)}/unit ($${a.total_regular.toFixed(2)} total) → Operating | Special: $${a.special_per_unit.toFixed(2)}/unit ($${a.total_special.toFixed(2)} total) → Reserve`).join('\n');
|
||||
|
||||
const forecastLines = datapoints.map((dp: any) => {
|
||||
const d: string[] = [];
|
||||
if (dp.op_income > 0) d.push(`OpInc:$${dp.op_income.toFixed(0)}`);
|
||||
if (dp.op_expense > 0) d.push(`OpExp:$${dp.op_expense.toFixed(0)}`);
|
||||
if (dp.res_income > 0) d.push(`ResInc:$${dp.res_income.toFixed(0)}`);
|
||||
if (dp.res_expense > 0) d.push(`ResExp:$${dp.res_expense.toFixed(0)}`);
|
||||
if (dp.project_cost_res > 0) d.push(`ResProjCost:$${dp.project_cost_res.toFixed(0)}`);
|
||||
if (dp.project_cost_op > 0) d.push(`OpProjCost:$${dp.project_cost_op.toFixed(0)}`);
|
||||
if (dp.maturity_op > 0) d.push(`OpMaturity:$${dp.maturity_op.toFixed(0)}`);
|
||||
if (dp.maturity_res > 0) d.push(`ResMaturity:$${dp.maturity_res.toFixed(0)}`);
|
||||
return `- ${dp.month} | OpCash: $${dp.operating_cash.toFixed(0)} | ResCash: $${dp.reserve_cash.toFixed(0)} | OpInv: $${dp.operating_investments.toFixed(0)} | ResInv: $${dp.reserve_investments.toFixed(0)} | Drivers: ${d.join(', ') || 'none'}`;
|
||||
}).join('\n');
|
||||
|
||||
const userPrompt = `Analyze this HOA's financial position and provide investment recommendations.
|
||||
|
||||
TODAY'S DATE: ${today}
|
||||
|
||||
=== CURRENT CASH POSITIONS ===
|
||||
Operating Cash (bank accounts): $${summary.operating_cash.toFixed(2)}
|
||||
Reserve Cash (bank accounts): $${summary.reserve_cash.toFixed(2)}
|
||||
Operating Investments: $${summary.operating_investments.toFixed(2)}
|
||||
Reserve Investments: $${summary.reserve_investments.toFixed(2)}
|
||||
Total Operating Fund: $${summary.total_operating.toFixed(2)}
|
||||
Total Reserve Fund: $${summary.total_reserve.toFixed(2)}
|
||||
Grand Total: $${summary.total_all.toFixed(2)}
|
||||
|
||||
=== CURRENT INVESTMENTS ===
|
||||
${investmentsList}
|
||||
|
||||
=== ASSESSMENT INCOME SCHEDULE ===
|
||||
${asmtLines}
|
||||
Note: "Regular" assessments fund Operating. "Special" assessments fund Reserve. Both are collected from homeowners per the frequency above.
|
||||
|
||||
=== ANNUAL BUDGET (${new Date().getFullYear()}) ===
|
||||
${budgetLines}
|
||||
|
||||
=== BUDGET SUMMARY (Annual Totals by Category) ===
|
||||
${budgetSummaryLines}
|
||||
|
||||
=== MONTHLY ASSESSMENT INCOME ===
|
||||
Recurring monthly regular assessment income: $${cfc.monthly_assessment_income.toFixed(2)}/month (operating fund)
|
||||
|
||||
=== UPCOMING CAPITAL PROJECTS ===
|
||||
${projectLines}
|
||||
|
||||
=== 12-MONTH CASH FLOW FORECAST (Projected) ===
|
||||
This forecast shows month-by-month projected balances factoring in ALL income (regular assessments, special assessments, budgeted income), ALL expenses (budgeted expenses, capital project costs), and investment maturities.
|
||||
${forecastLines}
|
||||
|
||||
=== AVAILABLE MARKET RATES ===
|
||||
|
||||
--- CD Rates ---
|
||||
${formatRates(allRates.cd, 'CD')}
|
||||
|
||||
--- Money Market Rates ---
|
||||
${formatRates(allRates.money_market, 'Money Market')}
|
||||
|
||||
--- High Yield Savings Rates ---
|
||||
${formatRates(allRates.high_yield_savings, 'High Yield Savings')}
|
||||
|
||||
Based on this complete financial picture INCLUDING the 12-month cash flow forecast, provide your investment recommendations. Consider:
|
||||
1. Is there excess cash that could earn better returns in CDs, money market accounts, or high-yield savings?
|
||||
2. Are any current investments maturing soon that need reinvestment planning?
|
||||
3. Is the liquidity position adequate for upcoming expenses and projects? USE THE FORECAST to check — if income (including special assessments) arrives before expenses are due, the position may be adequate even if current cash seems low.
|
||||
4. Would a CD ladder strategy improve the yield while maintaining access to funds?
|
||||
5. Are operating and reserve funds properly separated in the investment strategy?
|
||||
6. Could any current money market or savings accounts earn better rates at a different bank? Quantify the potential additional annual interest.
|
||||
7. For operating funds that need to stay liquid, are money market or high-yield savings accounts being used optimally?`;
|
||||
|
||||
return [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
];
|
||||
} finally {
|
||||
await qr.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async getModelConfigs(): Promise<ModelConfig[]> {
|
||||
const configs: ModelConfig[] = [];
|
||||
|
||||
// Production model from env vars
|
||||
const prodApiUrl = this.configService.get<string>('AI_API_URL') || 'https://integrate.api.nvidia.com/v1';
|
||||
const prodApiKey = this.configService.get<string>('AI_API_KEY');
|
||||
const prodModel = this.configService.get<string>('AI_MODEL') || 'qwen/qwen3.5-397b-a17b';
|
||||
|
||||
if (prodApiKey) {
|
||||
configs.push({
|
||||
role: 'production',
|
||||
name: 'Production',
|
||||
apiUrl: prodApiUrl,
|
||||
apiKey: prodApiKey,
|
||||
modelName: prodModel,
|
||||
});
|
||||
}
|
||||
|
||||
// Alternate models from DB
|
||||
const alternates = await this.dataSource.query(
|
||||
`SELECT slot, name, api_url, api_key, model_name
|
||||
FROM shared.shadow_ai_models
|
||||
WHERE is_active = true
|
||||
ORDER BY slot`,
|
||||
);
|
||||
|
||||
for (const alt of alternates) {
|
||||
configs.push({
|
||||
role: alt.slot === 'A' ? 'alternate_a' : 'alternate_b',
|
||||
name: alt.name,
|
||||
apiUrl: alt.api_url,
|
||||
apiKey: alt.api_key,
|
||||
modelName: alt.model_name,
|
||||
});
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
private getFeatureParams(feature: Feature): { temperature: number; maxTokens: number } {
|
||||
if (feature === 'investment_recommendations') {
|
||||
return { temperature: 0.3, maxTokens: 4096 };
|
||||
}
|
||||
return { temperature: 0.1, maxTokens: 2048 };
|
||||
}
|
||||
|
||||
private async executeModels(
|
||||
runId: string,
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
configs: ModelConfig[],
|
||||
feature: Feature,
|
||||
) {
|
||||
const { temperature, maxTokens } = this.getFeatureParams(feature);
|
||||
|
||||
const promises = configs.map(async (config) => {
|
||||
// Mark as running
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.shadow_run_results SET status = 'running' WHERE run_id = $1 AND model_role = $2`,
|
||||
[runId, config.role],
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await callOpenAICompatible({
|
||||
apiUrl: config.apiUrl,
|
||||
apiKey: config.apiKey,
|
||||
model: config.modelName,
|
||||
messages,
|
||||
temperature,
|
||||
maxTokens,
|
||||
});
|
||||
|
||||
// Try to parse the response as JSON
|
||||
let parsedResponse: any = null;
|
||||
try {
|
||||
parsedResponse = JSON.parse(result.content);
|
||||
} catch {
|
||||
// Store raw content if not valid JSON
|
||||
parsedResponse = { raw_text: result.content };
|
||||
}
|
||||
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.shadow_run_results
|
||||
SET status = 'success', raw_response = $1, parsed_response = $2,
|
||||
response_time_ms = $3, token_usage = $4
|
||||
WHERE run_id = $5 AND model_role = $6`,
|
||||
[
|
||||
result.rawResponse,
|
||||
JSON.stringify(parsedResponse),
|
||||
result.responseTimeMs,
|
||||
result.usage ? JSON.stringify(result.usage) : null,
|
||||
runId,
|
||||
config.role,
|
||||
],
|
||||
);
|
||||
|
||||
this.logger.log(`Shadow run ${runId} - ${config.role} (${config.modelName}) completed in ${result.responseTimeMs}ms`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Shadow run ${runId} - ${config.role} (${config.modelName}) failed: ${error.message}`);
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.shadow_run_results
|
||||
SET status = 'error', error_message = $1
|
||||
WHERE run_id = $2 AND model_role = $3`,
|
||||
[error.message, runId, config.role],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(promises);
|
||||
|
||||
// Determine overall run status
|
||||
const results = await this.dataSource.query(
|
||||
`SELECT status FROM shared.shadow_run_results WHERE run_id = $1`,
|
||||
[runId],
|
||||
);
|
||||
const allSuccess = results.every((r: any) => r.status === 'success');
|
||||
const allError = results.every((r: any) => r.status === 'error');
|
||||
const status = allSuccess ? 'completed' : allError ? 'failed' : 'partial';
|
||||
|
||||
await this.dataSource.query(
|
||||
`UPDATE shared.shadow_runs SET status = $1, completed_at = NOW() WHERE id = $2`,
|
||||
[status, runId],
|
||||
);
|
||||
|
||||
this.logger.log(`Shadow run ${runId} finished with status: ${status}`);
|
||||
}
|
||||
|
||||
// ── Table Creation (for initial setup) ──
|
||||
|
||||
async ensureTables() {
|
||||
const qr = this.dataSource.createQueryRunner();
|
||||
try {
|
||||
await qr.connect();
|
||||
|
||||
await qr.query(`
|
||||
CREATE TABLE IF NOT EXISTS shared.shadow_ai_models (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
slot VARCHAR(10) NOT NULL UNIQUE CHECK (slot IN ('A', 'B')),
|
||||
name VARCHAR(100) NOT NULL,
|
||||
api_url VARCHAR(500) NOT NULL,
|
||||
api_key VARCHAR(500) NOT NULL,
|
||||
model_name VARCHAR(200) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await qr.query(`
|
||||
CREATE TABLE IF NOT EXISTS shared.shadow_runs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
tenant_id UUID NOT NULL,
|
||||
feature VARCHAR(30) NOT NULL CHECK (feature IN ('operating_health', 'reserve_health', 'investment_recommendations')),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'partial', 'failed')),
|
||||
triggered_by UUID,
|
||||
prompt_messages JSONB NOT NULL,
|
||||
started_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
await qr.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_shadow_runs_tenant ON shared.shadow_runs(tenant_id)
|
||||
`);
|
||||
await qr.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_shadow_runs_created ON shared.shadow_runs(created_at DESC)
|
||||
`);
|
||||
|
||||
await qr.query(`
|
||||
CREATE TABLE IF NOT EXISTS shared.shadow_run_results (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
run_id UUID NOT NULL REFERENCES shared.shadow_runs(id) ON DELETE CASCADE,
|
||||
model_role VARCHAR(20) NOT NULL CHECK (model_role IN ('production', 'alternate_a', 'alternate_b')),
|
||||
model_name VARCHAR(200) NOT NULL,
|
||||
api_url VARCHAR(500) NOT NULL,
|
||||
raw_response TEXT,
|
||||
parsed_response JSONB,
|
||||
response_time_ms INTEGER,
|
||||
token_usage JSONB,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'success', 'error')),
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(run_id, model_role)
|
||||
)
|
||||
`);
|
||||
|
||||
await qr.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_shadow_results_run ON shared.shadow_run_results(run_id)
|
||||
`);
|
||||
|
||||
this.logger.log('Shadow AI tables ensured');
|
||||
} finally {
|
||||
await qr.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Res, UseGuards } from
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { 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')
|
||||
@@ -12,9 +13,11 @@ 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"' });
|
||||
@@ -22,17 +25,22 @@ 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); }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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')
|
||||
@@ -12,9 +13,11 @@ 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"' });
|
||||
@@ -22,19 +25,24 @@ 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); }
|
||||
}
|
||||
|
||||
@@ -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', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')),
|
||||
role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, organization_id)
|
||||
|
||||
9
db/migrations/020-add-vice-president-role.sql
Normal file
9
db/migrations/020-add-vice-president-role.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Migration 020: Add vice_president role to user_organizations
|
||||
-- This adds the vice_president role to the CHECK constraint on the role column.
|
||||
|
||||
ALTER TABLE shared.user_organizations
|
||||
DROP CONSTRAINT IF EXISTS user_organizations_role_check;
|
||||
|
||||
ALTER TABLE shared.user_organizations
|
||||
ADD CONSTRAINT user_organizations_role_check
|
||||
CHECK (role IN ('president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer'));
|
||||
230
docs/gitea-runner-setup.md
Normal file
230
docs/gitea-runner-setup.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Gitea Actions Runner Setup — HOALedgerIQ Production Server
|
||||
|
||||
This guide walks through setting up a self-hosted Gitea Actions runner on the production server so the deployment workflow (`.gitea/workflows/deploy.yml`) can execute automatically.
|
||||
|
||||
The runner uses **host execution mode** — jobs run directly on the server (not inside Docker containers) so the deploy script has access to Docker, the git repo, and the local filesystem.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Ubuntu Linux production server
|
||||
- Gitea instance (e.g., `https://git.sensetostyle.com`)
|
||||
- Docker and Docker Compose installed on the server
|
||||
- The HOALedgerIQ repo cloned at `/opt/hoa-ledgeriq`
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Enable Actions in Gitea
|
||||
|
||||
Ensure Actions are enabled in your Gitea configuration (`/etc/gitea/app.ini`):
|
||||
|
||||
```ini
|
||||
[actions]
|
||||
ENABLED = true
|
||||
```
|
||||
|
||||
Restart Gitea after making changes:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart gitea
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Get a Registration Token
|
||||
|
||||
1. Log into your Gitea instance
|
||||
2. Navigate to **Site Administration** → **Actions** → **Runners**
|
||||
3. Copy the **Registration Token**
|
||||
|
||||
> **Tip:** For tighter security, you can get a repo-scoped token instead:
|
||||
> Repo → **Settings** → **Actions** → **Runners** → copy the token shown there.
|
||||
> This limits the runner to only execute workflows from that specific repository.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Install the Act Runner Binary
|
||||
|
||||
```bash
|
||||
# Download the latest act_runner for x86_64 Linux
|
||||
wget https://dl.gitea.com/act_runner/latest/act_runner-linux-amd64
|
||||
|
||||
# Make executable and install to system path
|
||||
chmod +x act_runner-linux-amd64
|
||||
sudo mv act_runner-linux-amd64 /usr/local/bin/act_runner
|
||||
|
||||
# Verify installation
|
||||
act_runner --version
|
||||
```
|
||||
|
||||
> For ARM64 servers, use `act_runner-linux-arm64` instead.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Generate and Edit the Configuration
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/act_runner
|
||||
act_runner generate-config > /tmp/config.yaml
|
||||
```
|
||||
|
||||
Edit `/tmp/config.yaml` and set the **labels to use host execution mode**:
|
||||
|
||||
```yaml
|
||||
runner:
|
||||
labels:
|
||||
- "ubuntu-latest:host"
|
||||
- "ubuntu-22.04:host"
|
||||
```
|
||||
|
||||
The `:host` suffix tells the runner to execute jobs directly on the server rather than spinning up Docker containers. This is required because the deploy script needs access to:
|
||||
|
||||
- The Docker socket (to run `docker compose`)
|
||||
- The git repository at `/opt/hoa-ledgeriq`
|
||||
- The backup scripts and database
|
||||
|
||||
Move the config into place and lock down permissions:
|
||||
|
||||
```bash
|
||||
sudo mv /tmp/config.yaml /etc/act_runner/config.yaml
|
||||
sudo chmod 600 /etc/act_runner/config.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Register the Runner
|
||||
|
||||
```bash
|
||||
act_runner register \
|
||||
--no-interactive \
|
||||
--instance "https://git.sensetostyle.com" \
|
||||
--token "YOUR_REGISTRATION_TOKEN_HERE" \
|
||||
--name "hoaledgeriq-prod" \
|
||||
--labels "ubuntu-latest:host,ubuntu-22.04:host" \
|
||||
--config /etc/act_runner/config.yaml
|
||||
```
|
||||
|
||||
This creates a `.runner` file in the current directory containing the registration state.
|
||||
|
||||
> **Interactive alternative:** Run `act_runner register --config /etc/act_runner/config.yaml` and follow the prompts.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Set Up as a Systemd Service
|
||||
|
||||
Create the service file at `/etc/systemd/system/act_runner.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Gitea Actions Runner (HOALedgerIQ Prod)
|
||||
Documentation=https://docs.gitea.com/usage/actions/act-runner
|
||||
After=docker.service network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/hoa-ledgeriq
|
||||
ExecStart=/usr/local/bin/act_runner daemon --config /etc/act_runner/config.yaml
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
> **Security note on `User=root`:** The deploy script needs to run `docker compose`, `git reset --hard`, etc. If you have a dedicated deploy user in the `docker` group with write access to `/opt/hoa-ledgeriq`, use that instead. Running as root is the simplest option but grants maximum privileges.
|
||||
|
||||
Enable and start the service:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable act_runner
|
||||
sudo systemctl start act_runner
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Verify the Runner Is Online
|
||||
|
||||
Check the service is running:
|
||||
|
||||
```bash
|
||||
sudo systemctl status act_runner
|
||||
```
|
||||
|
||||
View logs:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u act_runner -f
|
||||
```
|
||||
|
||||
Then confirm in Gitea:
|
||||
|
||||
1. Go to **Site Administration** → **Actions** → **Runners**
|
||||
2. You should see **"hoaledgeriq-prod"** listed with status **Online**
|
||||
|
||||
---
|
||||
|
||||
## Step 8: Test the Workflow
|
||||
|
||||
1. Go to your repo on Gitea → **Actions** tab
|
||||
2. Select the **"Deploy to Production"** workflow
|
||||
3. Click **Run Workflow**
|
||||
4. If this is the first deployment against an existing database, check the **"Mark existing migrations as applied"** box
|
||||
5. Monitor the run in the Actions tab
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Runner shows as Offline
|
||||
|
||||
```bash
|
||||
# Check service status and logs
|
||||
sudo systemctl status act_runner
|
||||
sudo journalctl -u act_runner -n 50
|
||||
|
||||
# Verify the instance URL is reachable from the server
|
||||
wget -qO- https://git.sensetostyle.com/api/v1/version
|
||||
```
|
||||
|
||||
### Workflow stuck on "Waiting for runner"
|
||||
|
||||
- Verify the runner labels match what the workflow expects. The workflow uses `runs-on: ubuntu-latest` which must match the `ubuntu-latest:host` label.
|
||||
- Check the runner is registered at the correct scope (instance-wide, org-level, or repo-level).
|
||||
|
||||
### Permission denied errors during deploy
|
||||
|
||||
- Ensure the systemd service `User` has Docker access (`usermod -aG docker <user>`)
|
||||
- Ensure the user has write access to `/opt/hoa-ledgeriq`
|
||||
|
||||
### Re-registering after token expiry
|
||||
|
||||
```bash
|
||||
sudo systemctl stop act_runner
|
||||
# Get a new token from Gitea admin panel, then:
|
||||
act_runner register \
|
||||
--no-interactive \
|
||||
--instance "https://git.sensetostyle.com" \
|
||||
--token "NEW_TOKEN_HERE" \
|
||||
--name "hoaledgeriq-prod" \
|
||||
--labels "ubuntu-latest:host,ubuntu-22.04:host" \
|
||||
--config /etc/act_runner/config.yaml
|
||||
sudo systemctl start act_runner
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
| Concern | Recommendation |
|
||||
|---------|----------------|
|
||||
| Runner user | Use a dedicated user with `docker` group access rather than `root` when possible |
|
||||
| Registration token | Rotate periodically in the Gitea admin panel |
|
||||
| Config file | Keep `/etc/act_runner/config.yaml` at mode `600` (owner-read only) |
|
||||
| Runner scope | Register at the **repo level** instead of instance-wide so only this repo can trigger deployments |
|
||||
| Workflow triggers | The deploy workflow uses `workflow_dispatch` (manual only) — no automatic triggers on push |
|
||||
| Network | Ensure Gitea is accessed over HTTPS with valid SSL certificates |
|
||||
275
docs/shadow-ai-benchmarking-plan.md
Normal file
275
docs/shadow-ai-benchmarking-plan.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# Shadow AI Benchmarking Feature
|
||||
|
||||
## Context
|
||||
|
||||
The platform uses a single AI model (Qwen 3.5 via NVIDIA NIM) for three features: Operating Health Score, Reserve Health Score, and Investment Recommendations. The platform owner needs a way to evaluate alternate models (different providers, different versions) against the production model using real tenant data — without impacting users. This enables informed model migration decisions by comparing outputs side-by-side.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
- **New admin page** at `/admin/shadow-ai` with model configuration, run trigger, and history
|
||||
- **New backend module** `shadow-ai` with controller, service, and 3 entities
|
||||
- **3 new DB tables** in the `shared` schema for model configs, runs, and results
|
||||
- **Shared AI caller utility** to avoid duplicating HTTP logic
|
||||
- **Minimal changes** to existing services: make prompt-building methods public and export modules
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Shared AI Caller Utility
|
||||
|
||||
### New file: `backend/src/common/utils/ai-caller.ts`
|
||||
|
||||
Extract the HTTP POST logic (currently duplicated in both `callAI()` methods) into a reusable function:
|
||||
|
||||
```typescript
|
||||
export async function callOpenAICompatible(params: {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
timeoutMs?: number; // default 600000
|
||||
}): Promise<{
|
||||
content: string; // cleaned JSON string (fences + <think> stripped)
|
||||
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
|
||||
responseTimeMs: number;
|
||||
}>
|
||||
```
|
||||
|
||||
Handles: HTTPS POST to `{apiUrl}/chat/completions`, timeout, markdown fence stripping, `<think>` block removal, timing.
|
||||
|
||||
## Phase 2: Expose Existing Prompt Builders
|
||||
|
||||
### `backend/src/modules/health-scores/health-scores.service.ts`
|
||||
- Change `private` → `public` on:
|
||||
- `gatherOperatingData(qr)` (line 252)
|
||||
- `gatherReserveData(qr)` (line 523)
|
||||
- `buildOperatingPrompt(data)` (line 790)
|
||||
- `buildReservePrompt(data)` (line 930)
|
||||
- `checkDataReadiness(qr, scoreType)` (used to validate data exists)
|
||||
|
||||
### `backend/src/modules/health-scores/health-scores.module.ts`
|
||||
- Add `exports: [HealthScoresService]`
|
||||
|
||||
### `backend/src/modules/investment-planning/investment-planning.service.ts`
|
||||
- Add new public method `buildPromptForSchema(schemaName: string)` that:
|
||||
1. Creates a query runner, sets `search_path` to the tenant schema
|
||||
2. Runs the same data-gathering queries (financial snapshot, market rates, monthly forecast) using the query runner directly (bypassing request-scoped `TenantService`)
|
||||
3. Calls the existing `buildPromptMessages()` with gathered data
|
||||
4. Returns `Array<{ role: string; content: string }>`
|
||||
- Change `buildPromptMessages()` from `private` → `public` (line 880)
|
||||
|
||||
### `backend/src/modules/investment-planning/investment-planning.module.ts`
|
||||
- Add `exports: [InvestmentPlanningService]`
|
||||
|
||||
## Phase 3: Database Tables & Entities
|
||||
|
||||
### 3 new tables in `shared` schema
|
||||
|
||||
**`shared.shadow_ai_models`** — Alternate model configurations (slots A and B)
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID PK | |
|
||||
| slot | VARCHAR(10) | CHECK IN ('A', 'B'), UNIQUE |
|
||||
| name | VARCHAR(100) | Display label |
|
||||
| api_url | VARCHAR(500) | OpenAI-compatible endpoint |
|
||||
| api_key | VARCHAR(500) | Bearer token |
|
||||
| model_name | VARCHAR(200) | Model identifier |
|
||||
| is_active | BOOLEAN | Default true |
|
||||
| created_at | TIMESTAMPTZ | |
|
||||
| updated_at | TIMESTAMPTZ | |
|
||||
|
||||
**`shared.shadow_runs`** — One row per comparison execution
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID PK | |
|
||||
| tenant_id | UUID FK | → shared.organizations |
|
||||
| feature | VARCHAR(30) | CHECK IN ('operating_health', 'reserve_health', 'investment_recommendations') |
|
||||
| status | VARCHAR(20) | CHECK IN ('running', 'completed', 'partial', 'failed') |
|
||||
| triggered_by | UUID FK | → shared.users |
|
||||
| prompt_messages | JSONB | Exact messages sent to all models (proof of identical input) |
|
||||
| started_at | TIMESTAMPTZ | |
|
||||
| completed_at | TIMESTAMPTZ | |
|
||||
| created_at | TIMESTAMPTZ | |
|
||||
|
||||
**`shared.shadow_run_results`** — One row per model per run (up to 3 per run)
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| id | UUID PK | |
|
||||
| run_id | UUID FK | → shadow_runs ON DELETE CASCADE |
|
||||
| model_role | VARCHAR(20) | CHECK IN ('production', 'alternate_a', 'alternate_b'), UNIQUE(run_id, model_role) |
|
||||
| model_name | VARCHAR(200) | Snapshot of model used |
|
||||
| api_url | VARCHAR(500) | Snapshot of endpoint used |
|
||||
| raw_response | TEXT | Unprocessed AI response |
|
||||
| parsed_response | JSONB | Validated structured output |
|
||||
| response_time_ms | INTEGER | |
|
||||
| token_usage | JSONB | { prompt_tokens, completion_tokens, total_tokens } |
|
||||
| status | VARCHAR(20) | CHECK IN ('pending', 'running', 'success', 'error') |
|
||||
| error_message | TEXT | |
|
||||
| created_at | TIMESTAMPTZ | |
|
||||
|
||||
### Entity files
|
||||
- `backend/src/modules/shadow-ai/entities/shadow-ai-model.entity.ts`
|
||||
- `backend/src/modules/shadow-ai/entities/shadow-run.entity.ts`
|
||||
- `backend/src/modules/shadow-ai/entities/shadow-run-result.entity.ts`
|
||||
|
||||
All use `@Entity({ schema: 'shared', name: '...' })` pattern.
|
||||
|
||||
## Phase 4: Shadow AI Backend Module
|
||||
|
||||
### New directory: `backend/src/modules/shadow-ai/`
|
||||
|
||||
### `shadow-ai.service.ts`
|
||||
|
||||
**Model CRUD:**
|
||||
- `getModels()` — Return both slots, mask API keys (show last 4 chars)
|
||||
- `upsertModel(slot, dto)` — INSERT/UPDATE config for slot A or B
|
||||
- `deleteModel(slot)` — Remove model config
|
||||
|
||||
**Run Execution:**
|
||||
- `triggerRun(tenantId, feature, userId)`:
|
||||
1. Look up tenant `schema_name` from `shared.organizations`
|
||||
2. Build prompt messages by calling the appropriate exposed method:
|
||||
- `operating_health`: Create query runner → set search_path → `healthScoresService.gatherOperatingData(qr)` → `healthScoresService.buildOperatingPrompt(data)`
|
||||
- `reserve_health`: Same pattern with reserve methods
|
||||
- `investment_recommendations`: `investmentPlanningService.buildPromptForSchema(schemaName)`
|
||||
3. Insert `shadow_runs` row with `prompt_messages` stored as JSONB
|
||||
4. Get production config from env vars, alternate configs from DB
|
||||
5. Insert 1-3 `shadow_run_results` rows as 'pending' (production + active alternates)
|
||||
6. Return `{ runId }` immediately
|
||||
7. Fire-and-forget: call all models in parallel using `callOpenAICompatible()`
|
||||
- Per feature: operating/reserve use temp 0.1, max_tokens 2048; investment uses temp 0.3, max_tokens 4096
|
||||
8. Update each result row as it completes (success/error, parsed response, timing)
|
||||
9. Update run status when all complete
|
||||
|
||||
**History:**
|
||||
- `getRunHistory(page, limit, tenantFilter?, featureFilter?)` — Paginated list with tenant name JOIN
|
||||
- `getRunDetail(runId)` — Full run + all results
|
||||
|
||||
### `shadow-ai.controller.ts`
|
||||
|
||||
All endpoints use `@UseGuards(JwtAuthGuard)` + `requireSuperadmin(req)` pattern from `admin.controller.ts`.
|
||||
|
||||
| Method | Path | Body/Params |
|
||||
|--------|------|-------------|
|
||||
| GET | `/admin/shadow-ai/models` | — |
|
||||
| PUT | `/admin/shadow-ai/models/:slot` | `{ name, apiUrl, apiKey, modelName, isActive }` |
|
||||
| DELETE | `/admin/shadow-ai/models/:slot` | — |
|
||||
| POST | `/admin/shadow-ai/runs` | `{ tenantId, feature }` |
|
||||
| GET | `/admin/shadow-ai/runs` | `?page&limit&tenantId&feature` |
|
||||
| GET | `/admin/shadow-ai/runs/:id` | — |
|
||||
|
||||
### `shadow-ai.module.ts`
|
||||
|
||||
```typescript
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ShadowAiModel, ShadowRun, ShadowRunResult]),
|
||||
HealthScoresModule,
|
||||
InvestmentPlanningModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [ShadowAiController],
|
||||
providers: [ShadowAiService],
|
||||
})
|
||||
```
|
||||
|
||||
### Register in `backend/src/app.module.ts`
|
||||
- Add `import { ShadowAiModule }` and include in the `imports` array
|
||||
|
||||
## Phase 5: Frontend — Admin Shadow AI Page
|
||||
|
||||
### New file: `frontend/src/pages/admin/AdminShadowAiPage.tsx`
|
||||
|
||||
**Layout**: Mantine `Tabs` with 3 tabs
|
||||
|
||||
#### Tab 1: "Model Configuration"
|
||||
- Three `Card` components in a `SimpleGrid cols={3}`:
|
||||
- **Production** (read-only): Shows model name, API URL from a dedicated endpoint or hardcoded label "From environment config"
|
||||
- **Alternate A**: Form with `TextInput` (name, API URL, model name), `PasswordInput` (API key), `Switch` (active), Save/Delete buttons
|
||||
- **Alternate B**: Same form
|
||||
- Fetches via `GET /api/admin/shadow-ai/models`
|
||||
- Saves via `PUT /api/admin/shadow-ai/models/A` or `/B`
|
||||
|
||||
#### Tab 2: "Run Comparison"
|
||||
- `Select` dropdown for tenant (reuse `GET /api/admin/organizations` already used by AdminPage)
|
||||
- `Select` for feature type (Operating Health / Reserve Health / Investment Recommendations)
|
||||
- `Button` "Run Shadow Comparison"
|
||||
- On trigger: `POST /api/admin/shadow-ai/runs` → get `runId`
|
||||
- Poll `GET /api/admin/shadow-ai/runs/:id` every 3s via `refetchInterval` until status !== 'running'
|
||||
- Show per-model progress indicators during run
|
||||
- Once complete, render results using shared comparison component (below)
|
||||
|
||||
#### Tab 3: "History"
|
||||
- `Table` with columns: Date, Tenant, Feature, Status (Badge), Duration
|
||||
- Filter controls: tenant Select, feature Select
|
||||
- Click row → expand detail or modal showing full comparison
|
||||
- Pagination
|
||||
|
||||
#### Shared Component: Side-by-Side Results Display
|
||||
- `SimpleGrid cols={3}` (or fewer columns if only some models were configured)
|
||||
- Each column:
|
||||
- Header: model name + response time `Badge`
|
||||
- **For health scores**: Score with `RingProgress`, label `Badge`, summary text, factors list (color-coded by impact), recommendations list (color-coded by priority)
|
||||
- **For investment**: Overall assessment text, recommendation cards with type/priority badges, risk notes
|
||||
- Collapsible raw JSON via `Accordion`
|
||||
- **Diff highlighting**: Where parsed values differ across models, apply subtle background highlight (e.g., `yellow.0` in Mantine theme). Simple recursive comparison of JSON keys/values.
|
||||
|
||||
### Route addition: `frontend/src/App.tsx`
|
||||
Within the `/admin` route group (after `<Route index element={<AdminPage />} />`):
|
||||
```tsx
|
||||
<Route path="shadow-ai" element={<AdminShadowAiPage />} />
|
||||
```
|
||||
|
||||
### Sidebar nav: `frontend/src/components/layout/Sidebar.tsx`
|
||||
In the `isAdminOnly` section (after the "Admin Panel" NavLink, around line 134):
|
||||
```tsx
|
||||
<NavLink
|
||||
label="AI Benchmarking"
|
||||
leftSection={<IconScale size={18} />}
|
||||
active={location.pathname === '/admin/shadow-ai'}
|
||||
onClick={() => go('/admin/shadow-ai')}
|
||||
color="violet"
|
||||
/>
|
||||
```
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **`ai-caller.ts`** — Shared utility (no dependencies)
|
||||
2. **Health scores + investment planning** — Make methods public, add exports, add `buildPromptForSchema`
|
||||
3. **Entities** — 3 TypeORM entity files
|
||||
4. **Service + Controller + Module** — Shadow AI backend
|
||||
5. **Register module** in `app.module.ts`
|
||||
6. **Frontend page** — `AdminShadowAiPage.tsx`
|
||||
7. **Route + Sidebar** — Wire up navigation
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Backend**: Start server, confirm no TypeORM errors for new entities
|
||||
2. **Model config**: Use admin UI to save/load/delete alternate model configs
|
||||
3. **Run comparison**: Select a tenant, trigger a run, verify all 3 models are called with identical prompts
|
||||
4. **Results display**: Confirm side-by-side output renders correctly for all 3 feature types
|
||||
5. **History**: Verify past runs are persisted and browsable
|
||||
6. **Auth**: Confirm non-superadmin users get 403 on all shadow-ai endpoints
|
||||
7. **Production safety**: Verify no changes to production AI behavior — shadow runs are completely isolated
|
||||
|
||||
## Key Files to Modify
|
||||
|
||||
- `backend/src/modules/health-scores/health-scores.service.ts` — Make 5 methods public
|
||||
- `backend/src/modules/health-scores/health-scores.module.ts` — Add exports
|
||||
- `backend/src/modules/investment-planning/investment-planning.service.ts` — Add `buildPromptForSchema()`, make `buildPromptMessages()` public
|
||||
- `backend/src/modules/investment-planning/investment-planning.module.ts` — Add exports
|
||||
- `backend/src/app.module.ts` — Register ShadowAiModule
|
||||
- `frontend/src/App.tsx` — Add route
|
||||
- `frontend/src/components/layout/Sidebar.tsx` — Add nav item
|
||||
|
||||
## New Files
|
||||
|
||||
- `backend/src/common/utils/ai-caller.ts`
|
||||
- `backend/src/modules/shadow-ai/shadow-ai.module.ts`
|
||||
- `backend/src/modules/shadow-ai/shadow-ai.service.ts`
|
||||
- `backend/src/modules/shadow-ai/shadow-ai.controller.ts`
|
||||
- `backend/src/modules/shadow-ai/entities/shadow-ai-model.entity.ts`
|
||||
- `backend/src/modules/shadow-ai/entities/shadow-run.entity.ts`
|
||||
- `backend/src/modules/shadow-ai/entities/shadow-run-result.entity.ts`
|
||||
- `frontend/src/pages/admin/AdminShadowAiPage.tsx`
|
||||
@@ -30,6 +30,7 @@ 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';
|
||||
@@ -41,6 +42,7 @@ 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';
|
||||
|
||||
@@ -135,6 +137,7 @@ 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) */}
|
||||
@@ -180,6 +183,7 @@ 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>
|
||||
);
|
||||
|
||||
@@ -77,8 +77,9 @@ export function AppLayout() {
|
||||
navigate('/admin');
|
||||
};
|
||||
|
||||
// Tenant admins (president role) can manage org members
|
||||
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
|
||||
// Capability-based check: can this user manage members?
|
||||
const capabilities = currentOrg?.capabilities || [];
|
||||
const isTenantAdmin = user?.isSuperadmin || capabilities.includes('settings.members.manage');
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
|
||||
@@ -23,57 +23,60 @@ 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' },
|
||||
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard', capability: C.DASHBOARD_VIEW },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Financials',
|
||||
items: [
|
||||
{ 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: '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: 'Assessments',
|
||||
items: [
|
||||
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
|
||||
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
|
||||
{ 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: 'Board Planning',
|
||||
items: [
|
||||
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets' },
|
||||
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets', capability: C.PLANNING_BUDGETS_VIEW },
|
||||
{
|
||||
label: 'Projects', icon: IconShieldCheck, path: '/projects',
|
||||
label: 'Projects', icon: IconShieldCheck, path: '/projects', capability: C.PLANNING_PROJECTS_VIEW,
|
||||
children: [
|
||||
{ label: 'Capital Planning', path: '/capital-projects' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
|
||||
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments', capability: C.PLANNING_SCENARIOS_VIEW,
|
||||
},
|
||||
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
|
||||
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
|
||||
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
|
||||
{ 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: 'Board Reference',
|
||||
items: [
|
||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors', capability: C.REFERENCE_VENDORS_VIEW },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Transactions',
|
||||
items: [
|
||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions', capability: C.TRANSACTIONS_VIEW },
|
||||
// Invoices and Payments hidden — see PARKING-LOT.md for future re-enablement
|
||||
// { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||
// { label: 'Payments', icon: IconCash, path: '/payments' },
|
||||
@@ -86,6 +89,7 @@ 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' },
|
||||
@@ -114,6 +118,15 @@ 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?.();
|
||||
@@ -140,6 +153,13 @@ 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" />
|
||||
@@ -157,7 +177,10 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
|
||||
return (
|
||||
<ScrollArea p="sm" data-tour="sidebar-nav">
|
||||
{navSections.map((section, sIdx) => (
|
||||
{navSections.map((section, sIdx) => {
|
||||
const visibleItems = section.items.filter((item: any) => hasCapability(item.capability));
|
||||
if (visibleItems.length === 0) return null;
|
||||
return (
|
||||
<div key={sIdx}>
|
||||
{section.label && (
|
||||
<>
|
||||
@@ -167,7 +190,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{section.items.map((item: any) =>
|
||||
{visibleItems.map((item: any) =>
|
||||
item.children && !item.path ? (
|
||||
// Collapsible group without a parent route (e.g. Reports)
|
||||
<NavLink
|
||||
@@ -223,7 +246,8 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{user?.isSuperadmin && (
|
||||
<>
|
||||
@@ -245,6 +269,13 @@ 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>
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
|
||||
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
|
||||
|
||||
@@ -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 = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT);
|
||||
|
||||
// ── Accounts query ──
|
||||
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
||||
|
||||
780
frontend/src/pages/admin/AdminShadowAiPage.tsx
Normal file
780
frontend/src/pages/admin/AdminShadowAiPage.tsx
Normal file
@@ -0,0 +1,780 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Title, Text, Card, SimpleGrid, Group, Stack, Badge, Loader, Center,
|
||||
Tabs, TextInput, Button, PasswordInput, Select, Table, Accordion,
|
||||
Switch, Paper, RingProgress, Divider, Alert, Code, ScrollArea, Box,
|
||||
Tooltip, ActionIcon,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconScale, IconSettings, IconPlayerPlay, IconHistory,
|
||||
IconCheck, IconX, IconAlertTriangle, IconClock, IconTrash,
|
||||
IconRefresh, IconArrowRight, IconChevronDown,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
|
||||
// ── Interfaces ──
|
||||
|
||||
interface ShadowModel {
|
||||
id: string;
|
||||
slot: string;
|
||||
name: string;
|
||||
api_url: string;
|
||||
api_key: string;
|
||||
model_name: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ShadowRunResult {
|
||||
id: string;
|
||||
run_id: string;
|
||||
model_role: string;
|
||||
model_name: string;
|
||||
api_url: string;
|
||||
raw_response: string;
|
||||
parsed_response: any;
|
||||
response_time_ms: number;
|
||||
token_usage: any;
|
||||
status: string;
|
||||
error_message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ShadowRun {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
tenant_name: string;
|
||||
feature: string;
|
||||
status: string;
|
||||
prompt_messages: any;
|
||||
started_at: string;
|
||||
completed_at: string;
|
||||
created_at: string;
|
||||
results: ShadowRunResult[];
|
||||
result_count?: string;
|
||||
success_count?: string;
|
||||
}
|
||||
|
||||
interface AdminOrg {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// ── Helper Functions ──
|
||||
|
||||
const featureLabels: Record<string, string> = {
|
||||
operating_health: 'Operating Health',
|
||||
reserve_health: 'Reserve Health',
|
||||
investment_recommendations: 'Investment Recommendations',
|
||||
};
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
production: 'Production',
|
||||
alternate_a: 'Alternate A',
|
||||
alternate_b: 'Alternate B',
|
||||
};
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
running: 'blue',
|
||||
completed: 'green',
|
||||
partial: 'yellow',
|
||||
failed: 'red',
|
||||
pending: 'gray',
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
};
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (!ms) return '-';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function formatDate(d: string): string {
|
||||
if (!d) return '-';
|
||||
return new Date(d).toLocaleString();
|
||||
}
|
||||
|
||||
// ── Model Configuration Tab ──
|
||||
|
||||
function ModelConfigTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: models, isLoading } = useQuery<ShadowModel[]>({
|
||||
queryKey: ['shadow-ai-models'],
|
||||
queryFn: () => api.get('/admin/shadow-ai/models').then((r) => r.data),
|
||||
});
|
||||
|
||||
const modelA = models?.find((m) => m.slot === 'A');
|
||||
const modelB = models?.find((m) => m.slot === 'B');
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
Configure alternate AI models to benchmark against the production model.
|
||||
Each model can use any OpenAI-compatible API endpoint.
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }}>
|
||||
<ProductionModelCard />
|
||||
<ModelSlotCard slot="A" model={modelA} isLoading={isLoading} />
|
||||
<ModelSlotCard slot="B" model={modelB} isLoading={isLoading} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductionModelCard() {
|
||||
return (
|
||||
<Card withBorder shadow="sm">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>Production Model</Text>
|
||||
<Badge color="green" variant="light">Active</Badge>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Text size="sm" c="dimmed">Configured via environment variables</Text>
|
||||
<TextInput label="Model" value="(from AI_MODEL env var)" readOnly disabled size="sm" />
|
||||
<TextInput label="API URL" value="(from AI_API_URL env var)" readOnly disabled size="sm" />
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
Production model settings are managed through server environment
|
||||
variables and cannot be changed from the UI.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelSlotCard({ slot, model, isLoading }: { slot: string; model?: ShadowModel; isLoading: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [name, setName] = useState('');
|
||||
const [apiUrl, setApiUrl] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [modelName, setModelName] = useState('');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (model) {
|
||||
setName(model.name);
|
||||
setApiUrl(model.api_url);
|
||||
setApiKey(model.api_key);
|
||||
setModelName(model.model_name);
|
||||
setIsActive(model.is_active);
|
||||
}
|
||||
}, [model]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => api.put(`/admin/shadow-ai/models/${slot}`, { name, apiUrl, apiKey, modelName, isActive }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['shadow-ai-models'] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/admin/shadow-ai/models/${slot}`),
|
||||
onSuccess: () => {
|
||||
setName(''); setApiUrl(''); setApiKey(''); setModelName(''); setIsActive(true);
|
||||
queryClient.invalidateQueries({ queryKey: ['shadow-ai-models'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <Card withBorder shadow="sm"><Center h={200}><Loader size="sm" /></Center></Card>;
|
||||
|
||||
return (
|
||||
<Card withBorder shadow="sm">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>Alternate {slot}</Text>
|
||||
{model ? (
|
||||
<Badge color={isActive ? 'blue' : 'gray'} variant="light">
|
||||
{isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="gray" variant="light">Not configured</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Divider />
|
||||
<TextInput label="Display Name" placeholder="e.g. GPT-4o" value={name} onChange={(e) => setName(e.target.value)} size="sm" />
|
||||
<TextInput label="API URL" description="Base URL only — /chat/completions is added automatically" placeholder="https://openrouter.ai/api/v1" value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} size="sm" />
|
||||
<PasswordInput label="API Key" placeholder="sk-..." value={apiKey} onChange={(e) => setApiKey(e.target.value)} size="sm" />
|
||||
<TextInput label="Model Name" placeholder="gpt-4o" value={modelName} onChange={(e) => setModelName(e.target.value)} size="sm" />
|
||||
<Switch label="Active" checked={isActive} onChange={(e) => setIsActive(e.currentTarget.checked)} />
|
||||
<Group>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
loading={saveMutation.isPending}
|
||||
disabled={!name || !apiUrl || !apiKey || !modelName}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{model && (
|
||||
<Button size="sm" color="red" variant="light" onClick={() => deleteMutation.mutate()} loading={deleteMutation.isPending}>
|
||||
<IconTrash size={16} />
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
{saveMutation.isError && <Text size="xs" c="red">Failed to save</Text>}
|
||||
{saveMutation.isSuccess && <Text size="xs" c="green">Saved</Text>}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Run Comparison Tab ──
|
||||
|
||||
function RunComparisonTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [tenantId, setTenantId] = useState<string | null>(null);
|
||||
const [feature, setFeature] = useState<string | null>(null);
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
||||
|
||||
const { data: orgs } = useQuery<AdminOrg[]>({
|
||||
queryKey: ['admin-orgs'],
|
||||
queryFn: () => api.get('/admin/organizations').then((r) => r.data),
|
||||
});
|
||||
|
||||
const triggerMutation = useMutation({
|
||||
mutationFn: () => api.post('/admin/shadow-ai/runs', { tenantId, feature }),
|
||||
onSuccess: (res) => {
|
||||
setActiveRunId(res.data.runId);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: activeRun } = useQuery<ShadowRun>({
|
||||
queryKey: ['shadow-ai-run', activeRunId],
|
||||
queryFn: () => api.get(`/admin/shadow-ai/runs/${activeRunId}`).then((r) => r.data),
|
||||
enabled: !!activeRunId,
|
||||
refetchInterval: (query) => {
|
||||
const run = query.state.data;
|
||||
return run?.status === 'running' ? 3000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const orgOptions = (orgs || [])
|
||||
.filter((o) => o.status === 'active')
|
||||
.map((o) => ({ value: o.id, label: o.name }));
|
||||
|
||||
const featureOptions = [
|
||||
{ value: 'operating_health', label: 'Operating Health Score' },
|
||||
{ value: 'reserve_health', label: 'Reserve Health Score' },
|
||||
{ value: 'investment_recommendations', label: 'Investment Recommendations' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Card withBorder shadow="sm">
|
||||
<Stack gap="md">
|
||||
<Text fw={600}>Run Shadow Comparison</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<Select
|
||||
label="Tenant"
|
||||
placeholder="Select a tenant"
|
||||
data={orgOptions}
|
||||
value={tenantId}
|
||||
onChange={setTenantId}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="AI Feature"
|
||||
placeholder="Select feature"
|
||||
data={featureOptions}
|
||||
value={feature}
|
||||
onChange={setFeature}
|
||||
/>
|
||||
<Stack justify="flex-end">
|
||||
<Button
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
onClick={() => triggerMutation.mutate()}
|
||||
loading={triggerMutation.isPending}
|
||||
disabled={!tenantId || !feature}
|
||||
>
|
||||
Run Comparison
|
||||
</Button>
|
||||
</Stack>
|
||||
</SimpleGrid>
|
||||
{triggerMutation.isError && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
||||
Failed to start comparison. Ensure at least one alternate model is configured.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{activeRun && (
|
||||
<Card withBorder shadow="sm">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<Text fw={600}>
|
||||
{featureLabels[activeRun.feature] || activeRun.feature}
|
||||
</Text>
|
||||
<Badge color={statusColor[activeRun.status]}>{activeRun.status}</Badge>
|
||||
</Group>
|
||||
{activeRun.tenant_name && (
|
||||
<Text size="sm" c="dimmed">Tenant: {activeRun.tenant_name}</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{activeRun.status === 'running' && (
|
||||
<Center py="md">
|
||||
<Stack align="center" gap="xs">
|
||||
<Loader size="md" />
|
||||
<Text size="sm" c="dimmed">Running models... This may take a few minutes.</Text>
|
||||
<Group gap="xs">
|
||||
{(activeRun.results || []).map((r) => (
|
||||
<Badge key={r.model_role} color={statusColor[r.status]} variant="light">
|
||||
{roleLabels[r.model_role]}: {r.status}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{activeRun.status !== 'running' && activeRun.results && (
|
||||
<ComparisonResults results={activeRun.results} feature={activeRun.feature} />
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── History Tab ──
|
||||
|
||||
function HistoryTab() {
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
const [tenantFilter, setTenantFilter] = useState<string | null>(null);
|
||||
const [featureFilter, setFeatureFilter] = useState<string | null>(null);
|
||||
|
||||
const { data: orgs } = useQuery<AdminOrg[]>({
|
||||
queryKey: ['admin-orgs'],
|
||||
queryFn: () => api.get('/admin/organizations').then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: historyData, isLoading } = useQuery({
|
||||
queryKey: ['shadow-ai-runs', tenantFilter, featureFilter],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (tenantFilter) params.set('tenantId', tenantFilter);
|
||||
if (featureFilter) params.set('feature', featureFilter);
|
||||
params.set('limit', '50');
|
||||
return api.get(`/admin/shadow-ai/runs?${params}`).then((r) => r.data);
|
||||
},
|
||||
});
|
||||
|
||||
const { data: selectedRun } = useQuery<ShadowRun>({
|
||||
queryKey: ['shadow-ai-run', selectedRunId],
|
||||
queryFn: () => api.get(`/admin/shadow-ai/runs/${selectedRunId}`).then((r) => r.data),
|
||||
enabled: !!selectedRunId,
|
||||
});
|
||||
|
||||
const orgOptions = [
|
||||
{ value: '', label: 'All Tenants' },
|
||||
...(orgs || []).map((o) => ({ value: o.id, label: o.name })),
|
||||
];
|
||||
|
||||
const featureOptions = [
|
||||
{ value: '', label: 'All Features' },
|
||||
{ value: 'operating_health', label: 'Operating Health' },
|
||||
{ value: 'reserve_health', label: 'Reserve Health' },
|
||||
{ value: 'investment_recommendations', label: 'Investment Recommendations' },
|
||||
];
|
||||
|
||||
const runs: ShadowRun[] = historyData?.runs || [];
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group>
|
||||
<Select
|
||||
size="sm"
|
||||
placeholder="Filter by tenant"
|
||||
data={orgOptions}
|
||||
value={tenantFilter || ''}
|
||||
onChange={(v) => setTenantFilter(v || null)}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
placeholder="Filter by feature"
|
||||
data={featureOptions}
|
||||
value={featureFilter || ''}
|
||||
onChange={(v) => setFeatureFilter(v || null)}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Center py="xl"><Loader /></Center>
|
||||
) : runs.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">No shadow runs found.</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Date</Table.Th>
|
||||
<Table.Th>Tenant</Table.Th>
|
||||
<Table.Th>Feature</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Models</Table.Th>
|
||||
<Table.Th>Duration</Table.Th>
|
||||
<Table.Th></Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{runs.map((run) => {
|
||||
const duration = run.completed_at && run.started_at
|
||||
? new Date(run.completed_at).getTime() - new Date(run.started_at).getTime()
|
||||
: null;
|
||||
return (
|
||||
<Table.Tr
|
||||
key={run.id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setSelectedRunId(run.id)}
|
||||
bg={selectedRunId === run.id ? 'var(--mantine-color-blue-light)' : undefined}
|
||||
>
|
||||
<Table.Td>{formatDate(run.created_at)}</Table.Td>
|
||||
<Table.Td>{run.tenant_name || '-'}</Table.Td>
|
||||
<Table.Td>{featureLabels[run.feature] || run.feature}</Table.Td>
|
||||
<Table.Td><Badge color={statusColor[run.status]} size="sm">{run.status}</Badge></Table.Td>
|
||||
<Table.Td>{run.success_count || '0'}/{run.result_count || '0'}</Table.Td>
|
||||
<Table.Td>{formatDuration(duration)}</Table.Td>
|
||||
<Table.Td><IconArrowRight size={14} /></Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{selectedRun && selectedRun.results && (
|
||||
<Card withBorder shadow="sm" mt="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<Text fw={600}>{featureLabels[selectedRun.feature] || selectedRun.feature}</Text>
|
||||
<Badge color={statusColor[selectedRun.status]}>{selectedRun.status}</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{selectedRun.tenant_name} | {formatDate(selectedRun.created_at)}
|
||||
</Text>
|
||||
</Group>
|
||||
<ComparisonResults results={selectedRun.results} feature={selectedRun.feature} />
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Comparison Results Component ──
|
||||
|
||||
function ComparisonResults({ results, feature }: { results: ShadowRunResult[]; feature: string }) {
|
||||
const isHealthScore = feature === 'operating_health' || feature === 'reserve_health';
|
||||
|
||||
// Collect all parsed values for diff highlighting
|
||||
const parsedValues = results
|
||||
.filter((r) => r.status === 'success' && r.parsed_response)
|
||||
.map((r) => r.parsed_response);
|
||||
|
||||
return (
|
||||
<SimpleGrid cols={{ base: 1, md: Math.min(results.length, 3) }}>
|
||||
{results.map((result) => (
|
||||
<ResultCard
|
||||
key={result.model_role}
|
||||
result={result}
|
||||
isHealthScore={isHealthScore}
|
||||
allParsed={parsedValues}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultCard({
|
||||
result,
|
||||
isHealthScore,
|
||||
allParsed,
|
||||
}: {
|
||||
result: ShadowRunResult;
|
||||
isHealthScore: boolean;
|
||||
allParsed: any[];
|
||||
}) {
|
||||
const roleColor: Record<string, string> = {
|
||||
production: 'green',
|
||||
alternate_a: 'blue',
|
||||
alternate_b: 'violet',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder shadow="xs" padding="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Badge color={roleColor[result.model_role] || 'gray'} variant="filled">
|
||||
{roleLabels[result.model_role]}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Badge
|
||||
color={statusColor[result.status]}
|
||||
variant="light"
|
||||
leftSection={result.status === 'success' ? <IconCheck size={12} /> : result.status === 'error' ? <IconX size={12} /> : <IconClock size={12} />}
|
||||
>
|
||||
{result.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Text size="xs" c="dimmed" truncate>{result.model_name}</Text>
|
||||
|
||||
{result.response_time_ms && (
|
||||
<Badge color="gray" variant="light" size="sm">
|
||||
{formatDuration(result.response_time_ms)}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{result.token_usage && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Tokens: {result.token_usage.prompt_tokens || '?'} prompt / {result.token_usage.completion_tokens || '?'} completion
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{result.status === 'error' && (
|
||||
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
|
||||
<Text size="sm">{result.error_message || 'Unknown error'}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result.status === 'success' && result.parsed_response && (
|
||||
isHealthScore
|
||||
? <HealthScoreDisplay data={result.parsed_response} allParsed={allParsed} />
|
||||
: <InvestmentDisplay data={result.parsed_response} allParsed={allParsed} />
|
||||
)}
|
||||
|
||||
{result.status === 'success' && (
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="raw">
|
||||
<Accordion.Control>
|
||||
<Text size="xs">Raw JSON Response</Text>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<ScrollArea h={300}>
|
||||
<Code block style={{ fontSize: 11 }}>
|
||||
{JSON.stringify(result.parsed_response, null, 2)}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Health Score Display ──
|
||||
|
||||
function HealthScoreDisplay({ data, allParsed }: { data: any; allParsed: any[] }) {
|
||||
const score = data.score ?? data.raw_text;
|
||||
const label = data.label || '';
|
||||
const summary = data.summary || '';
|
||||
const factors = data.factors || [];
|
||||
const recommendations = data.recommendations || [];
|
||||
|
||||
// Check if score differs from other models
|
||||
const scores = allParsed.map((p) => p.score).filter((s) => typeof s === 'number');
|
||||
const scoreDiffers = scores.length > 1 && !scores.every((s) => s === scores[0]);
|
||||
|
||||
const labelColor: Record<string, string> = {
|
||||
Excellent: 'green', Good: 'teal', Fair: 'yellow',
|
||||
'Needs Attention': 'orange', 'At Risk': 'red', Critical: 'red',
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{typeof score === 'number' && (
|
||||
<Group justify="center">
|
||||
<Box bg={scoreDiffers ? 'yellow.0' : undefined} p="xs" style={{ borderRadius: 8 }}>
|
||||
<RingProgress
|
||||
size={100}
|
||||
thickness={10}
|
||||
roundCaps
|
||||
sections={[{ value: score, color: labelColor[label] || 'blue' }]}
|
||||
label={
|
||||
<Text ta="center" fw={700} size="lg">{score}</Text>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{label && (
|
||||
<Group justify="center">
|
||||
<Badge color={labelColor[label] || 'gray'} size="lg">{label}</Badge>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{summary && <Text size="sm">{summary}</Text>}
|
||||
|
||||
{factors.length > 0 && (
|
||||
<>
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase">Factors</Text>
|
||||
{factors.map((f: any, i: number) => (
|
||||
<Group key={i} gap="xs" wrap="nowrap">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={f.impact === 'positive' ? 'green' : f.impact === 'negative' ? 'red' : 'gray'}
|
||||
>
|
||||
{f.impact}
|
||||
</Badge>
|
||||
<Text size="xs" style={{ flex: 1 }}><b>{f.name}:</b> {f.detail}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{recommendations.length > 0 && (
|
||||
<>
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase">Recommendations</Text>
|
||||
{recommendations.map((r: any, i: number) => (
|
||||
<Group key={i} gap="xs" wrap="nowrap">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={r.priority === 'high' ? 'red' : r.priority === 'medium' ? 'yellow' : 'blue'}
|
||||
>
|
||||
{r.priority}
|
||||
</Badge>
|
||||
<Text size="xs" style={{ flex: 1 }}>{r.text}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Investment Display ──
|
||||
|
||||
function InvestmentDisplay({ data, allParsed }: { data: any; allParsed: any[] }) {
|
||||
const recommendations = data.recommendations || [];
|
||||
const overall = data.overall_assessment || '';
|
||||
const riskNotes = data.risk_notes || [];
|
||||
|
||||
const recCounts = allParsed.map((p) => (p.recommendations || []).length);
|
||||
const countDiffers = recCounts.length > 1 && !recCounts.every((c) => c === recCounts[0]);
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
cd_ladder: 'violet', new_investment: 'blue', reallocation: 'teal',
|
||||
maturity_action: 'orange', liquidity_warning: 'red', general: 'gray',
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{overall && (
|
||||
<Paper p="xs" bg="gray.0" radius="sm">
|
||||
<Text size="sm">{overall}</Text>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{recommendations.length > 0 && (
|
||||
<>
|
||||
<Group gap="xs">
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||
Recommendations
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={countDiffers ? 'yellow' : 'gray'}
|
||||
>
|
||||
{recommendations.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
{recommendations.map((rec: any, i: number) => (
|
||||
<Card key={i} withBorder padding="xs" radius="sm">
|
||||
<Stack gap={4}>
|
||||
<Group gap="xs">
|
||||
<Badge size="xs" color={typeColors[rec.type] || 'gray'}>{rec.type}</Badge>
|
||||
<Badge size="xs" variant="light" color={rec.priority === 'high' ? 'red' : rec.priority === 'medium' ? 'yellow' : 'blue'}>
|
||||
{rec.priority}
|
||||
</Badge>
|
||||
{rec.fund_type && <Badge size="xs" variant="outline">{rec.fund_type}</Badge>}
|
||||
</Group>
|
||||
<Text size="sm" fw={600}>{rec.title}</Text>
|
||||
<Text size="xs">{rec.summary}</Text>
|
||||
{rec.suggested_amount && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Amount: ${rec.suggested_amount.toLocaleString()}
|
||||
{rec.suggested_rate ? ` | Rate: ${rec.suggested_rate}%` : ''}
|
||||
{rec.suggested_term ? ` | Term: ${rec.suggested_term}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{riskNotes.length > 0 && (
|
||||
<>
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase">Risk Notes</Text>
|
||||
{riskNotes.map((note: string, i: number) => (
|
||||
<Group key={i} gap="xs" wrap="nowrap">
|
||||
<IconAlertTriangle size={14} color="orange" />
|
||||
<Text size="xs">{note}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ──
|
||||
|
||||
export function AdminShadowAiPage() {
|
||||
return (
|
||||
<Stack gap="lg" p="md">
|
||||
<Group>
|
||||
<IconScale size={28} />
|
||||
<Title order={2}>AI Benchmarking</Title>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">
|
||||
Compare AI model outputs side-by-side using real tenant data.
|
||||
Configure alternate models, run shadow comparisons, and review historical results.
|
||||
</Text>
|
||||
|
||||
<Tabs defaultValue="run">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="config" leftSection={<IconSettings size={16} />}>
|
||||
Model Configuration
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="run" leftSection={<IconPlayerPlay size={16} />}>
|
||||
Run Comparison
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="history" leftSection={<IconHistory size={16} />}>
|
||||
History
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="config" pt="md">
|
||||
<ModelConfigTab />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="run" pt="md">
|
||||
<RunComparisonTab />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="history" pt="md">
|
||||
<HistoryTab />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
|
||||
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 = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.ASSESSMENTS_GROUPS_EDIT);
|
||||
|
||||
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
|
||||
queryKey: ['assessment-groups'],
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||
|
||||
interface PlanLine {
|
||||
@@ -87,7 +87,7 @@ const statusColors: Record<string, string> = {
|
||||
|
||||
export function BudgetPlanningPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const isReadOnly = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_BUDGETS_EDIT);
|
||||
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';
|
||||
|
||||
@@ -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 { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
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 = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_BUDGETS_EDIT);
|
||||
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';
|
||||
|
||||
@@ -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 { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
|
||||
|
||||
// ---- Data fetching ----
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useHasAnyCapability, CAPABILITIES } from '../../permissions';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface HealthScore {
|
||||
@@ -350,7 +351,11 @@ interface DashboardData {
|
||||
|
||||
export function DashboardPage() {
|
||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||
const isReadOnly = useIsReadOnly();
|
||||
const isReadOnly = !useHasAnyCapability(
|
||||
CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT,
|
||||
CAPABILITIES.FINANCIALS_BUDGETS_EDIT,
|
||||
CAPABILITIES.FINANCIALS_ACTUALS_EDIT,
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
@@ -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 { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
|
||||
// ── 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 = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_INVESTMENTS_EDIT);
|
||||
|
||||
// Load investment scenarios for the "Add to Plan" modal
|
||||
const { data: investmentScenarios } = useQuery<any[]>({
|
||||
|
||||
@@ -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 { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
|
||||
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 = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_INVESTMENTS_EDIT);
|
||||
|
||||
const { data: investments = [], isLoading } = useQuery<Investment[]>({
|
||||
queryKey: ['investments'],
|
||||
|
||||
@@ -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 { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
|
||||
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 = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
|
||||
|
||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||
queryKey: ['invoices'],
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
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 = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_ACTUALS_EDIT);
|
||||
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';
|
||||
|
||||
@@ -12,8 +12,10 @@ 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, useIsReadOnly } from '../../stores/authStore';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useCanEdit, useHasCapability, CAPABILITIES } from '../../permissions';
|
||||
|
||||
interface OrgMember {
|
||||
id: string;
|
||||
@@ -29,19 +31,21 @@ interface OrgMember {
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'president', label: 'President' },
|
||||
{ value: 'vice_president', label: 'Vice President' },
|
||||
{ value: 'treasurer', label: 'Treasurer' },
|
||||
{ value: 'secretary', label: 'Secretary' },
|
||||
{ value: 'board_member', label: 'Board Member' },
|
||||
{ value: 'property_manager', label: 'Property Manager' },
|
||||
{ value: 'member_at_large', label: 'Member at Large' },
|
||||
{ value: 'manager', label: 'Property Manager' },
|
||||
{ value: 'viewer', label: 'Viewer (Read-Only)' },
|
||||
];
|
||||
|
||||
const roleColors: Record<string, string> = {
|
||||
president: 'red',
|
||||
vice_president: 'grape',
|
||||
treasurer: 'blue',
|
||||
secretary: 'green',
|
||||
board_member: 'violet',
|
||||
property_manager: 'orange',
|
||||
member_at_large: 'violet',
|
||||
manager: 'orange',
|
||||
viewer: 'gray',
|
||||
admin: 'red',
|
||||
};
|
||||
@@ -52,7 +56,9 @@ export function OrgMembersPage() {
|
||||
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { user, currentOrg } = useAuthStore();
|
||||
const isReadOnly = useIsReadOnly();
|
||||
const navigate = useNavigate();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.SETTINGS_MEMBERS_MANAGE);
|
||||
const canManagePermissions = useHasCapability(CAPABILITIES.SETTINGS_PERMISSIONS_MANAGE);
|
||||
|
||||
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
|
||||
queryKey: ['org-members'],
|
||||
@@ -68,7 +74,7 @@ export function OrgMembersPage() {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
password: '',
|
||||
role: 'board_member',
|
||||
role: 'member_at_large',
|
||||
},
|
||||
validate: {
|
||||
email: (v) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Valid email required'),
|
||||
@@ -80,7 +86,7 @@ export function OrgMembersPage() {
|
||||
|
||||
const editForm = useForm({
|
||||
initialValues: {
|
||||
role: 'board_member',
|
||||
role: 'member_at_large',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -163,11 +169,18 @@ export function OrgMembersPage() {
|
||||
<Title order={2}>Organization Members</Title>
|
||||
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
|
||||
</div>
|
||||
{!isReadOnly && (
|
||||
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
|
||||
Add Member
|
||||
</Button>
|
||||
)}
|
||||
<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>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
|
||||
@@ -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 { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
|
||||
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 = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
|
||||
|
||||
const { data: payments = [], isLoading } = useQuery<Payment[]>({
|
||||
queryKey: ['payments'],
|
||||
|
||||
@@ -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 { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
|
||||
|
||||
// ---- Data fetching ----
|
||||
|
||||
|
||||
@@ -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 { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
|
||||
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 = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
|
||||
|
||||
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
|
||||
queryKey: ['reserve-components'],
|
||||
|
||||
250
frontend/src/pages/settings/PermissionSettingsPage.tsx
Normal file
250
frontend/src/pages/settings/PermissionSettingsPage.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Title, Text, Card, Stack, Group, Table, Checkbox, Button, Alert,
|
||||
Badge, Tooltip, Divider, Loader, Center,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconShieldCheck, IconRefresh, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { CAPABILITY_AREAS } from '../../permissions/capabilities';
|
||||
import { DEFAULT_ROLE_CAPABILITIES } from '../../permissions/default-role-capabilities';
|
||||
import api from '../../services/api';
|
||||
|
||||
/** Roles shown as columns (homeowner hidden from UI per product decision) */
|
||||
const DISPLAY_ROLES = [
|
||||
{ value: 'president', label: 'President' },
|
||||
{ value: 'vice_president', label: 'Vice President' },
|
||||
{ value: 'treasurer', label: 'Treasurer' },
|
||||
{ value: 'secretary', label: 'Secretary' },
|
||||
{ value: 'member_at_large', label: 'Member at Large' },
|
||||
{ value: 'manager', label: 'Property Manager' },
|
||||
{ value: 'viewer', label: 'Viewer' },
|
||||
];
|
||||
|
||||
interface PermissionOverrides {
|
||||
[role: string]: {
|
||||
grant?: string[];
|
||||
revoke?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
function buildCheckedState(overrides: PermissionOverrides): Record<string, Record<string, boolean>> {
|
||||
const state: Record<string, Record<string, boolean>> = {};
|
||||
for (const role of DISPLAY_ROLES) {
|
||||
const defaults = new Set(DEFAULT_ROLE_CAPABILITIES[role.value] || []);
|
||||
const roleOverride = overrides[role.value];
|
||||
|
||||
if (roleOverride?.grant) {
|
||||
for (const cap of roleOverride.grant) defaults.add(cap);
|
||||
}
|
||||
if (roleOverride?.revoke) {
|
||||
for (const cap of roleOverride.revoke) defaults.delete(cap);
|
||||
}
|
||||
|
||||
state[role.value] = {};
|
||||
for (const area of CAPABILITY_AREAS) {
|
||||
for (const cap of area.capabilities) {
|
||||
state[role.value][cap.key] = defaults.has(cap.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function buildOverridesFromState(checkedState: Record<string, Record<string, boolean>>): PermissionOverrides {
|
||||
const overrides: PermissionOverrides = {};
|
||||
for (const role of DISPLAY_ROLES) {
|
||||
const defaults = new Set(DEFAULT_ROLE_CAPABILITIES[role.value] || []);
|
||||
const grant: string[] = [];
|
||||
const revoke: string[] = [];
|
||||
|
||||
for (const [cap, checked] of Object.entries(checkedState[role.value] || {})) {
|
||||
const isDefault = defaults.has(cap);
|
||||
if (checked && !isDefault) grant.push(cap);
|
||||
if (!checked && isDefault) revoke.push(cap);
|
||||
}
|
||||
|
||||
if (grant.length > 0 || revoke.length > 0) {
|
||||
overrides[role.value] = {};
|
||||
if (grant.length > 0) overrides[role.value].grant = grant;
|
||||
if (revoke.length > 0) overrides[role.value].revoke = revoke;
|
||||
}
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
export function PermissionSettingsPage() {
|
||||
const { currentOrg, setOrgSettings } = useAuthStore();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const existingOverrides: PermissionOverrides = useMemo(
|
||||
() => currentOrg?.settings?.permissionOverrides || {},
|
||||
[currentOrg?.settings?.permissionOverrides],
|
||||
);
|
||||
|
||||
const [checkedState, setCheckedState] = useState<Record<string, Record<string, boolean>>>(() =>
|
||||
buildCheckedState(existingOverrides),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCheckedState(buildCheckedState(existingOverrides));
|
||||
setLoaded(true);
|
||||
}, [existingOverrides]);
|
||||
|
||||
const currentOverrides = useMemo(() => buildOverridesFromState(checkedState), [checkedState]);
|
||||
const hasChanges = JSON.stringify(currentOverrides) !== JSON.stringify(existingOverrides);
|
||||
|
||||
const toggleCapability = (role: string, cap: string) => {
|
||||
setCheckedState((prev) => ({
|
||||
...prev,
|
||||
[role]: {
|
||||
...prev[role],
|
||||
[cap]: !prev[role]?.[cap],
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const resetRole = (roleValue: string) => {
|
||||
const defaults = new Set(DEFAULT_ROLE_CAPABILITIES[roleValue] || []);
|
||||
const newRoleState: Record<string, boolean> = {};
|
||||
for (const area of CAPABILITY_AREAS) {
|
||||
for (const cap of area.capabilities) {
|
||||
newRoleState[cap.key] = defaults.has(cap.key);
|
||||
}
|
||||
}
|
||||
setCheckedState((prev) => ({ ...prev, [roleValue]: newRoleState }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const overrides = buildOverridesFromState(checkedState);
|
||||
const res = await api.patch('/organizations/settings', { permissionOverrides: overrides });
|
||||
setOrgSettings(res.data);
|
||||
notifications.show({ title: 'Saved', message: 'Permission settings updated. Members will see changes on next login or page refresh.', color: 'green' });
|
||||
} catch (err: any) {
|
||||
notifications.show({ title: 'Error', message: err.response?.data?.message || 'Failed to save', color: 'red' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isOverridden = (role: string, cap: string) => {
|
||||
const isDefault = (DEFAULT_ROLE_CAPABILITIES[role] || []).includes(cap);
|
||||
const isChecked = checkedState[role]?.[cap] ?? false;
|
||||
return isChecked !== isDefault;
|
||||
};
|
||||
|
||||
if (!loaded) {
|
||||
return <Center mt="xl"><Loader /></Center>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="xs">
|
||||
<IconShieldCheck size={28} />
|
||||
<Title order={2}>Role Permissions</Title>
|
||||
</Group>
|
||||
<Group>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<IconRefresh size={16} />}
|
||||
onClick={() => setCheckedState(buildCheckedState(existingOverrides))}
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
||||
Customize which capabilities each role has in your organization.
|
||||
Highlighted cells differ from the system defaults. Use "Reset" to revert a role to defaults.
|
||||
The <strong>Viewer</strong> role is always read-only regardless of settings.
|
||||
</Alert>
|
||||
|
||||
<Card withBorder p={0} style={{ overflow: 'auto' }}>
|
||||
<Table striped highlightOnHover withColumnBorders style={{ minWidth: 900 }}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ position: 'sticky', left: 0, background: 'var(--mantine-color-body)', zIndex: 1, minWidth: 200 }}>
|
||||
Capability
|
||||
</Table.Th>
|
||||
{DISPLAY_ROLES.map((role) => (
|
||||
<Table.Th key={role.value} style={{ textAlign: 'center', minWidth: 110 }}>
|
||||
<Stack gap={4} align="center">
|
||||
<Text size="xs" fw={600}>{role.label}</Text>
|
||||
<Tooltip label={`Reset ${role.label} to defaults`}>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
onClick={() => resetRole(role.value)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Table.Th>
|
||||
))}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{CAPABILITY_AREAS.map((area) => (
|
||||
<>
|
||||
<Table.Tr key={`area-${area.label}`}>
|
||||
<Table.Td
|
||||
colSpan={DISPLAY_ROLES.length + 1}
|
||||
style={{ background: 'var(--mantine-color-gray-1)', fontWeight: 700 }}
|
||||
>
|
||||
<Text size="sm" fw={700} tt="uppercase">{area.label}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{area.capabilities.map((cap) => (
|
||||
<Table.Tr key={cap.key}>
|
||||
<Table.Td style={{ position: 'sticky', left: 0, background: 'var(--mantine-color-body)', zIndex: 1 }}>
|
||||
<Text size="sm">{cap.label}</Text>
|
||||
</Table.Td>
|
||||
{DISPLAY_ROLES.map((role) => {
|
||||
const checked = checkedState[role.value]?.[cap.key] ?? false;
|
||||
const overridden = isOverridden(role.value, cap.key);
|
||||
return (
|
||||
<Table.Td
|
||||
key={role.value}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
background: overridden ? 'var(--mantine-color-yellow-0)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={() => toggleCapability(role.value, cap.key)}
|
||||
styles={{ input: { cursor: 'pointer' } }}
|
||||
/>
|
||||
</Table.Td>
|
||||
);
|
||||
})}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{hasChanges && (
|
||||
<Alert color="yellow" variant="light">
|
||||
You have unsaved changes. Click "Save Changes" to apply.
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -237,7 +237,7 @@ export function SettingsPage() {
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Version</Text>
|
||||
<Badge variant="light">2026.4.2</Badge>
|
||||
<Badge variant="light">2026.4.6</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">API</Text>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from
|
||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
|
||||
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 = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
|
||||
|
||||
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
|
||||
queryKey: ['journal-entries'],
|
||||
|
||||
@@ -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 { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
|
||||
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 = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.ASSESSMENTS_UNITS_EDIT);
|
||||
|
||||
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
||||
queryKey: ['units'],
|
||||
|
||||
4
frontend/src/pages/vendors/VendorsPage.tsx
vendored
4
frontend/src/pages/vendors/VendorsPage.tsx
vendored
@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
|
||||
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload, IconUsers, IconBulb, IconRocket } from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
import { useIsReadOnly } from '../../stores/authStore';
|
||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
||||
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 = useIsReadOnly();
|
||||
const isReadOnly = !useCanEdit(CAPABILITIES.REFERENCE_VENDORS_EDIT);
|
||||
|
||||
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
||||
queryKey: ['vendors'],
|
||||
|
||||
22
frontend/src/permissions/CapabilityGate.tsx
Normal file
22
frontend/src/permissions/CapabilityGate.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { useHasCapability, useHasAnyCapability } from './useCapability';
|
||||
|
||||
interface CapabilityGateProps {
|
||||
/** Single capability required */
|
||||
capability?: string;
|
||||
/** Multiple capabilities — user needs at least one */
|
||||
anyOf?: string[];
|
||||
/** Content shown when user has the capability */
|
||||
children: ReactNode;
|
||||
/** Optional fallback shown when user lacks the capability */
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
export function CapabilityGate({ capability, anyOf, children, fallback = null }: CapabilityGateProps) {
|
||||
const hasSingle = useHasCapability(capability || '');
|
||||
const hasAny = useHasAnyCapability(...(anyOf || []));
|
||||
|
||||
const allowed = capability ? hasSingle : anyOf ? hasAny : true;
|
||||
|
||||
return allowed ? <>{children}</> : <>{fallback}</>;
|
||||
}
|
||||
131
frontend/src/permissions/capabilities.ts
Normal file
131
frontend/src/permissions/capabilities.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Capability taxonomy for the HOA Financial Platform.
|
||||
*
|
||||
* This file mirrors backend/src/common/permissions/capabilities.ts.
|
||||
* Keep both files in sync when adding new capabilities.
|
||||
*/
|
||||
export const CAPABILITIES = {
|
||||
DASHBOARD_VIEW: 'dashboard.view',
|
||||
|
||||
FINANCIALS_ACCOUNTS_VIEW: 'financials.accounts.view',
|
||||
FINANCIALS_ACCOUNTS_EDIT: 'financials.accounts.edit',
|
||||
FINANCIALS_CASHFLOW_VIEW: 'financials.cashflow.view',
|
||||
FINANCIALS_CASHFLOW_EDIT: 'financials.cashflow.edit',
|
||||
FINANCIALS_ACTUALS_VIEW: 'financials.actuals.view',
|
||||
FINANCIALS_ACTUALS_EDIT: 'financials.actuals.edit',
|
||||
FINANCIALS_BUDGETS_VIEW: 'financials.budgets.view',
|
||||
FINANCIALS_BUDGETS_EDIT: 'financials.budgets.edit',
|
||||
FINANCIALS_BUDGETS_APPROVE: 'financials.budgets.approve',
|
||||
|
||||
ASSESSMENTS_UNITS_VIEW: 'assessments.units.view',
|
||||
ASSESSMENTS_UNITS_EDIT: 'assessments.units.edit',
|
||||
ASSESSMENTS_GROUPS_VIEW: 'assessments.groups.view',
|
||||
ASSESSMENTS_GROUPS_EDIT: 'assessments.groups.edit',
|
||||
|
||||
PLANNING_BUDGETS_VIEW: 'planning.budgets.view',
|
||||
PLANNING_BUDGETS_EDIT: 'planning.budgets.edit',
|
||||
PLANNING_PROJECTS_VIEW: 'planning.projects.view',
|
||||
PLANNING_PROJECTS_EDIT: 'planning.projects.edit',
|
||||
PLANNING_SCENARIOS_VIEW: 'planning.scenarios.view',
|
||||
PLANNING_SCENARIOS_EDIT: 'planning.scenarios.edit',
|
||||
PLANNING_SCENARIOS_APPROVE: 'planning.scenarios.approve',
|
||||
PLANNING_INVESTMENTS_VIEW: 'planning.investments.view',
|
||||
PLANNING_INVESTMENTS_EDIT: 'planning.investments.edit',
|
||||
|
||||
REFERENCE_VENDORS_VIEW: 'reference.vendors.view',
|
||||
REFERENCE_VENDORS_EDIT: 'reference.vendors.edit',
|
||||
|
||||
TRANSACTIONS_VIEW: 'transactions.view',
|
||||
TRANSACTIONS_EDIT: 'transactions.edit',
|
||||
TRANSACTIONS_APPROVE: 'transactions.approve',
|
||||
|
||||
REPORTS_VIEW: 'reports.view',
|
||||
|
||||
SETTINGS_ORG_VIEW: 'settings.org.view',
|
||||
SETTINGS_ORG_EDIT: 'settings.org.edit',
|
||||
SETTINGS_MEMBERS_VIEW: 'settings.members.view',
|
||||
SETTINGS_MEMBERS_MANAGE: 'settings.members.manage',
|
||||
SETTINGS_PERMISSIONS_MANAGE: 'settings.permissions.manage',
|
||||
} as const;
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
|
||||
|
||||
export const ALL_CAPABILITIES = new Set<string>(Object.values(CAPABILITIES));
|
||||
|
||||
/** Human-readable labels for capability areas (for admin UI) */
|
||||
export const CAPABILITY_AREAS: { label: string; capabilities: { key: string; label: string }[] }[] = [
|
||||
{
|
||||
label: 'Dashboard',
|
||||
capabilities: [
|
||||
{ key: CAPABILITIES.DASHBOARD_VIEW, label: 'View Dashboard' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Financials',
|
||||
capabilities: [
|
||||
{ key: CAPABILITIES.FINANCIALS_ACCOUNTS_VIEW, label: 'View Accounts' },
|
||||
{ key: CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT, label: 'Edit Accounts' },
|
||||
{ key: CAPABILITIES.FINANCIALS_CASHFLOW_VIEW, label: 'View Cash Flow' },
|
||||
{ key: CAPABILITIES.FINANCIALS_CASHFLOW_EDIT, label: 'Edit Cash Flow' },
|
||||
{ key: CAPABILITIES.FINANCIALS_ACTUALS_VIEW, label: 'View Monthly Actuals' },
|
||||
{ key: CAPABILITIES.FINANCIALS_ACTUALS_EDIT, label: 'Edit Monthly Actuals' },
|
||||
{ key: CAPABILITIES.FINANCIALS_BUDGETS_VIEW, label: 'View Budgets' },
|
||||
{ key: CAPABILITIES.FINANCIALS_BUDGETS_EDIT, label: 'Edit Budgets' },
|
||||
{ key: CAPABILITIES.FINANCIALS_BUDGETS_APPROVE, label: 'Approve Budgets' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Assessments',
|
||||
capabilities: [
|
||||
{ key: CAPABILITIES.ASSESSMENTS_UNITS_VIEW, label: 'View Units' },
|
||||
{ key: CAPABILITIES.ASSESSMENTS_UNITS_EDIT, label: 'Edit Units' },
|
||||
{ key: CAPABILITIES.ASSESSMENTS_GROUPS_VIEW, label: 'View Assessment Groups' },
|
||||
{ key: CAPABILITIES.ASSESSMENTS_GROUPS_EDIT, label: 'Edit Assessment Groups' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Board Planning',
|
||||
capabilities: [
|
||||
{ key: CAPABILITIES.PLANNING_BUDGETS_VIEW, label: 'View Budget Planning' },
|
||||
{ key: CAPABILITIES.PLANNING_BUDGETS_EDIT, label: 'Edit Budget Planning' },
|
||||
{ key: CAPABILITIES.PLANNING_PROJECTS_VIEW, label: 'View Projects' },
|
||||
{ key: CAPABILITIES.PLANNING_PROJECTS_EDIT, label: 'Edit Projects' },
|
||||
{ key: CAPABILITIES.PLANNING_SCENARIOS_VIEW, label: 'View Scenarios' },
|
||||
{ key: CAPABILITIES.PLANNING_SCENARIOS_EDIT, label: 'Edit Scenarios' },
|
||||
{ key: CAPABILITIES.PLANNING_SCENARIOS_APPROVE, label: 'Approve Scenarios' },
|
||||
{ key: CAPABILITIES.PLANNING_INVESTMENTS_VIEW, label: 'View Investments' },
|
||||
{ key: CAPABILITIES.PLANNING_INVESTMENTS_EDIT, label: 'Edit Investments' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Board Reference',
|
||||
capabilities: [
|
||||
{ key: CAPABILITIES.REFERENCE_VENDORS_VIEW, label: 'View Vendors' },
|
||||
{ key: CAPABILITIES.REFERENCE_VENDORS_EDIT, label: 'Edit Vendors' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Transactions',
|
||||
capabilities: [
|
||||
{ key: CAPABILITIES.TRANSACTIONS_VIEW, label: 'View Transactions' },
|
||||
{ key: CAPABILITIES.TRANSACTIONS_EDIT, label: 'Edit Transactions' },
|
||||
{ key: CAPABILITIES.TRANSACTIONS_APPROVE, label: 'Approve Transactions' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Reports',
|
||||
capabilities: [
|
||||
{ key: CAPABILITIES.REPORTS_VIEW, label: 'View Reports' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Administration',
|
||||
capabilities: [
|
||||
{ key: CAPABILITIES.SETTINGS_ORG_VIEW, label: 'View Org Settings' },
|
||||
{ key: CAPABILITIES.SETTINGS_ORG_EDIT, label: 'Edit Org Settings' },
|
||||
{ key: CAPABILITIES.SETTINGS_MEMBERS_VIEW, label: 'View Members' },
|
||||
{ key: CAPABILITIES.SETTINGS_MEMBERS_MANAGE, label: 'Manage Members' },
|
||||
{ key: CAPABILITIES.SETTINGS_PERMISSIONS_MANAGE, label: 'Manage Permissions' },
|
||||
],
|
||||
},
|
||||
];
|
||||
155
frontend/src/permissions/default-role-capabilities.ts
Normal file
155
frontend/src/permissions/default-role-capabilities.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { CAPABILITIES } from './capabilities';
|
||||
|
||||
const C = CAPABILITIES;
|
||||
|
||||
/**
|
||||
* Default capability sets per role.
|
||||
*
|
||||
* Mirrors backend/src/common/permissions/default-role-capabilities.ts.
|
||||
* Keep both files in sync.
|
||||
*/
|
||||
export const DEFAULT_ROLE_CAPABILITIES: Record<string, readonly string[]> = {
|
||||
president: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
|
||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
|
||||
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
|
||||
C.REPORTS_VIEW,
|
||||
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
|
||||
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
|
||||
C.SETTINGS_PERMISSIONS_MANAGE,
|
||||
],
|
||||
|
||||
admin: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
|
||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
|
||||
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
|
||||
C.REPORTS_VIEW,
|
||||
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
|
||||
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
|
||||
C.SETTINGS_PERMISSIONS_MANAGE,
|
||||
],
|
||||
|
||||
vice_president: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||
C.FINANCIALS_CASHFLOW_VIEW,
|
||||
C.FINANCIALS_ACTUALS_VIEW,
|
||||
C.FINANCIALS_BUDGETS_VIEW,
|
||||
C.ASSESSMENTS_UNITS_VIEW,
|
||||
C.ASSESSMENTS_GROUPS_VIEW,
|
||||
C.PLANNING_BUDGETS_VIEW,
|
||||
C.PLANNING_PROJECTS_VIEW,
|
||||
C.PLANNING_SCENARIOS_VIEW,
|
||||
C.PLANNING_INVESTMENTS_VIEW,
|
||||
C.REFERENCE_VENDORS_VIEW,
|
||||
C.TRANSACTIONS_VIEW,
|
||||
C.REPORTS_VIEW,
|
||||
C.SETTINGS_ORG_VIEW,
|
||||
C.SETTINGS_MEMBERS_VIEW,
|
||||
],
|
||||
|
||||
treasurer: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
||||
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT,
|
||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
||||
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
||||
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
||||
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT,
|
||||
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
|
||||
C.REPORTS_VIEW,
|
||||
C.SETTINGS_MEMBERS_VIEW,
|
||||
],
|
||||
|
||||
secretary: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||
C.FINANCIALS_CASHFLOW_VIEW,
|
||||
C.FINANCIALS_ACTUALS_VIEW,
|
||||
C.FINANCIALS_BUDGETS_VIEW,
|
||||
C.ASSESSMENTS_UNITS_VIEW,
|
||||
C.ASSESSMENTS_GROUPS_VIEW,
|
||||
C.PLANNING_BUDGETS_VIEW,
|
||||
C.PLANNING_PROJECTS_VIEW,
|
||||
C.PLANNING_SCENARIOS_VIEW,
|
||||
C.PLANNING_INVESTMENTS_VIEW,
|
||||
C.REFERENCE_VENDORS_VIEW,
|
||||
C.REPORTS_VIEW,
|
||||
],
|
||||
|
||||
member_at_large: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||
C.FINANCIALS_CASHFLOW_VIEW,
|
||||
C.FINANCIALS_ACTUALS_VIEW,
|
||||
C.FINANCIALS_BUDGETS_VIEW,
|
||||
C.ASSESSMENTS_UNITS_VIEW,
|
||||
C.ASSESSMENTS_GROUPS_VIEW,
|
||||
C.PLANNING_BUDGETS_VIEW,
|
||||
C.PLANNING_PROJECTS_VIEW,
|
||||
C.PLANNING_SCENARIOS_VIEW,
|
||||
C.PLANNING_INVESTMENTS_VIEW,
|
||||
C.REFERENCE_VENDORS_VIEW,
|
||||
C.REPORTS_VIEW,
|
||||
],
|
||||
|
||||
manager: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||
C.FINANCIALS_CASHFLOW_VIEW,
|
||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
||||
C.FINANCIALS_BUDGETS_VIEW,
|
||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
||||
C.ASSESSMENTS_GROUPS_VIEW,
|
||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
|
||||
C.REPORTS_VIEW,
|
||||
],
|
||||
|
||||
homeowner: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.REPORTS_VIEW,
|
||||
],
|
||||
|
||||
viewer: [
|
||||
C.DASHBOARD_VIEW,
|
||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
||||
C.FINANCIALS_CASHFLOW_VIEW,
|
||||
C.FINANCIALS_ACTUALS_VIEW,
|
||||
C.FINANCIALS_BUDGETS_VIEW,
|
||||
C.ASSESSMENTS_UNITS_VIEW,
|
||||
C.ASSESSMENTS_GROUPS_VIEW,
|
||||
C.PLANNING_BUDGETS_VIEW,
|
||||
C.PLANNING_PROJECTS_VIEW,
|
||||
C.PLANNING_SCENARIOS_VIEW,
|
||||
C.PLANNING_INVESTMENTS_VIEW,
|
||||
C.REFERENCE_VENDORS_VIEW,
|
||||
C.TRANSACTIONS_VIEW,
|
||||
C.REPORTS_VIEW,
|
||||
],
|
||||
};
|
||||
7
frontend/src/permissions/index.ts
Normal file
7
frontend/src/permissions/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { CAPABILITIES, ALL_CAPABILITIES, CAPABILITY_AREAS } from './capabilities';
|
||||
export type { Capability } from './capabilities';
|
||||
export { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
||||
export { resolveCapabilities } from './resolve-permissions';
|
||||
export type { PermissionOverrides } from './resolve-permissions';
|
||||
export { useHasCapability, useHasAnyCapability, useHasAllCapabilities, useCanEdit } from './useCapability';
|
||||
export { CapabilityGate } from './CapabilityGate';
|
||||
42
frontend/src/permissions/resolve-permissions.ts
Normal file
42
frontend/src/permissions/resolve-permissions.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ALL_CAPABILITIES } from './capabilities';
|
||||
import { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
||||
|
||||
export interface PermissionOverrides {
|
||||
[role: string]: {
|
||||
grant?: string[];
|
||||
revoke?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve effective capabilities for a role, applying tenant overrides.
|
||||
*
|
||||
* Mirrors backend/src/common/permissions/resolve-permissions.ts.
|
||||
*/
|
||||
export function resolveCapabilities(
|
||||
role: string,
|
||||
overrides?: PermissionOverrides | null,
|
||||
): Set<string> {
|
||||
const defaults = DEFAULT_ROLE_CAPABILITIES[role] || [];
|
||||
const result = new Set<string>(defaults);
|
||||
|
||||
if (overrides && overrides[role]) {
|
||||
const roleOverride = overrides[role];
|
||||
|
||||
if (roleOverride.grant) {
|
||||
for (const cap of roleOverride.grant) {
|
||||
if (ALL_CAPABILITIES.has(cap)) {
|
||||
result.add(cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (roleOverride.revoke) {
|
||||
for (const cap of roleOverride.revoke) {
|
||||
result.delete(cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
44
frontend/src/permissions/useCapability.ts
Normal file
44
frontend/src/permissions/useCapability.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
|
||||
/**
|
||||
* Check if the current user has a specific capability.
|
||||
* Superadmins always return true.
|
||||
*/
|
||||
export function useHasCapability(capability: string): boolean {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const capabilities = useAuthStore((s) => s.currentOrg?.capabilities);
|
||||
if (user?.isSuperadmin) return true;
|
||||
return capabilities?.includes(capability) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has ANY of the given capabilities.
|
||||
* Superadmins always return true.
|
||||
*/
|
||||
export function useHasAnyCapability(...caps: string[]): boolean {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const capabilities = useAuthStore((s) => s.currentOrg?.capabilities);
|
||||
if (user?.isSuperadmin) return true;
|
||||
if (!capabilities) return false;
|
||||
return caps.some((c) => capabilities.includes(c));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has ALL of the given capabilities.
|
||||
* Superadmins always return true.
|
||||
*/
|
||||
export function useHasAllCapabilities(...caps: string[]): boolean {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const capabilities = useAuthStore((s) => s.currentOrg?.capabilities);
|
||||
if (user?.isSuperadmin) return true;
|
||||
if (!capabilities) return false;
|
||||
return caps.every((c) => capabilities.includes(c));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific capability string matches the user's capability for edit actions.
|
||||
* This replaces the old useIsReadOnly() for more granular checks.
|
||||
*/
|
||||
export function useCanEdit(editCapability: string): boolean {
|
||||
return useHasCapability(editCapability);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ interface Organization {
|
||||
status?: string;
|
||||
planLevel?: string;
|
||||
settings?: Record<string, any>;
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
interface User {
|
||||
@@ -119,7 +120,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
}),
|
||||
{
|
||||
name: 'ledgeriq-auth',
|
||||
version: 5,
|
||||
version: 6,
|
||||
migrate: () => ({
|
||||
token: null,
|
||||
user: null,
|
||||
|
||||
24
package.json
24
package.json
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
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',
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -21,6 +21,7 @@ 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"
|
||||
@@ -51,9 +52,9 @@ ensure_postgres_running() {
|
||||
|
||||
format_size() {
|
||||
local bytes=$1
|
||||
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)"
|
||||
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 ))
|
||||
else printf "%d B" "$bytes"
|
||||
fi
|
||||
}
|
||||
@@ -121,8 +122,12 @@ do_restore() {
|
||||
warn "This will DESTROY the current '${DB_NAME}' database and replace it"
|
||||
warn "with the contents of: $(basename "$file")"
|
||||
echo ""
|
||||
read -rp "Type 'yes' to continue: " confirm
|
||||
[ "$confirm" = "yes" ] || { info "Aborted."; exit 0; }
|
||||
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
|
||||
|
||||
echo ""
|
||||
info "Step 1/4 — Terminating active connections ..."
|
||||
@@ -229,6 +234,7 @@ 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)
|
||||
@@ -255,6 +261,7 @@ 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
|
||||
|
||||
425
scripts/deploy-prod.sh
Executable file
425
scripts/deploy-prod.sh
Executable file
@@ -0,0 +1,425 @@
|
||||
#!/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 ""
|
||||
@@ -1,140 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* 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());
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* 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 });
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* 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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 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
108
tests/fixtures/auth.fixture.ts
vendored
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* 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
87
tests/fixtures/base.fixture.ts
vendored
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* 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
124
tests/fixtures/db.fixture.ts
vendored
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* 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
48
tests/fixtures/test-data.ts
vendored
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -1,82 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
Reference in New Issue
Block a user