diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bbe60fb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,229 @@ +# CLAUDE.md – HOA Financial Platform (HOALedgerIQ) + +## Project Overview + +Multi-tenant SaaS platform for HOA (Homeowners Association) financial management. Handles chart of accounts, journal entries, budgets, invoices, payments, reserve planning, and board scenario planning. + +--- + +## Stack & Framework + +| Layer | Technology | +| --------- | --------------------------------------------------- | +| Backend | **NestJS 10** (TypeScript), runs on port 3000 | +| Frontend | **React 18** + Vite 5 + Mantine UI + Zustand | +| Database | **PostgreSQL** via **TypeORM 0.3** | +| Cache | **Redis** (BullMQ for queues) | +| Auth | **Passport.js** – JWT access + httpOnly refresh | +| Payments | **Stripe** (checkout, subscriptions, webhooks) | +| Email | **Resend** | +| AI | NVIDIA API (Qwen model) for investment advisor | +| Monitoring| **New Relic** APM (app name: `HOALedgerIQ_App`) | +| Infra | Docker Compose (dev + prod), Nginx reverse proxy | + +--- + +## Auth Pattern + +- **Access token**: JWT, 1-hour TTL, payload `{ sub, email, orgId, role, isSuperadmin }` +- **Refresh token**: 64-byte random, SHA256-hashed in DB, 30-day TTL, sent as httpOnly cookie `ledgeriq_rt` +- **MFA**: TOTP via `otplib`, challenge token (5-min TTL), recovery codes +- **Passkeys**: WebAuthn via `@simplewebauthn/server` +- **SSO**: Google OAuth 2.0, Azure AD +- **Password hashing**: bcryptjs, cost 12 +- **Rate limiting**: 100 req/min global (Throttler), custom per endpoint + +### Guards & Middleware + +- `TenantMiddleware` – extracts `orgId` from JWT, sets tenant schema (60s cache) +- `JwtAuthGuard` – Passport JWT guard on all protected routes +- `WriteAccessGuard` – blocks write ops for `viewer` role and `past_due` orgs +- `@AllowViewer()` decorator – exempts read endpoints from WriteAccessGuard + +### Roles + +`president`, `treasurer`, `secretary`, `member_at_large`, `manager`, `homeowner`, `admin`, `viewer` + +--- + +## Multi-Tenant Architecture + +- **Shared schema** (`shared`): users, organizations, user_organizations, refresh_tokens, invite_tokens, login_history, cd_rates +- **Tenant schemas** (dynamic, per org): accounts, journal_entries, budgets, invoices, payments, units, vendors, etc. +- Schema name stored in `shared.organizations.schema_name` + +--- + +## Route Map (180+ endpoints) + +### Auth (`/api/auth`) +| Method | Path | Purpose | +| ------ | ----------------------- | -------------------------------- | +| POST | /login | Email/password login | +| POST | /refresh | Refresh access token (cookie) | +| POST | /logout | Revoke refresh token | +| POST | /logout-everywhere | Revoke all sessions | +| GET | /profile | Current user profile | +| POST | /register | Register (disabled by default) | +| POST | /activate | Activate invited user | +| POST | /forgot-password | Request password reset | +| POST | /reset-password | Reset with token | +| PATCH | /change-password | Change password (authed) | +| POST | /switch-org | Switch active organization | + +### Auth MFA (`/api/auth/mfa`) +| POST | /setup | POST /enable | POST /verify | POST /disable | GET /status | + +### Auth Passkeys (`/api/auth/passkeys`) +| POST /register-options | POST /register | POST /login-options | POST /login | GET / | DELETE /:id | + +### Admin (`/api/admin`) – superadmin only +| GET /metrics | GET /users | GET /organizations | PUT /organizations/:id/subscription | POST /impersonate/:userId | POST /tenants | + +### Organizations (`/api/organizations`) +| POST / | GET / | PATCH /settings | GET /members | POST /members | PUT /members/:id/role | DELETE /members/:id | + +### Accounts (`/api/accounts`) +| GET / | GET /trial-balance | POST / | PUT /:id | PUT /:id/set-primary | POST /bulk-opening-balances | POST /:id/opening-balance | POST /:id/adjust-balance | + +### Journal Entries (`/api/journal-entries`) +| GET / | GET /:id | POST / | POST /:id/post | POST /:id/void | + +### Budgets (`/api/budgets`) +| GET /:year | PUT /:year | GET /:year/vs-actual | POST /:year/import | GET /:year/template | + +### Invoices (`/api/invoices`) +| GET / | GET /:id | POST /generate-preview | POST /generate-bulk | POST /apply-late-fees | + +### Payments (`/api/payments`) +| GET / | GET /:id | POST / | PUT /:id | DELETE /:id | + +### Units (`/api/units`) +| GET / | GET /:id | POST / | PUT /:id | DELETE /:id | GET /export | POST /import | + +### Vendors (`/api/vendors`) +| GET / | GET /:id | POST / | PUT /:id | GET /export | POST /import | GET /1099-data | + +### Reports (`/api/reports`) +| GET /dashboard | GET /balance-sheet | GET /income-statement | GET /cash-flow | GET /cash-flow-sankey | GET /aging | GET /year-end | GET /cash-flow-forecast | GET /quarterly | + +### Board Planning (`/api/board-planning`) +Scenarios CRUD, scenario investments, scenario assessments, projections, budget plans – 28 endpoints total. + +### Other Modules +- `/api/fiscal-periods` – list, close, lock +- `/api/reserve-components` – CRUD +- `/api/capital-projects` – CRUD +- `/api/projects` – CRUD + planning + import/export +- `/api/assessment-groups` – CRUD + summary + default +- `/api/monthly-actuals` – GET/POST /:year/:month +- `/api/health-scores` – latest + calculate +- `/api/investment-planning` – snapshot, market-rates, recommendations +- `/api/investment-accounts` – CRUD +- `/api/attachments` – upload, list, download, delete (10MB limit) +- `/api/onboarding` – progress get/patch +- `/api/billing` – trial, checkout, webhook, subscription, portal + +--- + +## Database + +- **Connection pool**: min 5, max 30, 30s idle, 5s connect timeout +- **Migrations**: SQL files in `db/migrations/` (manual execution, no ORM runner) +- **Init script**: `db/init/00-init.sql` (shared schema DDL) + +--- + +## Key File Paths + +| Purpose | Path | +| ---------------------- | ------------------------------------------------- | +| NestJS bootstrap | `backend/src/main.ts` | +| Root module | `backend/src/app.module.ts` | +| Auth controller | `backend/src/modules/auth/auth.controller.ts` | +| Auth service | `backend/src/modules/auth/auth.service.ts` | +| Refresh token svc | `backend/src/modules/auth/refresh-token.service.ts` | +| JWT strategy | `backend/src/modules/auth/strategies/jwt.strategy.ts` | +| Tenant middleware | `backend/src/database/tenant.middleware.ts` | +| Write-access guard | `backend/src/common/guards/write-access.guard.ts` | +| DB schema init | `db/init/00-init.sql` | +| Env example | `.env.example` | +| Docker compose (dev) | `docker-compose.yml` | +| Frontend entry | `frontend/src/main.tsx` | +| Frontend pages | `frontend/src/pages/` | + +--- + +## Environment Variables (critical) + +``` +DATABASE_URL – PostgreSQL connection string +REDIS_URL – Redis connection +JWT_SECRET – JWT signing key +INVITE_TOKEN_SECRET – Invite token signing +STRIPE_SECRET_KEY – Stripe API key +STRIPE_WEBHOOK_SECRET – Stripe webhook verification +RESEND_API_KEY – Email service +NEW_RELIC_APP_NAME – "HOALedgerIQ_App" +NEW_RELIC_LICENSE_KEY – New Relic license +APP_URL – Base URL for email links +``` + +--- + +## New Relic + +- **App name**: `HOALedgerIQ_App` (env: `NEW_RELIC_APP_NAME`) +- Enabled via `NEW_RELIC_ENABLED=true` +- NRQL query library: `load-tests/analysis/nrql-queries.sql` + +--- + +## Load Testing + +### Run k6 scenarios + +```bash +# Auth + Dashboard flow (staging) +k6 run --env TARGET_ENV=staging load-tests/scenarios/auth-dashboard-flow.js + +# CRUD flow (staging) +k6 run --env TARGET_ENV=staging load-tests/scenarios/crud-flow.js + +# Local dev +k6 run --env TARGET_ENV=local load-tests/scenarios/auth-dashboard-flow.js +``` + +### Conventions + +- Scenarios live in `load-tests/scenarios/` +- Config in `load-tests/config/environments.json` (staging/production/local thresholds) +- Test users parameterized from `load-tests/config/user-pool.csv` +- Baseline results stored in `load-tests/analysis/baseline.json` +- NRQL queries for New Relic in `load-tests/analysis/nrql-queries.sql` +- All k6 scripts use `SharedArray` for user pool, `http.batch()` for parallel requests +- Custom metrics: `*_duration` trends + `*_error_rate` rates per journey +- Thresholds: p95 latency + error rate per environment + +### User Pool CSV Format + +``` +email,password,orgId,role +``` + +Roles match the app: `treasurer`, `admin`, `president`, `manager`, `member_at_large`, `viewer`, `homeowner` + +--- + +## Fix Conventions + +- Backend tests: `npm run test` (Jest, `*.spec.ts` co-located with source) +- E2E tests: `npm run test:e2e` +- Backend build: `npm run build` (NestJS CLI) +- Frontend dev: `npm run dev` (Vite, port 5173) +- Frontend build: `npm run build` +- Always run `npm run build` in `backend/` after changes to verify compilation +- TypeORM entities use decorators (`@Entity`, `@Column`, etc.) +- Multi-tenant: any new module touching tenant data must use `TenantService` to get the correct schema connection +- New endpoints need `@UseGuards(JwtAuthGuard)` and should respect `WriteAccessGuard` +- Use `@AllowViewer()` on read-only endpoints diff --git a/load-tests/analysis/baseline.json b/load-tests/analysis/baseline.json new file mode 100644 index 0000000..503d142 --- /dev/null +++ b/load-tests/analysis/baseline.json @@ -0,0 +1,141 @@ +{ + "_meta": { + "capturedAt": null, + "environment": "staging", + "k6Version": null, + "vus": null, + "duration": null, + "notes": "Empty baseline – populate after first stable load test run" + }, + "auth": { + "POST /api/auth/login": { + "p50": null, + "p95": null, + "p99": null, + "errorRate": null, + "rps": null + }, + "POST /api/auth/refresh": { + "p50": null, + "p95": null, + "p99": null, + "errorRate": null, + "rps": null + }, + "POST /api/auth/logout": { + "p50": null, + "p95": null, + "p99": null, + "errorRate": null, + "rps": null + }, + "GET /api/auth/profile": { + "p50": null, + "p95": null, + "p99": null, + "errorRate": null, + "rps": null + } + }, + "dashboard": { + "GET /api/reports/dashboard": { + "p50": null, + "p95": null, + "p99": null, + "errorRate": null, + "rps": null + }, + "GET /api/reports/balance-sheet": { + "p50": null, + "p95": null, + "p99": null, + "errorRate": null, + "rps": null + }, + "GET /api/reports/income-statement": { + "p50": null, + "p95": null, + "p99": null, + "errorRate": null, + "rps": null + }, + "GET /api/reports/aging": { + "p50": null, + "p95": null, + "p99": null, + "errorRate": null, + "rps": null + }, + "GET /api/health-scores/latest": { + "p50": null, + "p95": null, + "p99": null, + "errorRate": null, + "rps": null + }, + "GET /api/reports/cash-flow": { + "p50": null, + "p95": null, + "p99": null, + "errorRate": null, + "rps": null + }, + "GET /api/reports/cash-flow-forecast": { + "p50": null, + "p95": null, + "p99": null, + "errorRate": null, + "rps": null + } + }, + "crud": { + "units": { + "GET /api/units": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "POST /api/units": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "GET /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "PUT /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "DELETE /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null } + }, + "vendors": { + "GET /api/vendors": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "POST /api/vendors": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "GET /api/vendors/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "PUT /api/vendors/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null } + }, + "journalEntries": { + "GET /api/journal-entries": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "POST /api/journal-entries": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "GET /api/journal-entries/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "POST /api/journal-entries/:id/post": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "POST /api/journal-entries/:id/void": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null } + }, + "payments": { + "GET /api/payments": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "POST /api/payments": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "GET /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "PUT /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "DELETE /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null } + }, + "accounts": { + "GET /api/accounts": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "GET /api/accounts/trial-balance": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "POST /api/accounts": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "PUT /api/accounts/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null } + }, + "invoices": { + "GET /api/invoices": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "GET /api/invoices/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "POST /api/invoices/generate-bulk": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null } + } + }, + "boardPlanning": { + "GET /api/board-planning/scenarios": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "POST /api/board-planning/scenarios": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "GET /api/board-planning/scenarios/:id/projection": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "GET /api/board-planning/budget-plans": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null } + }, + "organizations": { + "GET /api/organizations": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }, + "GET /api/organizations/members": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null } + } +} diff --git a/load-tests/analysis/nrql-queries.sql b/load-tests/analysis/nrql-queries.sql new file mode 100644 index 0000000..8c2eb01 --- /dev/null +++ b/load-tests/analysis/nrql-queries.sql @@ -0,0 +1,270 @@ +-- ============================================================================= +-- HOA Financial Platform (HOALedgerIQ) – New Relic NRQL Query Library +-- App Name: HOALedgerIQ_App (see NEW_RELIC_APP_NAME env var) +-- ============================================================================= + +-- --------------------------------------------------------------------------- +-- 1. OVERVIEW & THROUGHPUT +-- --------------------------------------------------------------------------- + +-- Overall throughput (requests/min) over past hour +SELECT rate(count(*), 1 minute) AS 'RPM' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +SINCE 1 hour ago +TIMESERIES AUTO; + +-- Throughput by HTTP method +SELECT rate(count(*), 1 minute) AS 'RPM' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +FACET request.method +SINCE 1 hour ago +TIMESERIES AUTO; + +-- Apdex score over time +SELECT apdex(duration, t: 0.5) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +SINCE 1 hour ago +TIMESERIES AUTO; + +-- --------------------------------------------------------------------------- +-- 2. AUTHENTICATION ENDPOINTS +-- --------------------------------------------------------------------------- + +-- Login latency (p50, p95, p99) +SELECT percentile(duration, 50, 95, 99) AS 'Login Latency (s)' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri = '/api/auth/login' +SINCE 1 hour ago +TIMESERIES AUTO; + +-- Login error rate +SELECT percentage(count(*), WHERE httpResponseCode >= 400) AS 'Login Error %' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri = '/api/auth/login' +SINCE 1 hour ago +TIMESERIES AUTO; + +-- Token refresh latency +SELECT percentile(duration, 50, 95, 99) AS 'Refresh Latency (s)' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri = '/api/auth/refresh' +SINCE 1 hour ago +TIMESERIES AUTO; + +-- Auth endpoints overview +SELECT count(*), average(duration), percentile(duration, 95) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri LIKE '/api/auth/%' +FACET request.uri +SINCE 1 hour ago; + +-- --------------------------------------------------------------------------- +-- 3. DASHBOARD & REPORTS +-- --------------------------------------------------------------------------- + +-- Dashboard KPI latency +SELECT percentile(duration, 50, 95, 99) AS 'Dashboard Latency (s)' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri = '/api/reports/dashboard' +SINCE 1 hour ago +TIMESERIES AUTO; + +-- All report endpoints performance +SELECT count(*), average(duration), percentile(duration, 95) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri LIKE '/api/reports/%' +FACET request.uri +SINCE 1 hour ago; + +-- Slowest report queries (> 2s) +SELECT count(*) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri LIKE '/api/reports/%' + AND duration > 2 +FACET request.uri +SINCE 1 hour ago; + +-- --------------------------------------------------------------------------- +-- 4. CRUD OPERATIONS +-- --------------------------------------------------------------------------- + +-- Units endpoint latency by method +SELECT percentile(duration, 50, 95) AS 'Units Latency' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri LIKE '/api/units%' +FACET request.method +SINCE 1 hour ago; + +-- Vendors endpoint latency by method +SELECT percentile(duration, 50, 95) AS 'Vendors Latency' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri LIKE '/api/vendors%' +FACET request.method +SINCE 1 hour ago; + +-- Journal entries performance +SELECT percentile(duration, 50, 95) AS 'JE Latency' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri LIKE '/api/journal-entries%' +FACET request.method +SINCE 1 hour ago; + +-- Payments performance +SELECT percentile(duration, 50, 95) AS 'Payments Latency' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri LIKE '/api/payments%' +FACET request.method +SINCE 1 hour ago; + +-- Accounts performance +SELECT percentile(duration, 50, 95) AS 'Accounts Latency' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri LIKE '/api/accounts%' +FACET request.method +SINCE 1 hour ago; + +-- Invoices performance +SELECT percentile(duration, 50, 95) AS 'Invoices Latency' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri LIKE '/api/invoices%' +FACET request.method +SINCE 1 hour ago; + +-- --------------------------------------------------------------------------- +-- 5. MULTI-TENANT / ORG OPERATIONS +-- --------------------------------------------------------------------------- + +-- Organizations endpoint performance +SELECT percentile(duration, 50, 95) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri LIKE '/api/organizations%' +FACET request.method +SINCE 1 hour ago; + +-- Board planning (complex module) latency +SELECT count(*), percentile(duration, 50, 95, 99) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND request.uri LIKE '/api/board-planning%' +FACET request.uri +SINCE 1 hour ago; + +-- --------------------------------------------------------------------------- +-- 6. ERROR ANALYSIS +-- --------------------------------------------------------------------------- + +-- Error rate by endpoint (top 20 offenders) +SELECT percentage(count(*), WHERE httpResponseCode >= 400) AS 'Error %', + count(*) AS 'Total' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +FACET request.uri +SINCE 1 hour ago +LIMIT 20; + +-- 5xx errors specifically +SELECT count(*) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND httpResponseCode >= 500 +FACET request.uri, httpResponseCode +SINCE 1 hour ago; + +-- Error rate over time +SELECT percentage(count(*), WHERE httpResponseCode >= 500) AS 'Server Error %', + percentage(count(*), WHERE httpResponseCode >= 400 AND httpResponseCode < 500) AS 'Client Error %' +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +SINCE 1 hour ago +TIMESERIES AUTO; + +-- --------------------------------------------------------------------------- +-- 7. DATABASE & EXTERNAL SERVICES +-- --------------------------------------------------------------------------- + +-- Database call duration (TypeORM / Postgres) +SELECT average(databaseDuration), percentile(databaseDuration, 95) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +SINCE 1 hour ago +TIMESERIES AUTO; + +-- Slowest DB transactions +SELECT average(databaseDuration), count(*) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND databaseDuration > 1 +FACET request.uri +SINCE 1 hour ago +LIMIT 20; + +-- External service calls (Stripe, Resend, NVIDIA AI) +SELECT average(externalDuration), count(*) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' + AND externalDuration > 0 +FACET request.uri +SINCE 1 hour ago; + +-- --------------------------------------------------------------------------- +-- 8. LOAD TEST COMPARISON +-- --------------------------------------------------------------------------- + +-- Compare metrics between two time windows (baseline vs test) +-- Adjust SINCE/UNTIL for your test windows +SELECT percentile(duration, 50, 95, 99), count(*), percentage(count(*), WHERE httpResponseCode >= 500) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +SINCE 2 hours ago +UNTIL 1 hour ago +COMPARE WITH 1 hour ago; + +-- Per-endpoint comparison during load test window +SELECT average(duration), percentile(duration, 95), count(*) +FROM Transaction +WHERE appName = 'HOALedgerIQ_App' +FACET request.uri +SINCE 1 hour ago +LIMIT 50; + +-- --------------------------------------------------------------------------- +-- 9. INFRASTRUCTURE (if NR Infrastructure agent is installed) +-- --------------------------------------------------------------------------- + +-- CPU utilization +SELECT average(cpuPercent) +FROM SystemSample +WHERE hostname LIKE '%hoaledgeriq%' +SINCE 1 hour ago +TIMESERIES AUTO; + +-- Memory utilization +SELECT average(memoryUsedPercent) +FROM SystemSample +WHERE hostname LIKE '%hoaledgeriq%' +SINCE 1 hour ago +TIMESERIES AUTO; + +-- Connection pool saturation (custom metric – requires NR custom events) +-- SELECT average(custom.db.pool.active), average(custom.db.pool.idle) +-- FROM Metric +-- WHERE appName = 'HOALedgerIQ_App' +-- SINCE 1 hour ago +-- TIMESERIES AUTO; diff --git a/load-tests/config/environments.json b/load-tests/config/environments.json new file mode 100644 index 0000000..a1a5968 --- /dev/null +++ b/load-tests/config/environments.json @@ -0,0 +1,53 @@ +{ + "staging": { + "baseUrl": "https://staging.hoaledgeriq.com", + "stages": { + "rampUp": 10, + "steady": 25, + "rampDown": 0 + }, + "thresholds": { + "http_req_duration_p95": 2000, + "login_p95": 1500, + "dashboard_p95": 3000, + "crud_write_p95": 2000, + "crud_read_p95": 1500, + "error_rate": 0.05 + }, + "notes": "Staging environment – smaller infra, relaxed thresholds" + }, + "production": { + "baseUrl": "https://app.hoaledgeriq.com", + "stages": { + "rampUp": 20, + "steady": 50, + "rampDown": 0 + }, + "thresholds": { + "http_req_duration_p95": 1000, + "login_p95": 800, + "dashboard_p95": 1500, + "crud_write_p95": 1000, + "crud_read_p95": 800, + "error_rate": 0.01 + }, + "notes": "Production thresholds – strict SLA targets" + }, + "local": { + "baseUrl": "http://localhost:3000", + "stages": { + "rampUp": 5, + "steady": 10, + "rampDown": 0 + }, + "thresholds": { + "http_req_duration_p95": 3000, + "login_p95": 2000, + "dashboard_p95": 5000, + "crud_write_p95": 3000, + "crud_read_p95": 2000, + "error_rate": 0.10 + }, + "notes": "Local dev – generous thresholds for single-machine testing" + } +} diff --git a/load-tests/config/user-pool.csv b/load-tests/config/user-pool.csv new file mode 100644 index 0000000..abc8c40 --- /dev/null +++ b/load-tests/config/user-pool.csv @@ -0,0 +1,11 @@ +email,password,orgId,role +loadtest-treasurer-01@hoaledgeriq.test,LoadTest!Pass01,org-uuid-placeholder-1,treasurer +loadtest-treasurer-02@hoaledgeriq.test,LoadTest!Pass02,org-uuid-placeholder-1,treasurer +loadtest-admin-01@hoaledgeriq.test,LoadTest!Pass03,org-uuid-placeholder-1,admin +loadtest-admin-02@hoaledgeriq.test,LoadTest!Pass04,org-uuid-placeholder-2,admin +loadtest-president-01@hoaledgeriq.test,LoadTest!Pass05,org-uuid-placeholder-2,president +loadtest-manager-01@hoaledgeriq.test,LoadTest!Pass06,org-uuid-placeholder-2,manager +loadtest-member-01@hoaledgeriq.test,LoadTest!Pass07,org-uuid-placeholder-1,member_at_large +loadtest-viewer-01@hoaledgeriq.test,LoadTest!Pass08,org-uuid-placeholder-1,viewer +loadtest-homeowner-01@hoaledgeriq.test,LoadTest!Pass09,org-uuid-placeholder-2,homeowner +loadtest-homeowner-02@hoaledgeriq.test,LoadTest!Pass10,org-uuid-placeholder-2,homeowner diff --git a/load-tests/scenarios/auth-dashboard-flow.js b/load-tests/scenarios/auth-dashboard-flow.js new file mode 100644 index 0000000..4a6c8f7 --- /dev/null +++ b/load-tests/scenarios/auth-dashboard-flow.js @@ -0,0 +1,189 @@ +import http from 'k6/http'; +import { check, group, sleep } from 'k6'; +import { SharedArray } from 'k6/data'; +import { Counter, Rate, Trend } from 'k6/metrics'; + +// --------------------------------------------------------------------------- +// Custom metrics +// --------------------------------------------------------------------------- +const loginDuration = new Trend('login_duration', true); +const refreshDuration = new Trend('refresh_duration', true); +const dashboardDuration = new Trend('dashboard_duration', true); +const profileDuration = new Trend('profile_duration', true); +const loginFailures = new Counter('login_failures'); +const authErrors = new Rate('auth_error_rate'); + +// --------------------------------------------------------------------------- +// Test user pool – parameterized from CSV +// --------------------------------------------------------------------------- +const users = new SharedArray('users', function () { + const lines = open('../config/user-pool.csv').split('\n').slice(1); // skip header + return lines + .filter((l) => l.trim().length > 0) + .map((line) => { + const [email, password, orgId, role] = line.split(','); + return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() }; + }); +}); + +// --------------------------------------------------------------------------- +// Environment config +// --------------------------------------------------------------------------- +const ENV = JSON.parse(open('../config/environments.json')); +const CONF = ENV[__ENV.TARGET_ENV || 'staging']; +const BASE = CONF.baseUrl; + +// --------------------------------------------------------------------------- +// k6 options – ramp-up / steady / ramp-down +// --------------------------------------------------------------------------- +export const options = { + scenarios: { + auth_dashboard: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '1m', target: CONF.stages.rampUp }, // ramp-up + { duration: '5m', target: CONF.stages.steady }, // steady state + { duration: '1m', target: 0 }, // ramp-down + ], + gracefulStop: '30s', + }, + }, + thresholds: { + http_req_duration: [`p(95)<${CONF.thresholds.http_req_duration_p95}`], + login_duration: [`p(95)<${CONF.thresholds.login_p95}`], + dashboard_duration: [`p(95)<${CONF.thresholds.dashboard_p95}`], + auth_error_rate: [`rate<${CONF.thresholds.error_rate}`], + http_req_failed: [`rate<${CONF.thresholds.error_rate}`], + }, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function authHeaders(accessToken) { + return { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }; +} + +function jsonPost(url, body, params = {}) { + return http.post(url, JSON.stringify(body), { + headers: { 'Content-Type': 'application/json', ...((params.headers) || {}) }, + tags: params.tags || {}, + }); +} + +// --------------------------------------------------------------------------- +// Default VU function – login → dashboard journey +// --------------------------------------------------------------------------- +export default function () { + const user = users[__VU % users.length]; + + let accessToken = null; + + // ── 1. Login ───────────────────────────────────────────────────────── + group('01_login', () => { + const res = jsonPost(`${BASE}/api/auth/login`, { + email: user.email, + password: user.password, + }, { tags: { name: 'POST /api/auth/login' } }); + + loginDuration.add(res.timings.duration); + + const ok = check(res, { + 'login status 200|201': (r) => r.status === 200 || r.status === 201, + 'login returns accessToken': (r) => { + try { return !!JSON.parse(r.body).accessToken; } catch { return false; } + }, + }); + + if (!ok) { + loginFailures.add(1); + authErrors.add(1); + return; // abort journey – cannot continue without token + } + authErrors.add(0); + + const body = JSON.parse(res.body); + accessToken = body.accessToken; + }); + + if (!accessToken) return; // guard + + sleep(0.5); // think-time between login and dashboard load + + // ── 2. Fetch profile ──────────────────────────────────────────────── + group('02_profile', () => { + const res = http.get(`${BASE}/api/auth/profile`, authHeaders(accessToken)); + profileDuration.add(res.timings.duration); + check(res, { + 'profile status 200': (r) => r.status === 200, + }); + }); + + sleep(0.3); + + // ── 3. Dashboard KPIs ─────────────────────────────────────────────── + group('03_dashboard', () => { + const res = http.get(`${BASE}/api/reports/dashboard`, authHeaders(accessToken)); + dashboardDuration.add(res.timings.duration); + check(res, { + 'dashboard status 200': (r) => r.status === 200, + }); + }); + + sleep(0.3); + + // ── 4. Parallel dashboard widgets (batch) ─────────────────────────── + group('04_dashboard_widgets', () => { + const now = new Date(); + const year = now.getFullYear(); + const fromDate = `${year}-01-01`; + const toDate = now.toISOString().slice(0, 10); + + const responses = http.batch([ + ['GET', `${BASE}/api/accounts?fundType=operating`, null, authHeaders(accessToken)], + ['GET', `${BASE}/api/reports/income-statement?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)], + ['GET', `${BASE}/api/reports/balance-sheet?as_of=${toDate}`, null, authHeaders(accessToken)], + ['GET', `${BASE}/api/reports/aging`, null, authHeaders(accessToken)], + ['GET', `${BASE}/api/health-scores/latest`, null, authHeaders(accessToken)], + ['GET', `${BASE}/api/onboarding/progress`, null, authHeaders(accessToken)], + ]); + + responses.forEach((res, i) => { + check(res, { + [`widget_${i} status 200`]: (r) => r.status === 200, + }); + }); + }); + + sleep(0.5); + + // ── 5. Refresh token ─────────────────────────────────────────────── + group('05_refresh_token', () => { + const res = http.post(`${BASE}/api/auth/refresh`, null, { + headers: { 'Content-Type': 'application/json' }, + tags: { name: 'POST /api/auth/refresh' }, + }); + refreshDuration.add(res.timings.duration); + check(res, { + 'refresh status 200|201': (r) => r.status === 200 || r.status === 201, + }); + }); + + sleep(0.5); + + // ── 6. Logout ────────────────────────────────────────────────────── + group('06_logout', () => { + const res = http.post(`${BASE}/api/auth/logout`, null, authHeaders(accessToken)); + check(res, { + 'logout status 200|201': (r) => r.status === 200 || r.status === 201, + }); + }); + + sleep(1); // pacing between iterations +} diff --git a/load-tests/scenarios/crud-flow.js b/load-tests/scenarios/crud-flow.js new file mode 100644 index 0000000..2a07fa3 --- /dev/null +++ b/load-tests/scenarios/crud-flow.js @@ -0,0 +1,377 @@ +import http from 'k6/http'; +import { check, group, sleep } from 'k6'; +import { SharedArray } from 'k6/data'; +import { Counter, Rate, Trend } from 'k6/metrics'; + +// --------------------------------------------------------------------------- +// Custom metrics +// --------------------------------------------------------------------------- +const loginDuration = new Trend('crud_login_duration', true); +const createDuration = new Trend('crud_create_duration', true); +const readDuration = new Trend('crud_read_duration', true); +const updateDuration = new Trend('crud_update_duration', true); +const deleteDuration = new Trend('crud_delete_duration', true); +const listDuration = new Trend('crud_list_duration', true); +const crudErrors = new Rate('crud_error_rate'); + +// --------------------------------------------------------------------------- +// Test user pool +// --------------------------------------------------------------------------- +const users = new SharedArray('users', function () { + const lines = open('../config/user-pool.csv').split('\n').slice(1); + return lines + .filter((l) => l.trim().length > 0) + .map((line) => { + const [email, password, orgId, role] = line.split(','); + return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() }; + }); +}); + +// --------------------------------------------------------------------------- +// Environment config +// --------------------------------------------------------------------------- +const ENV = JSON.parse(open('../config/environments.json')); +const CONF = ENV[__ENV.TARGET_ENV || 'staging']; +const BASE = CONF.baseUrl; + +// --------------------------------------------------------------------------- +// k6 options +// --------------------------------------------------------------------------- +export const options = { + scenarios: { + crud_flow: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '1m', target: CONF.stages.rampUp }, + { duration: '5m', target: CONF.stages.steady }, + { duration: '1m', target: 0 }, + ], + gracefulStop: '30s', + }, + }, + thresholds: { + http_req_duration: [`p(95)<${CONF.thresholds.http_req_duration_p95}`], + crud_create_duration: [`p(95)<${CONF.thresholds.crud_write_p95}`], + crud_update_duration: [`p(95)<${CONF.thresholds.crud_write_p95}`], + crud_read_duration: [`p(95)<${CONF.thresholds.crud_read_p95}`], + crud_list_duration: [`p(95)<${CONF.thresholds.crud_read_p95}`], + crud_error_rate: [`rate<${CONF.thresholds.error_rate}`], + http_req_failed: [`rate<${CONF.thresholds.error_rate}`], + }, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function authHeaders(token) { + return { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }; +} + +function jsonPost(url, body, token, tags) { + return http.post(url, JSON.stringify(body), { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + tags: tags || {}, + }); +} + +function jsonPut(url, body, token, tags) { + return http.put(url, JSON.stringify(body), { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + tags: tags || {}, + }); +} + +function login(user) { + const res = http.post( + `${BASE}/api/auth/login`, + JSON.stringify({ email: user.email, password: user.password }), + { headers: { 'Content-Type': 'application/json' }, tags: { name: 'POST /api/auth/login' } }, + ); + loginDuration.add(res.timings.duration); + if (res.status !== 200 && res.status !== 201) return null; + try { return JSON.parse(res.body).accessToken; } catch { return null; } +} + +// --------------------------------------------------------------------------- +// VU function – CRUD journey across core entities +// --------------------------------------------------------------------------- +export default function () { + const user = users[__VU % users.length]; + + // ── 1. Login ───────────────────────────────────────────────────────── + const accessToken = login(user); + if (!accessToken) { + crudErrors.add(1); + return; + } + crudErrors.add(0); + sleep(0.5); + + // ── 2. Units CRUD ─────────────────────────────────────────────────── + let unitId = null; + group('units_crud', () => { + // List + group('list_units', () => { + const res = http.get(`${BASE}/api/units`, authHeaders(accessToken)); + listDuration.add(res.timings.duration); + check(res, { 'list units 200': (r) => r.status === 200 }); + }); + sleep(0.3); + + // Create + group('create_unit', () => { + const payload = { + unitNumber: `LT-${__VU}-${Date.now()}`, + address: `${__VU} Load Test Lane`, + ownerName: `Load Tester ${__VU}`, + ownerEmail: `lt-${__VU}@loadtest.local`, + squareFeet: 1200, + }; + const res = jsonPost(`${BASE}/api/units`, payload, accessToken, { name: 'POST /api/units' }); + createDuration.add(res.timings.duration); + const ok = check(res, { + 'create unit 200|201': (r) => r.status === 200 || r.status === 201, + }); + if (ok) { + try { unitId = JSON.parse(res.body).id; } catch { /* noop */ } + } + }); + sleep(0.3); + + // Read + if (unitId) { + group('read_unit', () => { + const res = http.get(`${BASE}/api/units/${unitId}`, authHeaders(accessToken)); + readDuration.add(res.timings.duration); + check(res, { 'read unit 200': (r) => r.status === 200 }); + }); + sleep(0.3); + + // Update + group('update_unit', () => { + const res = jsonPut(`${BASE}/api/units/${unitId}`, { + ownerName: `Updated Tester ${__VU}`, + }, accessToken, { name: 'PUT /api/units/:id' }); + updateDuration.add(res.timings.duration); + check(res, { 'update unit 200': (r) => r.status === 200 }); + }); + sleep(0.3); + + // Delete + group('delete_unit', () => { + const res = http.del(`${BASE}/api/units/${unitId}`, null, authHeaders(accessToken)); + deleteDuration.add(res.timings.duration); + check(res, { 'delete unit 200': (r) => r.status === 200 }); + }); + } + }); + + sleep(0.5); + + // ── 3. Vendors CRUD ───────────────────────────────────────────────── + let vendorId = null; + group('vendors_crud', () => { + group('list_vendors', () => { + const res = http.get(`${BASE}/api/vendors`, authHeaders(accessToken)); + listDuration.add(res.timings.duration); + check(res, { 'list vendors 200': (r) => r.status === 200 }); + }); + sleep(0.3); + + group('create_vendor', () => { + const payload = { + name: `LT Vendor ${__VU}-${Date.now()}`, + email: `vendor-${__VU}@loadtest.local`, + phone: '555-0100', + category: 'maintenance', + }; + const res = jsonPost(`${BASE}/api/vendors`, payload, accessToken, { name: 'POST /api/vendors' }); + createDuration.add(res.timings.duration); + const ok = check(res, { + 'create vendor 200|201': (r) => r.status === 200 || r.status === 201, + }); + if (ok) { + try { vendorId = JSON.parse(res.body).id; } catch { /* noop */ } + } + }); + sleep(0.3); + + if (vendorId) { + group('read_vendor', () => { + const res = http.get(`${BASE}/api/vendors/${vendorId}`, authHeaders(accessToken)); + readDuration.add(res.timings.duration); + check(res, { 'read vendor 200': (r) => r.status === 200 }); + }); + sleep(0.3); + + group('update_vendor', () => { + const res = jsonPut(`${BASE}/api/vendors/${vendorId}`, { + name: `Updated Vendor ${__VU}`, + }, accessToken, { name: 'PUT /api/vendors/:id' }); + updateDuration.add(res.timings.duration); + check(res, { 'update vendor 200': (r) => r.status === 200 }); + }); + } + }); + + sleep(0.5); + + // ── 4. Journal Entries workflow ───────────────────────────────────── + let journalEntryId = null; + group('journal_entries', () => { + group('list_journal_entries', () => { + const res = http.get(`${BASE}/api/journal-entries`, authHeaders(accessToken)); + listDuration.add(res.timings.duration); + check(res, { 'list JE 200': (r) => r.status === 200 }); + }); + sleep(0.3); + + // Fetch accounts first so we can build a valid entry + let accounts = []; + group('fetch_accounts_for_je', () => { + const res = http.get(`${BASE}/api/accounts`, authHeaders(accessToken)); + check(res, { 'list accounts 200': (r) => r.status === 200 }); + try { accounts = JSON.parse(res.body); } catch { /* noop */ } + }); + sleep(0.3); + + if (Array.isArray(accounts) && accounts.length >= 2) { + group('create_journal_entry', () => { + const payload = { + date: new Date().toISOString().slice(0, 10), + memo: `Load test JE VU-${__VU}`, + type: 'standard', + lines: [ + { accountId: accounts[0].id, debit: 100, credit: 0, memo: 'debit leg' }, + { accountId: accounts[1].id, debit: 0, credit: 100, memo: 'credit leg' }, + ], + }; + const res = jsonPost(`${BASE}/api/journal-entries`, payload, accessToken, { name: 'POST /api/journal-entries' }); + createDuration.add(res.timings.duration); + const ok = check(res, { + 'create JE 200|201': (r) => r.status === 200 || r.status === 201, + }); + if (ok) { + try { journalEntryId = JSON.parse(res.body).id; } catch { /* noop */ } + } + }); + sleep(0.3); + + if (journalEntryId) { + group('read_journal_entry', () => { + const res = http.get(`${BASE}/api/journal-entries/${journalEntryId}`, authHeaders(accessToken)); + readDuration.add(res.timings.duration); + check(res, { 'read JE 200': (r) => r.status === 200 }); + }); + sleep(0.3); + + // Post (finalize) the journal entry + group('post_journal_entry', () => { + const res = http.post(`${BASE}/api/journal-entries/${journalEntryId}/post`, null, authHeaders(accessToken)); + updateDuration.add(res.timings.duration); + check(res, { 'post JE 200': (r) => r.status === 200 }); + }); + sleep(0.3); + + // Void the journal entry (cleanup) + group('void_journal_entry', () => { + const res = http.post(`${BASE}/api/journal-entries/${journalEntryId}/void`, null, authHeaders(accessToken)); + deleteDuration.add(res.timings.duration); + check(res, { 'void JE 200': (r) => r.status === 200 }); + }); + } + } + }); + + sleep(0.5); + + // ── 5. Payments CRUD ──────────────────────────────────────────────── + let paymentId = null; + group('payments_crud', () => { + group('list_payments', () => { + const res = http.get(`${BASE}/api/payments`, authHeaders(accessToken)); + listDuration.add(res.timings.duration); + check(res, { 'list payments 200': (r) => r.status === 200 }); + }); + sleep(0.3); + + group('create_payment', () => { + const payload = { + amount: 150.00, + date: new Date().toISOString().slice(0, 10), + method: 'check', + memo: `Load test payment VU-${__VU}`, + }; + const res = jsonPost(`${BASE}/api/payments`, payload, accessToken, { name: 'POST /api/payments' }); + createDuration.add(res.timings.duration); + const ok = check(res, { + 'create payment 200|201': (r) => r.status === 200 || r.status === 201, + }); + if (ok) { + try { paymentId = JSON.parse(res.body).id; } catch { /* noop */ } + } + }); + sleep(0.3); + + if (paymentId) { + group('read_payment', () => { + const res = http.get(`${BASE}/api/payments/${paymentId}`, authHeaders(accessToken)); + readDuration.add(res.timings.duration); + check(res, { 'read payment 200': (r) => r.status === 200 }); + }); + sleep(0.3); + + group('update_payment', () => { + const res = jsonPut(`${BASE}/api/payments/${paymentId}`, { + memo: `Updated payment VU-${__VU}`, + }, accessToken, { name: 'PUT /api/payments/:id' }); + updateDuration.add(res.timings.duration); + check(res, { 'update payment 200': (r) => r.status === 200 }); + }); + sleep(0.3); + + group('delete_payment', () => { + const res = http.del(`${BASE}/api/payments/${paymentId}`, null, authHeaders(accessToken)); + deleteDuration.add(res.timings.duration); + check(res, { 'delete payment 200': (r) => r.status === 200 }); + }); + } + }); + + sleep(0.5); + + // ── 6. Reports (read-heavy) ───────────────────────────────────────── + group('reports_read', () => { + const now = new Date(); + const year = now.getFullYear(); + const toDate = now.toISOString().slice(0, 10); + const fromDate = `${year}-01-01`; + + const responses = http.batch([ + ['GET', `${BASE}/api/reports/balance-sheet?as_of=${toDate}`, null, authHeaders(accessToken)], + ['GET', `${BASE}/api/reports/income-statement?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)], + ['GET', `${BASE}/api/reports/aging`, null, authHeaders(accessToken)], + ['GET', `${BASE}/api/reports/cash-flow?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)], + ['GET', `${BASE}/api/accounts/trial-balance?asOfDate=${toDate}`, null, authHeaders(accessToken)], + ]); + + responses.forEach((res, i) => { + readDuration.add(res.timings.duration); + check(res, { [`report_${i} status 200`]: (r) => r.status === 200 }); + }); + }); + + sleep(1); // pacing +}