23 Commits

Author SHA1 Message Date
a025c9e979 fix: health check now probes HTTP directly with 3-min timeout
The previous approach relied on Docker's container health status, but
Docker's healthcheck (start_period:30s + 3x15s retries = ~75s) marks
the container "unhealthy" before NestJS finishes cold-starting after a
fresh image build (New Relic + TypeORM + Redis + BullMQ init can take
2-3 minutes).

Changes:
- Primary check is now direct wget to localhost:3000/api from the host
- Docker health status used only for informational logging
- Total timeout increased from 130s to 190s (~3 min) for cold starts
- Early exit if container has stopped/exited (no point waiting)
- More backend log lines (30 vs 20) shown on failure for diagnostics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 09:42:49 -04:00
19bd19b0c4 docs: add Gitea Actions runner setup guide for production server
Step-by-step guide covering act_runner installation, registration,
host execution mode configuration, systemd service setup, and
troubleshooting for the HOALedgerIQ production deployment workflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 09:35:49 -04:00
3e7463cf46 fix: replace curl with Docker health status and wget for health check
The health check used curl which is not installed on the prod server.
Replace with a dual approach:
1. Primary: check Docker's own container health status (already running
   via docker-compose.prod.yml healthcheck with wget inside container)
2. Secondary: wget from host as fallback signal

Also add diagnostic logging (container status + recent backend logs)
before triggering rollback on health check failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 09:22:28 -04:00
2aad137bd7 fix: resolve unbound variable error in deploy script migration check
The APPLIED_MIGRATIONS associative array triggered "unbound variable"
under set -u when empty (first run / seed-existing). Fix by initializing
with =() and using a safe helper function with ${:-} default syntax.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 09:15:54 -04:00
e06ca74d1d fix: remove bc dependency from db-backup.sh format_size function
Replace bc-based floating point division with pure bash integer
arithmetic so the script works on minimal Ubuntu servers without
bc installed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 09:09:24 -04:00
95c83a57b6 feat: add production deploy script with auto-rollback and Gitea Actions workflow
Add automated production deployment pipeline:
- scripts/deploy-prod.sh: Full deployment script with pre/post DB backups,
  migration tracking via shared.schema_migrations table, health checks,
  and automatic rollback on failure (restores DB, reverts code, rebuilds)
- .gitea/workflows/deploy.yml: Manual-trigger Gitea Actions workflow for
  intentional production deployments with optional --seed-existing flag
- scripts/db-backup.sh: Add --yes/-y flag to skip interactive confirmation
  prompts, enabling automated restore during rollback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 09:05:45 -04:00
83115c9b5c Merge pull request 'feat: add flexible capability-based RBAC with per-tenant customization' (#14) from feature-rbac into main
Reviewed-on: #14
2026-04-06 16:13:26 -04:00
c57dd3e155 Merge branch 'main' into feature-rbac 2026-04-06 16:13:17 -04:00
afe5633b0a Updating Version 2026-04-06 16:13:00 -04:00
43b10869f0 feat: add flexible capability-based RBAC with per-tenant customization
Introduces a capability layer on top of existing roles that controls
feature visibility and access. Capabilities follow an area.feature.action
taxonomy (~35 capabilities) with sensible defaults per role. Tenant admins
can customize via grant/revoke overrides stored in org settings JSONB.

Key changes:
- Add vice_president role to DB schema
- Backend: capability constants, resolution logic, CapabilityGuard (global),
  @RequireCapability decorator on all 16 tenant controllers
- Frontend: permission hooks (useCanEdit, useHasCapability), CapabilityGate
  component, sidebar filtering by capability, all 17 pages migrated from
  useIsReadOnly to capability-based checks
- New admin UI: /settings/permissions matrix page for per-tenant role
  customization with grant/revoke delta model
- GET /organizations/my-capabilities endpoint for capability refresh
- Validation of permissionOverrides in settings updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 15:28:14 -04:00
f76c67f51a Update Version 2026-04-05 09:16:51 -04:00
5fec296569 Merge pull request 'fix: normalize API URL to prevent duplicate /chat/completions path' (#13) from feature-shadowAI into main
Reviewed-on: #13
2026-04-05 08:19:26 -04:00
c981676bc7 Merge branch 'main' into feature-shadowAI 2026-04-05 08:19:15 -04:00
JoeBot
bd174fc22b fix: normalize API URL to prevent duplicate /chat/completions path
Users entering the full endpoint URL (e.g. https://openrouter.ai/api/v1/chat/completions)
caused a 404 because the code appended /chat/completions again. Now strips any trailing
/chat/completions before re-appending, and adds a hint in the UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 08:14:37 -04:00
827eef4f49 Merge pull request 'feat: add shadow AI benchmarking for admin model comparison' (#12) from feature-shadowAI into main
Reviewed-on: #12
2026-04-05 07:54:11 -04:00
JoeBot
4797669591 feat: add shadow AI benchmarking for admin model comparison
Add a new admin-only feature that allows the platform owner to benchmark
the production AI model against up to 2 alternate models (any OpenAI-compatible
API) using real tenant data, without impacting users.

Backend:
- Shared AI caller utility (ai-caller.ts) for OpenAI-compatible endpoints
- Shadow AI module with service, controller, and 3 entities
- 6 admin API endpoints for model config CRUD, run trigger, and history
- Auto-creates shadow_ai_models, shadow_runs, shadow_run_results tables
- Exposes health-scores and investment-planning prompt builders for reuse

Frontend:
- New admin page at /admin/shadow-ai with 3 tabs:
  - Model Configuration (production + 2 alternate slots)
  - Run Comparison (tenant select, feature select, side-by-side results)
  - History (filterable run log with detail drill-down)
- Full side-by-side output display with diff highlighting
- Sidebar navigation link for AI Benchmarking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 07:50:59 -04:00
629d112850 Merge pull request 'Add k6 load testing suite and CLAUDE.md' (#9) from claude/beautiful-gauss into main
Reviewed-on: #9
2026-04-02 17:42:35 -04:00
32506d6a2e Merge branch 'main' into claude/beautiful-gauss 2026-04-02 17:42:24 -04:00
9a60970837 Updated Version 2026-04-02 17:41:49 -04:00
1ade446187 Merge pull request 'ideation-feature' (#11) from ideation-feature into main
Reviewed-on: #11
2026-04-02 17:39:32 -04:00
JoeBot
d430b96b51 feat: add admin ideas management page with private notes
Adds a dedicated super admin page for managing idea submissions across
all tenants. Includes status summary cards, filterable/searchable table,
detail modal with status updates, and private admin notes for internal
tracking (sprint refs, thoughts, follow-ups). Notes are not visible to
tenant users.

- Database: admin_note column on shared.ideas (019 migration)
- Backend: PUT /admin/ideas/:id/note endpoint
- Frontend: AdminIdeasPage with table, filters, detail modal
- Sidebar: "Idea Submissions" nav link in admin sections
- Routing: /admin/ideas route under SuperAdminRoute guard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 17:35:30 -04:00
JoeBot
140cd7acb7 feat: add ideation feature with per-tenant toggle
Adds idea submission capability gated by a per-tenant feature flag.
Super admins can enable/disable ideation for specific tenants via the
admin tenant detail drawer. Users see a lightbulb icon in the header
when enabled, opening a modal to submit ideas (title + description).
Ideas are stored in shared schema for cross-tenant backlog querying.

- Database: shared.ideas table (018-ideas.sql migration)
- Backend: Ideas NestJS module (entity, service, controller)
- Admin API: GET /admin/ideas, PUT /admin/ideas/:id/status,
  PUT /admin/organizations/:id/settings
- Frontend: IdeaModal component, lightbulb ActionIcon in header
- Admin UI: Feature Toggles card with ideation Switch in drawer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 17:20:37 -04:00
06bc0181f8 feat: add k6 load testing suite, NRQL query library, and CLAUDE.md
Add comprehensive load testing infrastructure:
- k6 auth-dashboard flow (login → profile → dashboard KPIs → widgets → refresh → logout)
- k6 CRUD flow (units, vendors, journal entries, payments, reports)
- Environment configs with staging/production/local thresholds
- Parameterized user pool CSV matching app roles
- New Relic NRQL query library (25+ queries for perf analysis)
- Empty baseline.json structure for all tested endpoints
- CLAUDE.md documenting full stack, auth, route map, and conventions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 15:49:22 -04:00
81 changed files with 4074 additions and 95 deletions

View 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

229
CLAUDE.md Normal file
View File

@@ -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

View File

@@ -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';
@@ -33,6 +34,7 @@ import { BoardPlanningModule } from './modules/board-planning/board-planning.mod
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';
@@ -89,6 +91,7 @@ import { ScheduleModule } from '@nestjs/schedule';
BillingModule,
EmailModule,
OnboardingModule,
IdeasModule,
ShadowAiModule,
ScheduleModule.forRoot(),
],
@@ -98,6 +101,10 @@ import { ScheduleModule } from '@nestjs/schedule';
provide: APP_GUARD,
useClass: WriteAccessGuard,
},
{
provide: APP_GUARD,
useClass: CapabilityGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: NoCacheInterceptor,

View 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);

View 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);
}
}

View 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));

View 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,
],
};

View 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';

View 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();
}

View File

@@ -37,7 +37,12 @@ export async function callOpenAICompatible(params: AICallerParams): Promise<AICa
const https = await import('https');
const aiResult = await new Promise<{ status: number; body: string }>((resolve, reject) => {
const url = new URL(`${apiUrl}/chat/completions`);
// 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,

View File

@@ -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);
}

View File

@@ -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); }
}

View File

@@ -5,6 +5,7 @@ import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { OrganizationsService } from '../organizations/organizations.service';
import { AdminAnalyticsService } from './admin-analytics.service';
import { IdeasService } from '../ideas/ideas.service';
import * as bcrypt from 'bcryptjs';
@ApiTags('admin')
@@ -17,6 +18,7 @@ export class AdminController {
private usersService: UsersService,
private orgService: OrganizationsService,
private analyticsService: AdminAnalyticsService,
private ideasService: IdeasService,
) {}
private async requireSuperadmin(req: any) {
@@ -196,4 +198,45 @@ export class AdminController {
return { success: true, organization: org };
}
// ── Ideation ──
@Get('ideas')
async listAllIdeas(@Req() req: any) {
await this.requireSuperadmin(req);
return this.ideasService.findAll();
}
@Put('ideas/:id/status')
async updateIdeaStatus(
@Req() req: any,
@Param('id') id: string,
@Body() body: { status: string },
) {
await this.requireSuperadmin(req);
const idea = await this.ideasService.updateStatus(id, body.status);
return { success: true, idea };
}
@Put('ideas/:id/note')
async updateIdeaNote(
@Req() req: any,
@Param('id') id: string,
@Body() body: { adminNote: string },
) {
await this.requireSuperadmin(req);
const idea = await this.ideasService.updateNote(id, body.adminNote);
return { success: true, idea };
}
@Put('organizations/:id/settings')
async updateOrgSettings(
@Req() req: any,
@Param('id') id: string,
@Body() body: Record<string, any>,
) {
await this.requireSuperadmin(req);
const org = await this.orgService.updateSettings(id, body);
return { success: true, organization: org };
}
}

View File

@@ -17,11 +17,13 @@ import { JwtStrategy } from './strategies/jwt.strategy';
import { LocalStrategy } from './strategies/local.strategy';
import { UsersModule } from '../users/users.module';
import { OrganizationsModule } from '../organizations/organizations.module';
import { IdeasModule } from '../ideas/ideas.module';
@Module({
imports: [
UsersModule,
OrganizationsModule,
IdeasModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],

View File

@@ -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),
};
}),
};
}

View File

@@ -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));
}

View File

@@ -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[],

View File

@@ -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); }
}

View File

@@ -0,0 +1,12 @@
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
export class CreateIdeaDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
title: string;
@IsString()
@IsOptional()
description?: string;
}

View File

@@ -0,0 +1,49 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Organization } from '../../organizations/entities/organization.entity';
import { User } from '../../users/entities/user.entity';
@Entity({ schema: 'shared', name: 'ideas' })
export class Idea {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'org_id' })
orgId: string;
@Column({ name: 'user_id' })
userId: string;
@Column({ length: 255 })
title: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ length: 20, default: 'new' })
status: string;
@Column({ name: 'admin_note', type: 'text', nullable: true })
adminNote: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'org_id' })
organization: Organization;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
}

View File

@@ -0,0 +1,27 @@
import { Controller, Get, Post, Body, Req, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { IdeasService } from './ideas.service';
import { CreateIdeaDto } from './dto/create-idea.dto';
@ApiTags('ideas')
@Controller('ideas')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class IdeasController {
constructor(private ideasService: IdeasService) {}
@Post()
async create(@Req() req: any, @Body() dto: CreateIdeaDto) {
const orgId = req.user.orgId;
const userId = req.user.userId || req.user.sub;
const idea = await this.ideasService.create(orgId, userId, dto);
return { success: true, idea };
}
@Get()
async findByOrg(@Req() req: any) {
const orgId = req.user.orgId;
return this.ideasService.findByOrg(orgId);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Idea } from './entities/idea.entity';
import { Organization } from '../organizations/entities/organization.entity';
import { IdeasController } from './ideas.controller';
import { IdeasService } from './ideas.service';
@Module({
imports: [TypeOrmModule.forFeature([Idea, Organization])],
controllers: [IdeasController],
providers: [IdeasService],
exports: [IdeasService],
})
export class IdeasModule {}

View File

@@ -0,0 +1,89 @@
import { Injectable, ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Idea } from './entities/idea.entity';
import { Organization } from '../organizations/entities/organization.entity';
import { CreateIdeaDto } from './dto/create-idea.dto';
@Injectable()
export class IdeasService {
constructor(
@InjectRepository(Idea)
private ideasRepository: Repository<Idea>,
@InjectRepository(Organization)
private orgRepository: Repository<Organization>,
) {}
async create(orgId: string, userId: string, dto: CreateIdeaDto): Promise<Idea> {
const org = await this.orgRepository.findOne({ where: { id: orgId } });
if (!org) {
throw new NotFoundException('Organization not found');
}
if (org.settings?.ideationEnabled !== true) {
throw new ForbiddenException('Ideation is not enabled for this organization');
}
const idea = this.ideasRepository.create({
orgId,
userId,
title: dto.title,
description: dto.description,
});
return this.ideasRepository.save(idea);
}
async findByOrg(orgId: string): Promise<Idea[]> {
return this.ideasRepository.find({
where: { orgId },
order: { createdAt: 'DESC' },
});
}
async findAll(): Promise<any[]> {
return this.ideasRepository
.createQueryBuilder('idea')
.leftJoin('idea.organization', 'org')
.leftJoin('idea.user', 'user')
.select([
'idea.id AS id',
'idea.title AS title',
'idea.description AS description',
'idea.status AS status',
'idea.createdAt AS "createdAt"',
'idea.adminNote AS "adminNote"',
'org.id AS "orgId"',
'org.name AS "orgName"',
'user.id AS "userId"',
'user.email AS "userEmail"',
'user.firstName AS "userFirstName"',
'user.lastName AS "userLastName"',
])
.orderBy('idea.createdAt', 'DESC')
.getRawMany();
}
async updateStatus(id: string, status: string): Promise<Idea> {
const validStatuses = ['new', 'reviewed', 'accepted', 'rejected'];
if (!validStatuses.includes(status)) {
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
}
const idea = await this.ideasRepository.findOne({ where: { id } });
if (!idea) {
throw new NotFoundException('Idea not found');
}
idea.status = status;
return this.ideasRepository.save(idea);
}
async updateNote(id: string, adminNote: string): Promise<Idea> {
const idea = await this.ideasRepository.findOne({ where: { id } });
if (!idea) {
throw new NotFoundException('Idea not found');
}
idea.adminNote = adminNote;
return this.ideasRepository.save(idea);
}
}

View File

@@ -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);
}

View File

@@ -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); }
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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}`);
}
}
}
}
}
}

View File

@@ -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); }
}

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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); }
}

View File

@@ -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); }
}

View File

@@ -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); }
}

View File

@@ -58,7 +58,7 @@ CREATE TABLE shared.user_organizations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL CHECK (role IN ('president', '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)

View File

@@ -0,0 +1,15 @@
-- Ideation feature: shared ideas table for cross-tenant idea submissions
CREATE TABLE IF NOT EXISTS shared.ideas (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
org_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'new',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ideas_org_id ON shared.ideas(org_id);
CREATE INDEX IF NOT EXISTS idx_ideas_status ON shared.ideas(status);
CREATE INDEX IF NOT EXISTS idx_ideas_created_at ON shared.ideas(created_at DESC);

View File

@@ -0,0 +1,2 @@
-- Add private admin note column to ideas table
ALTER TABLE shared.ideas ADD COLUMN IF NOT EXISTS admin_note TEXT;

View 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
View 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 |

View File

@@ -29,6 +29,7 @@ import { SettingsPage } from './pages/settings/SettingsPage';
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';
@@ -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';
@@ -134,6 +136,7 @@ export function App() {
}
>
<Route index element={<AdminPage />} />
<Route path="ideas" element={<AdminIdeasPage />} />
<Route path="shadow-ai" element={<AdminShadowAiPage />} />
</Route>
@@ -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>
);

View File

@@ -0,0 +1,69 @@
import { useState } from 'react';
import { Modal, TextInput, Textarea, Button, Stack } from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { useMutation } from '@tanstack/react-query';
import api from '../../services/api';
interface IdeaModalProps {
opened: boolean;
onClose: () => void;
}
export function IdeaModal({ opened, onClose }: IdeaModalProps) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const submitIdea = useMutation({
mutationFn: async () => {
const { data } = await api.post('/ideas', { title, description });
return data;
},
onSuccess: () => {
notifications.show({ message: 'Idea submitted — thank you!', color: 'green' });
setTitle('');
setDescription('');
onClose();
},
onError: (err: any) => {
notifications.show({
message: err.response?.data?.message || 'Failed to submit idea',
color: 'red',
});
},
});
const handleClose = () => {
setTitle('');
setDescription('');
onClose();
};
return (
<Modal opened={opened} onClose={handleClose} title="Submit an Idea" size="md">
<Stack>
<TextInput
label="Title"
placeholder="Brief summary of your idea"
required
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
maxLength={255}
/>
<Textarea
label="Description"
placeholder="Describe your idea in more detail (optional)"
minRows={4}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
<Button
onClick={() => submitIdea.mutate()}
loading={submitIdea.isPending}
disabled={!title.trim()}
>
Submit Idea
</Button>
</Stack>
</Modal>
);
}

View File

@@ -11,6 +11,7 @@ import {
IconEyeOff,
IconSun,
IconMoon,
IconBulb,
} from '@tabler/icons-react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
@@ -18,6 +19,7 @@ import { usePreferencesStore } from '../../stores/preferencesStore';
import { Sidebar } from './Sidebar';
import { AppTour } from '../onboarding/AppTour';
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
import { IdeaModal } from '../ideas/IdeaModal';
import logoSrc from '../../assets/logo.png';
export function AppLayout() {
@@ -28,6 +30,10 @@ export function AppLayout() {
const location = useLocation();
const isImpersonating = !!impersonationOriginal;
// ── Ideation State ──
const [ideaModalOpened, { open: openIdeaModal, close: closeIdeaModal }] = useDisclosure(false);
const ideationEnabled = currentOrg?.settings?.ideationEnabled === true;
// ── Onboarding State ──
const [showTour, setShowTour] = useState(false);
const [showWizard, setShowWizard] = useState(false);
@@ -71,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
@@ -121,6 +128,13 @@ export function AppLayout() {
{currentOrg && (
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
)}
{ideationEnabled && (
<Tooltip label="Submit an idea">
<ActionIcon variant="default" size="lg" onClick={openIdeaModal} aria-label="Submit idea">
<IconBulb size={18} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
<ActionIcon
variant="default"
@@ -209,6 +223,9 @@ export function AppLayout() {
{/* ── Onboarding Components ── */}
<AppTour run={showTour} onComplete={handleTourComplete} />
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
{/* ── Ideation Modal ── */}
<IdeaModal opened={ideaModalOpened} onClose={closeIdeaModal} />
</AppShell>
);
}

View File

@@ -20,59 +20,63 @@ import {
IconCalculator,
IconGitCompare,
IconScale,
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' },
@@ -85,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' },
@@ -113,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?.();
@@ -132,6 +146,13 @@ export function Sidebar({ onNavigate }: SidebarProps) {
onClick={() => go('/admin')}
color="red"
/>
<NavLink
label="Idea Submissions"
leftSection={<IconBulb size={18} />}
active={location.pathname === '/admin/ideas'}
onClick={() => go('/admin/ideas')}
color="yellow"
/>
<NavLink
label="AI Benchmarking"
leftSection={<IconScale size={18} />}
@@ -156,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 && (
<>
@@ -166,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
@@ -222,7 +246,8 @@ export function Sidebar({ onNavigate }: SidebarProps) {
),
)}
</div>
))}
);
})}
{user?.isSuperadmin && (
<>
@@ -237,6 +262,20 @@ export function Sidebar({ onNavigate }: SidebarProps) {
onClick={() => go('/admin')}
color="red"
/>
<NavLink
label="Idea Submissions"
leftSection={<IconBulb size={18} />}
active={location.pathname === '/admin/ideas'}
onClick={() => go('/admin/ideas')}
color="yellow"
/>
<NavLink
label="AI Benchmarking"
leftSection={<IconScale size={18} />}
active={location.pathname === '/admin/shadow-ai'}
onClick={() => go('/admin/shadow-ai')}
color="violet"
/>
</>
)}
</ScrollArea>

View File

@@ -41,7 +41,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { 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[]>({

View File

@@ -0,0 +1,308 @@
import { useState } from 'react';
import {
Title, Text, Card, Table, Group, Stack, Badge, Loader, Center,
Select, TextInput, Textarea, Button, Modal, SimpleGrid, ActionIcon,
Tooltip, Paper,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconBulb, IconSearch, IconNote, IconFilter,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface AdminIdea {
id: string;
title: string;
description: string | null;
status: string;
createdAt: string;
adminNote: string | null;
orgId: string;
orgName: string;
userId: string;
userEmail: string;
userFirstName: string;
userLastName: string;
}
const statusColor: Record<string, string> = {
new: 'blue',
reviewed: 'yellow',
accepted: 'green',
rejected: 'red',
};
const statusOptions = [
{ value: 'new', label: 'New' },
{ value: 'reviewed', label: 'Reviewed' },
{ value: 'accepted', label: 'Accepted' },
{ value: 'rejected', label: 'Rejected' },
];
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString();
}
function formatDateTime(dateStr: string | null | undefined): string {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleString();
}
export function AdminIdeasPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [selectedIdea, setSelectedIdea] = useState<AdminIdea | null>(null);
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
const [noteText, setNoteText] = useState('');
const queryClient = useQueryClient();
const { data: ideas, isLoading } = useQuery<AdminIdea[]>({
queryKey: ['admin-ideas'],
queryFn: async () => { const { data } = await api.get('/admin/ideas'); return data; },
});
const updateStatus = useMutation({
mutationFn: async ({ id, status }: { id: string; status: string }) => {
await api.put(`/admin/ideas/${id}/status`, { status });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
notifications.show({ message: 'Status updated', color: 'green' });
},
});
const updateNote = useMutation({
mutationFn: async ({ id, adminNote }: { id: string; adminNote: string }) => {
await api.put(`/admin/ideas/${id}/note`, { adminNote });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
notifications.show({ message: 'Note saved', color: 'green' });
},
});
const openIdeaDetail = (idea: AdminIdea) => {
setSelectedIdea(idea);
setNoteText(idea.adminNote || '');
openDetail();
};
const handleSaveNote = () => {
if (selectedIdea) {
updateNote.mutate({ id: selectedIdea.id, adminNote: noteText });
}
};
const filtered = (ideas || []).filter((idea) => {
const matchesSearch = !search ||
idea.title.toLowerCase().includes(search.toLowerCase()) ||
idea.description?.toLowerCase().includes(search.toLowerCase()) ||
idea.orgName.toLowerCase().includes(search.toLowerCase()) ||
idea.userEmail.toLowerCase().includes(search.toLowerCase());
const matchesStatus = !statusFilter || idea.status === statusFilter;
return matchesSearch && matchesStatus;
});
const counts = {
total: ideas?.length || 0,
new: ideas?.filter(i => i.status === 'new').length || 0,
reviewed: ideas?.filter(i => i.status === 'reviewed').length || 0,
accepted: ideas?.filter(i => i.status === 'accepted').length || 0,
rejected: ideas?.filter(i => i.status === 'rejected').length || 0,
};
if (isLoading) {
return <Center h={400}><Loader /></Center>;
}
return (
<Stack>
<Group justify="space-between">
<Group>
<IconBulb size={28} />
<Title order={2}>Idea Submissions</Title>
</Group>
<Badge size="lg" variant="light">{counts.total} total</Badge>
</Group>
{/* Summary cards */}
<SimpleGrid cols={{ base: 2, sm: 4 }}>
<Paper withBorder p="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>New</Text>
<Text size="xl" fw={700} c="blue">{counts.new}</Text>
</Paper>
<Paper withBorder p="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reviewed</Text>
<Text size="xl" fw={700} c="yellow">{counts.reviewed}</Text>
</Paper>
<Paper withBorder p="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Accepted</Text>
<Text size="xl" fw={700} c="green">{counts.accepted}</Text>
</Paper>
<Paper withBorder p="md" radius="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Rejected</Text>
<Text size="xl" fw={700} c="red">{counts.rejected}</Text>
</Paper>
</SimpleGrid>
{/* Filters */}
<Group>
<TextInput
placeholder="Search ideas, tenants, users..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="All statuses"
leftSection={<IconFilter size={16} />}
data={statusOptions}
value={statusFilter}
onChange={setStatusFilter}
clearable
w={160}
/>
</Group>
{/* Ideas table */}
<Card withBorder p={0}>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Date</Table.Th>
<Table.Th>Tenant</Table.Th>
<Table.Th>Submitted By</Table.Th>
<Table.Th>Title</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th w={40}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={6}>
<Text ta="center" c="dimmed" py="lg">
{ideas?.length === 0 ? 'No ideas submitted yet' : 'No ideas match your filters'}
</Text>
</Table.Td>
</Table.Tr>
) : (
filtered.map((idea) => (
<Table.Tr
key={idea.id}
style={{ cursor: 'pointer' }}
onClick={() => openIdeaDetail(idea)}
>
<Table.Td>
<Text size="xs">{formatDate(idea.createdAt)}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500}>{idea.orgName}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{idea.userFirstName} {idea.userLastName}</Text>
<Text size="xs" c="dimmed">{idea.userEmail}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={500} lineClamp={1}>{idea.title}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color={statusColor[idea.status]}>
{idea.status}
</Badge>
</Table.Td>
<Table.Td>
{idea.adminNote && (
<Tooltip label="Has admin note">
<IconNote size={16} color="gray" />
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Card>
{/* Detail Modal */}
<Modal
opened={detailOpened}
onClose={closeDetail}
title={<Text fw={600}>Idea Detail</Text>}
size="lg"
>
{selectedIdea && (
<Stack>
<Card withBorder>
<SimpleGrid cols={2} spacing="xs">
<Text size="xs" c="dimmed">Tenant</Text>
<Text size="sm" fw={500}>{selectedIdea.orgName}</Text>
<Text size="xs" c="dimmed">Submitted By</Text>
<Text size="sm">{selectedIdea.userFirstName} {selectedIdea.userLastName} ({selectedIdea.userEmail})</Text>
<Text size="xs" c="dimmed">Date</Text>
<Text size="sm">{formatDateTime(selectedIdea.createdAt)}</Text>
</SimpleGrid>
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Title</Text>
<Text size="sm">{selectedIdea.title}</Text>
{selectedIdea.description && (
<>
<Text fw={600} mt="md" mb="xs">Description</Text>
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{selectedIdea.description}</Text>
</>
)}
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Status</Text>
<Select
data={statusOptions}
value={selectedIdea.status}
onChange={(val) => {
if (val && val !== selectedIdea.status) {
updateStatus.mutate({ id: selectedIdea.id, status: val }, {
onSuccess: () => {
setSelectedIdea({ ...selectedIdea, status: val });
},
});
}
}}
w={200}
/>
</Card>
<Card withBorder>
<Group justify="space-between" mb="xs">
<Text fw={600}>Private Admin Note</Text>
<Text size="xs" c="dimmed">Only visible to super admins</Text>
</Group>
<Textarea
placeholder="Add internal notes — sprint reference, thoughts, follow-up actions..."
minRows={3}
value={noteText}
onChange={(e) => setNoteText(e.currentTarget.value)}
/>
<Button
size="xs"
variant="light"
mt="xs"
onClick={handleSaveNote}
loading={updateNote.isPending}
disabled={noteText === (selectedIdea.adminNote || '')}
>
Save Note
</Button>
</Card>
</Stack>
)}
</Modal>
</Stack>
);
}

View File

@@ -11,7 +11,7 @@ import {
IconCrown, IconPlus, IconArchive, IconChevronDown,
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye,
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye, IconBulb,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
@@ -211,6 +211,16 @@ export function AdminPage() {
},
});
const toggleIdeation = useMutation({
mutationFn: async ({ orgId, enabled }: { orgId: string; enabled: boolean }) => {
await api.put(`/admin/organizations/${orgId}/settings`, { ideationEnabled: enabled });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tenant-detail', selectedOrgId] });
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
},
});
const impersonateUser = useMutation({
mutationFn: async (userId: string) => {
const { data } = await api.post(`/admin/impersonate/${userId}`);
@@ -782,6 +792,27 @@ export function AdminPage() {
</SimpleGrid>
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Feature Toggles</Text>
<Group justify="space-between">
<Group gap="xs">
<IconBulb size={16} />
<div>
<Text size="sm">Ideation</Text>
<Text size="xs" c="dimmed">Allow users to submit feature ideas</Text>
</div>
</Group>
<Switch
checked={tenantDetail.organization.settings?.ideationEnabled === true}
onChange={(e) => {
if (selectedOrgId) {
toggleIdeation.mutate({ orgId: selectedOrgId, enabled: e.currentTarget.checked });
}
}}
/>
</Group>
</Card>
<Card withBorder>
<Text fw={600} mb="xs">Subscription</Text>
<Stack gap="xs">

View File

@@ -194,7 +194,7 @@ function ModelSlotCard({ slot, model, isLoading }: { slot: string; model?: Shado
</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" placeholder="https://api.openai.com/v1" value={apiUrl} onChange={(e) => setApiUrl(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)} />

View File

@@ -12,7 +12,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { 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'],

View File

@@ -11,7 +11,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { 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';

View File

@@ -8,7 +8,7 @@ import { IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, IconArrowRight } f
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { 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';

View File

@@ -14,7 +14,7 @@ import {
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { 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 ----

View File

@@ -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();

View File

@@ -43,7 +43,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { notifications } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { 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[]>({

View File

@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { 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'],

View File

@@ -9,7 +9,7 @@ import { notifications } from '@mantine/notifications';
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { 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'],

View File

@@ -10,7 +10,7 @@ import {
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { 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';

View File

@@ -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 }}>

View File

@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { 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'],

View File

@@ -12,7 +12,7 @@ import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen, I
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { parseCSV, downloadBlob } from '../../utils/csv';
import { 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 ----

View File

@@ -11,7 +11,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { 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'],

View 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>
);
}

View File

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

View File

@@ -12,7 +12,7 @@ import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { 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'],

View File

@@ -10,7 +10,7 @@ import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload,
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { parseCSV, downloadBlob } from '../../utils/csv';
import { 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'],

View File

@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload, IconUsers, IconBulb, IconRocket } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { 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'],

View 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}</>;
}

View 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' },
],
},
];

View 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,
],
};

View 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';

View 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;
}

View 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);
}

View File

@@ -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,

View File

@@ -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 }
}
}

View File

@@ -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;

View File

@@ -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"
}
}

View File

@@ -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
1 email password orgId role
2 loadtest-treasurer-01@hoaledgeriq.test LoadTest!Pass01 org-uuid-placeholder-1 treasurer
3 loadtest-treasurer-02@hoaledgeriq.test LoadTest!Pass02 org-uuid-placeholder-1 treasurer
4 loadtest-admin-01@hoaledgeriq.test LoadTest!Pass03 org-uuid-placeholder-1 admin
5 loadtest-admin-02@hoaledgeriq.test LoadTest!Pass04 org-uuid-placeholder-2 admin
6 loadtest-president-01@hoaledgeriq.test LoadTest!Pass05 org-uuid-placeholder-2 president
7 loadtest-manager-01@hoaledgeriq.test LoadTest!Pass06 org-uuid-placeholder-2 manager
8 loadtest-member-01@hoaledgeriq.test LoadTest!Pass07 org-uuid-placeholder-1 member_at_large
9 loadtest-viewer-01@hoaledgeriq.test LoadTest!Pass08 org-uuid-placeholder-1 viewer
10 loadtest-homeowner-01@hoaledgeriq.test LoadTest!Pass09 org-uuid-placeholder-2 homeowner
11 loadtest-homeowner-02@hoaledgeriq.test LoadTest!Pass10 org-uuid-placeholder-2 homeowner

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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

434
scripts/deploy-prod.sh Executable file
View File

@@ -0,0 +1,434 @@
#!/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=36
HEALTH_INTERVAL=5
HEALTH_START_WAIT=10
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 ---"
# After a fresh image build, NestJS cold-start can take 2-3 minutes:
# New Relic init → TypeORM connections → Redis → BullMQ → NestJS bootstrap
# Docker's own healthcheck (start_period:30s + 3×15s retries = ~75s) is too
# aggressive and will mark the container "unhealthy" before the app finishes
# booting. So we do NOT rely on Docker's health status — we probe the HTTP
# endpoint directly from the host and give it up to ~3 minutes total.
TOTAL_WAIT=$((HEALTH_START_WAIT + HEALTH_RETRIES * HEALTH_INTERVAL))
log "Will wait up to ${TOTAL_WAIT}s for backend to respond at $HEALTH_URL ..."
sleep "$HEALTH_START_WAIT"
HEALTHY=false
for ((i=1; i<=HEALTH_RETRIES; i++)); do
# Direct HTTP check from the host using wget (available on Ubuntu)
if wget -qO- --timeout=5 "$HEALTH_URL" >/dev/null 2>&1; then
HEALTHY=true
break
fi
# Also check Docker's container health for informational logging
CONTAINER_HEALTH=$($COMPOSE_CMD ps backend --format '{{.Health}}' 2>/dev/null || echo "unknown")
# If the container exited or was removed, fail immediately — no point waiting
CONTAINER_STATUS=$($COMPOSE_CMD ps backend --format '{{.Status}}' 2>/dev/null || echo "unknown")
if echo "$CONTAINER_STATUS" | grep -qi "exit\|dead\|removed"; then
err "Backend container has stopped unexpectedly: $CONTAINER_STATUS"
break
fi
log " Health check attempt $i/$HEALTH_RETRIES — docker: ${CONTAINER_HEALTH}, retrying in ${HEALTH_INTERVAL}s ..."
sleep "$HEALTH_INTERVAL"
done
if [ "$HEALTHY" = true ]; then
ok "Backend is healthy and responding at $HEALTH_URL"
else
# Log diagnostics before triggering rollback
err "Backend failed to respond after ${TOTAL_WAIT}s"
warn "Container status: $($COMPOSE_CMD ps backend 2>/dev/null || echo 'unknown')"
warn "Recent backend logs:"
$COMPOSE_CMD logs --tail=30 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 ""