Compare commits
1 Commits
feature-de
...
5ae6f672be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ae6f672be |
@@ -1,65 +0,0 @@
|
|||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Production Deployment Workflow for HOA LedgerIQ
|
|
||||||
#
|
|
||||||
# Trigger: Manual only (workflow_dispatch) — production deploys are intentional.
|
|
||||||
# Runner: Self-hosted on the production server at /opt/hoa-ledgeriq.
|
|
||||||
#
|
|
||||||
# This workflow does NOT use actions/checkout. The runner operates directly
|
|
||||||
# on the production directory. The deploy script itself handles git pull.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
name: Deploy to Production
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
seed_existing:
|
|
||||||
description: "Mark existing migrations as applied without running them (first deployment only)"
|
|
||||||
required: false
|
|
||||||
default: "false"
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
name: Deploy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: /opt/hoa-ledgeriq
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Pre-deploy info
|
|
||||||
run: |
|
|
||||||
echo "## Pre-Deploy Info" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- **Server:** $(hostname)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- **Directory:** $(pwd)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- **Current commit:** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- **Branch:** $(git branch --show-current || echo 'detached')" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- **Triggered by:** ${{ github.actor }}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- **Seed existing:** ${{ inputs.seed_existing }}" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- **Started at:** $(date -Iseconds)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
|
|
||||||
- name: Run deployment
|
|
||||||
run: |
|
|
||||||
DEPLOY_FLAGS=""
|
|
||||||
if [ "${{ inputs.seed_existing }}" = "true" ]; then
|
|
||||||
DEPLOY_FLAGS="--seed-existing"
|
|
||||||
fi
|
|
||||||
bash scripts/deploy-prod.sh $DEPLOY_FLAGS
|
|
||||||
env:
|
|
||||||
TERM: xterm
|
|
||||||
|
|
||||||
- name: Deployment result
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "## Deployment Result" >> $GITHUB_STEP_SUMMARY
|
|
||||||
if [ "${{ job.status }}" = "success" ]; then
|
|
||||||
echo "- **Status:** Successful" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- **Commit:** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
else
|
|
||||||
echo "- **Status:** FAILED (auto-rollback triggered)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- **Commit (after rollback):** $(git rev-parse --short HEAD)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
echo "- Check the deploy log on the server for details" >> $GITHUB_STEP_SUMMARY
|
|
||||||
fi
|
|
||||||
echo "- **Completed at:** $(date -Iseconds)" >> $GITHUB_STEP_SUMMARY
|
|
||||||
229
CLAUDE.md
229
CLAUDE.md
@@ -1,229 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -7,7 +7,6 @@ import { AppController } from './app.controller';
|
|||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { TenantMiddleware } from './database/tenant.middleware';
|
import { TenantMiddleware } from './database/tenant.middleware';
|
||||||
import { WriteAccessGuard } from './common/guards/write-access.guard';
|
import { WriteAccessGuard } from './common/guards/write-access.guard';
|
||||||
import { CapabilityGuard } from './common/guards/capability.guard';
|
|
||||||
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
|
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
import { OrganizationsModule } from './modules/organizations/organizations.module';
|
||||||
@@ -34,7 +33,6 @@ import { BoardPlanningModule } from './modules/board-planning/board-planning.mod
|
|||||||
import { BillingModule } from './modules/billing/billing.module';
|
import { BillingModule } from './modules/billing/billing.module';
|
||||||
import { EmailModule } from './modules/email/email.module';
|
import { EmailModule } from './modules/email/email.module';
|
||||||
import { OnboardingModule } from './modules/onboarding/onboarding.module';
|
import { OnboardingModule } from './modules/onboarding/onboarding.module';
|
||||||
import { IdeasModule } from './modules/ideas/ideas.module';
|
|
||||||
import { ShadowAiModule } from './modules/shadow-ai/shadow-ai.module';
|
import { ShadowAiModule } from './modules/shadow-ai/shadow-ai.module';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
||||||
@@ -91,7 +89,6 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
BillingModule,
|
BillingModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
OnboardingModule,
|
OnboardingModule,
|
||||||
IdeasModule,
|
|
||||||
ShadowAiModule,
|
ShadowAiModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
@@ -101,10 +98,6 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: WriteAccessGuard,
|
useClass: WriteAccessGuard,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: APP_GUARD,
|
|
||||||
useClass: CapabilityGuard,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
provide: APP_INTERCEPTOR,
|
provide: APP_INTERCEPTOR,
|
||||||
useClass: NoCacheInterceptor,
|
useClass: NoCacheInterceptor,
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import { SetMetadata } from '@nestjs/common';
|
|
||||||
|
|
||||||
export const CAPABILITIES_KEY = 'required_capabilities';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decorator to require specific capabilities on an endpoint.
|
|
||||||
* User must have ALL listed capabilities to access the endpoint.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* @RequireCapability('financials.accounts.edit')
|
|
||||||
* @RequireCapability('financials.accounts.view', 'financials.accounts.edit')
|
|
||||||
*/
|
|
||||||
export const RequireCapability = (...capabilities: string[]) =>
|
|
||||||
SetMetadata(CAPABILITIES_KEY, capabilities);
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { DataSource } from 'typeorm';
|
|
||||||
import { CAPABILITIES_KEY } from '../decorators/capability.decorator';
|
|
||||||
import { resolveCapabilities } from '../permissions';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class CapabilityGuard implements CanActivate {
|
|
||||||
// Cache org settings (including permissionOverrides) per orgId
|
|
||||||
private settingsCache = new Map<string, { settings: Record<string, any>; cachedAt: number }>();
|
|
||||||
private static readonly CACHE_TTL = 60_000; // 60 seconds
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private reflector: Reflector,
|
|
||||||
private dataSource: DataSource,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const requiredCapabilities = this.reflector.getAllAndOverride<string[]>(CAPABILITIES_KEY, [
|
|
||||||
context.getHandler(),
|
|
||||||
context.getClass(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// No capabilities required — pass through (backward compatible)
|
|
||||||
if (!requiredCapabilities || requiredCapabilities.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = context.switchToHttp().getRequest();
|
|
||||||
const user = request.user;
|
|
||||||
|
|
||||||
// No authenticated user — let other guards handle auth
|
|
||||||
if (!user) return true;
|
|
||||||
|
|
||||||
// Superadmins bypass all capability checks
|
|
||||||
if (user.isSuperadmin) return true;
|
|
||||||
|
|
||||||
const role = user.role;
|
|
||||||
const orgId = user.orgId;
|
|
||||||
|
|
||||||
if (!role || !orgId) return true;
|
|
||||||
|
|
||||||
// Get org settings (with caching)
|
|
||||||
const settings = await this.getOrgSettings(orgId);
|
|
||||||
const userCapabilities = resolveCapabilities(role, settings?.permissionOverrides);
|
|
||||||
|
|
||||||
// User must have ALL required capabilities
|
|
||||||
const hasAll = requiredCapabilities.every((cap) => userCapabilities.has(cap));
|
|
||||||
if (!hasAll) {
|
|
||||||
throw new ForbiddenException(
|
|
||||||
'You do not have the required permissions for this action.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getOrgSettings(orgId: string): Promise<Record<string, any> | null> {
|
|
||||||
const cached = this.settingsCache.get(orgId);
|
|
||||||
if (cached && Date.now() - cached.cachedAt < CapabilityGuard.CACHE_TTL) {
|
|
||||||
return cached.settings;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await this.dataSource.query(
|
|
||||||
`SELECT settings FROM shared.organizations WHERE id = $1`,
|
|
||||||
[orgId],
|
|
||||||
);
|
|
||||||
if (result.length > 0) {
|
|
||||||
const settings = result[0].settings || {};
|
|
||||||
this.settingsCache.set(orgId, { settings, cachedAt: Date.now() });
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Non-critical — fall through to use defaults only
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear cached settings for an org (call after settings update) */
|
|
||||||
clearCache(orgId: string) {
|
|
||||||
this.settingsCache.delete(orgId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* Capability taxonomy for the HOA Financial Platform.
|
|
||||||
*
|
|
||||||
* Pattern: {area}.{feature}.{action}
|
|
||||||
* Actions: view, edit, approve, manage
|
|
||||||
*
|
|
||||||
* Add new capabilities here when new features are built.
|
|
||||||
* The default role matrix in ./default-role-capabilities.ts must also be updated.
|
|
||||||
*/
|
|
||||||
export const CAPABILITIES = {
|
|
||||||
// Dashboard
|
|
||||||
DASHBOARD_VIEW: 'dashboard.view',
|
|
||||||
|
|
||||||
// Financials
|
|
||||||
FINANCIALS_ACCOUNTS_VIEW: 'financials.accounts.view',
|
|
||||||
FINANCIALS_ACCOUNTS_EDIT: 'financials.accounts.edit',
|
|
||||||
FINANCIALS_CASHFLOW_VIEW: 'financials.cashflow.view',
|
|
||||||
FINANCIALS_CASHFLOW_EDIT: 'financials.cashflow.edit',
|
|
||||||
FINANCIALS_ACTUALS_VIEW: 'financials.actuals.view',
|
|
||||||
FINANCIALS_ACTUALS_EDIT: 'financials.actuals.edit',
|
|
||||||
FINANCIALS_BUDGETS_VIEW: 'financials.budgets.view',
|
|
||||||
FINANCIALS_BUDGETS_EDIT: 'financials.budgets.edit',
|
|
||||||
FINANCIALS_BUDGETS_APPROVE: 'financials.budgets.approve',
|
|
||||||
|
|
||||||
// Assessments
|
|
||||||
ASSESSMENTS_UNITS_VIEW: 'assessments.units.view',
|
|
||||||
ASSESSMENTS_UNITS_EDIT: 'assessments.units.edit',
|
|
||||||
ASSESSMENTS_GROUPS_VIEW: 'assessments.groups.view',
|
|
||||||
ASSESSMENTS_GROUPS_EDIT: 'assessments.groups.edit',
|
|
||||||
|
|
||||||
// Board Planning
|
|
||||||
PLANNING_BUDGETS_VIEW: 'planning.budgets.view',
|
|
||||||
PLANNING_BUDGETS_EDIT: 'planning.budgets.edit',
|
|
||||||
PLANNING_PROJECTS_VIEW: 'planning.projects.view',
|
|
||||||
PLANNING_PROJECTS_EDIT: 'planning.projects.edit',
|
|
||||||
PLANNING_SCENARIOS_VIEW: 'planning.scenarios.view',
|
|
||||||
PLANNING_SCENARIOS_EDIT: 'planning.scenarios.edit',
|
|
||||||
PLANNING_SCENARIOS_APPROVE: 'planning.scenarios.approve',
|
|
||||||
PLANNING_INVESTMENTS_VIEW: 'planning.investments.view',
|
|
||||||
PLANNING_INVESTMENTS_EDIT: 'planning.investments.edit',
|
|
||||||
|
|
||||||
// Board Reference
|
|
||||||
REFERENCE_VENDORS_VIEW: 'reference.vendors.view',
|
|
||||||
REFERENCE_VENDORS_EDIT: 'reference.vendors.edit',
|
|
||||||
|
|
||||||
// Transactions
|
|
||||||
TRANSACTIONS_VIEW: 'transactions.view',
|
|
||||||
TRANSACTIONS_EDIT: 'transactions.edit',
|
|
||||||
TRANSACTIONS_APPROVE: 'transactions.approve',
|
|
||||||
|
|
||||||
// Reports
|
|
||||||
REPORTS_VIEW: 'reports.view',
|
|
||||||
|
|
||||||
// Settings & Administration
|
|
||||||
SETTINGS_ORG_VIEW: 'settings.org.view',
|
|
||||||
SETTINGS_ORG_EDIT: 'settings.org.edit',
|
|
||||||
SETTINGS_MEMBERS_VIEW: 'settings.members.view',
|
|
||||||
SETTINGS_MEMBERS_MANAGE: 'settings.members.manage',
|
|
||||||
SETTINGS_PERMISSIONS_MANAGE: 'settings.permissions.manage',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
|
|
||||||
|
|
||||||
/** Set of all valid capability strings, for validation */
|
|
||||||
export const ALL_CAPABILITIES = new Set<string>(Object.values(CAPABILITIES));
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { CAPABILITIES, Capability } from './capabilities';
|
|
||||||
|
|
||||||
const C = CAPABILITIES;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default capability sets per role.
|
|
||||||
*
|
|
||||||
* These represent sensible defaults for a typical HOA. Tenant admins can
|
|
||||||
* customize per-role capabilities via permission overrides in org settings.
|
|
||||||
*
|
|
||||||
* Roles not listed here (e.g. unknown future roles) get zero capabilities.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_ROLE_CAPABILITIES: Record<string, readonly Capability[]> = {
|
|
||||||
president: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
|
||||||
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
|
||||||
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
|
||||||
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
|
|
||||||
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
|
||||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
|
||||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
|
|
||||||
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
|
|
||||||
C.SETTINGS_PERMISSIONS_MANAGE,
|
|
||||||
],
|
|
||||||
|
|
||||||
admin: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
|
||||||
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
|
||||||
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
|
||||||
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
|
|
||||||
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
|
||||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
|
||||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
|
|
||||||
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
|
|
||||||
C.SETTINGS_PERMISSIONS_MANAGE,
|
|
||||||
],
|
|
||||||
|
|
||||||
vice_president: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW,
|
|
||||||
C.PLANNING_BUDGETS_VIEW,
|
|
||||||
C.PLANNING_PROJECTS_VIEW,
|
|
||||||
C.PLANNING_SCENARIOS_VIEW,
|
|
||||||
C.PLANNING_INVESTMENTS_VIEW,
|
|
||||||
C.REFERENCE_VENDORS_VIEW,
|
|
||||||
C.TRANSACTIONS_VIEW,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
C.SETTINGS_ORG_VIEW,
|
|
||||||
C.SETTINGS_MEMBERS_VIEW,
|
|
||||||
],
|
|
||||||
|
|
||||||
treasurer: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
|
||||||
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
|
||||||
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
|
||||||
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT,
|
|
||||||
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
|
||||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
|
||||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
C.SETTINGS_MEMBERS_VIEW,
|
|
||||||
],
|
|
||||||
|
|
||||||
secretary: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW,
|
|
||||||
C.PLANNING_BUDGETS_VIEW,
|
|
||||||
C.PLANNING_PROJECTS_VIEW,
|
|
||||||
C.PLANNING_SCENARIOS_VIEW,
|
|
||||||
C.PLANNING_INVESTMENTS_VIEW,
|
|
||||||
C.REFERENCE_VENDORS_VIEW,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
],
|
|
||||||
|
|
||||||
member_at_large: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW,
|
|
||||||
C.PLANNING_BUDGETS_VIEW,
|
|
||||||
C.PLANNING_PROJECTS_VIEW,
|
|
||||||
C.PLANNING_SCENARIOS_VIEW,
|
|
||||||
C.PLANNING_INVESTMENTS_VIEW,
|
|
||||||
C.REFERENCE_VENDORS_VIEW,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
],
|
|
||||||
|
|
||||||
manager: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW,
|
|
||||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
|
||||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
],
|
|
||||||
|
|
||||||
homeowner: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
],
|
|
||||||
|
|
||||||
viewer: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW,
|
|
||||||
C.PLANNING_BUDGETS_VIEW,
|
|
||||||
C.PLANNING_PROJECTS_VIEW,
|
|
||||||
C.PLANNING_SCENARIOS_VIEW,
|
|
||||||
C.PLANNING_INVESTMENTS_VIEW,
|
|
||||||
C.REFERENCE_VENDORS_VIEW,
|
|
||||||
C.TRANSACTIONS_VIEW,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export { CAPABILITIES, ALL_CAPABILITIES } from './capabilities';
|
|
||||||
export type { Capability } from './capabilities';
|
|
||||||
export { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
|
||||||
export { resolveCapabilities, resolveCapabilitiesArray } from './resolve-permissions';
|
|
||||||
export type { PermissionOverrides } from './resolve-permissions';
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { ALL_CAPABILITIES } from './capabilities';
|
|
||||||
import { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
|
||||||
|
|
||||||
export interface PermissionOverrides {
|
|
||||||
[role: string]: {
|
|
||||||
grant?: string[];
|
|
||||||
revoke?: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve effective capabilities for a role, applying tenant overrides.
|
|
||||||
*
|
|
||||||
* 1. Start with default capabilities for the role
|
|
||||||
* 2. Add any granted capabilities from overrides
|
|
||||||
* 3. Remove any revoked capabilities from overrides
|
|
||||||
*
|
|
||||||
* Unknown capabilities in grant/revoke are silently ignored (they may
|
|
||||||
* come from an older version of the overrides).
|
|
||||||
*/
|
|
||||||
export function resolveCapabilities(
|
|
||||||
role: string,
|
|
||||||
overrides?: PermissionOverrides | null,
|
|
||||||
): Set<string> {
|
|
||||||
const defaults = DEFAULT_ROLE_CAPABILITIES[role] || [];
|
|
||||||
const result = new Set<string>(defaults);
|
|
||||||
|
|
||||||
if (overrides && overrides[role]) {
|
|
||||||
const roleOverride = overrides[role];
|
|
||||||
|
|
||||||
if (roleOverride.grant) {
|
|
||||||
for (const cap of roleOverride.grant) {
|
|
||||||
if (ALL_CAPABILITIES.has(cap)) {
|
|
||||||
result.add(cap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roleOverride.revoke) {
|
|
||||||
for (const cap of roleOverride.revoke) {
|
|
||||||
result.delete(cap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convenience: resolve to a sorted array (for API responses).
|
|
||||||
*/
|
|
||||||
export function resolveCapabilitiesArray(
|
|
||||||
role: string,
|
|
||||||
overrides?: PermissionOverrides | null,
|
|
||||||
): string[] {
|
|
||||||
return Array.from(resolveCapabilities(role, overrides)).sort();
|
|
||||||
}
|
|
||||||
@@ -37,12 +37,7 @@ export async function callOpenAICompatible(params: AICallerParams): Promise<AICa
|
|||||||
const https = await import('https');
|
const https = await import('https');
|
||||||
|
|
||||||
const aiResult = await new Promise<{ status: number; body: string }>((resolve, reject) => {
|
const aiResult = await new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||||
// Normalize: strip trailing slash and /chat/completions if user included it
|
const url = new URL(`${apiUrl}/chat/completions`);
|
||||||
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 = {
|
const options = {
|
||||||
hostname: url.hostname,
|
hostname: url.hostname,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { AccountsService } from './accounts.service';
|
import { AccountsService } from './accounts.service';
|
||||||
import { CreateAccountDto } from './dto/create-account.dto';
|
import { CreateAccountDto } from './dto/create-account.dto';
|
||||||
import { UpdateAccountDto } from './dto/update-account.dto';
|
import { UpdateAccountDto } from './dto/update-account.dto';
|
||||||
@@ -17,28 +16,24 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List all accounts' })
|
@ApiOperation({ summary: 'List all accounts' })
|
||||||
@RequireCapability('financials.accounts.view')
|
|
||||||
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
|
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
|
||||||
return this.accountsService.findAll(fundType, includeArchived === 'true');
|
return this.accountsService.findAll(fundType, includeArchived === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('trial-balance')
|
@Get('trial-balance')
|
||||||
@ApiOperation({ summary: 'Get trial balance' })
|
@ApiOperation({ summary: 'Get trial balance' })
|
||||||
@RequireCapability('financials.accounts.view')
|
|
||||||
getTrialBalance(@Query('asOfDate') asOfDate?: string) {
|
getTrialBalance(@Query('asOfDate') asOfDate?: string) {
|
||||||
return this.accountsService.getTrialBalance(asOfDate);
|
return this.accountsService.getTrialBalance(asOfDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/set-primary')
|
@Put(':id/set-primary')
|
||||||
@ApiOperation({ summary: 'Set account as primary for its fund type' })
|
@ApiOperation({ summary: 'Set account as primary for its fund type' })
|
||||||
@RequireCapability('financials.accounts.edit')
|
|
||||||
setPrimary(@Param('id') id: string) {
|
setPrimary(@Param('id') id: string) {
|
||||||
return this.accountsService.setPrimary(id);
|
return this.accountsService.setPrimary(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('bulk-opening-balances')
|
@Post('bulk-opening-balances')
|
||||||
@ApiOperation({ summary: 'Set opening balances for multiple accounts' })
|
@ApiOperation({ summary: 'Set opening balances for multiple accounts' })
|
||||||
@RequireCapability('financials.accounts.edit')
|
|
||||||
bulkSetOpeningBalances(
|
bulkSetOpeningBalances(
|
||||||
@Body() dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] },
|
@Body() dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] },
|
||||||
) {
|
) {
|
||||||
@@ -47,7 +42,6 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Post(':id/opening-balance')
|
@Post(':id/opening-balance')
|
||||||
@ApiOperation({ summary: 'Set opening balance for an account at a specific date' })
|
@ApiOperation({ summary: 'Set opening balance for an account at a specific date' })
|
||||||
@RequireCapability('financials.accounts.edit')
|
|
||||||
setOpeningBalance(
|
setOpeningBalance(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
||||||
@@ -57,7 +51,6 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Post(':id/adjust-balance')
|
@Post(':id/adjust-balance')
|
||||||
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
|
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
|
||||||
@RequireCapability('financials.accounts.edit')
|
|
||||||
adjustBalance(
|
adjustBalance(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
|
||||||
@@ -67,7 +60,6 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Post('transfer')
|
@Post('transfer')
|
||||||
@ApiOperation({ summary: 'Transfer funds between asset accounts' })
|
@ApiOperation({ summary: 'Transfer funds between asset accounts' })
|
||||||
@RequireCapability('financials.accounts.edit')
|
|
||||||
transferFunds(
|
transferFunds(
|
||||||
@Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string },
|
@Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string },
|
||||||
) {
|
) {
|
||||||
@@ -76,21 +68,18 @@ export class AccountsController {
|
|||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get account by ID' })
|
@ApiOperation({ summary: 'Get account by ID' })
|
||||||
@RequireCapability('financials.accounts.view')
|
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.accountsService.findOne(id);
|
return this.accountsService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a new account' })
|
@ApiOperation({ summary: 'Create a new account' })
|
||||||
@RequireCapability('financials.accounts.edit')
|
|
||||||
create(@Body() dto: CreateAccountDto) {
|
create(@Body() dto: CreateAccountDto) {
|
||||||
return this.accountsService.create(dto);
|
return this.accountsService.create(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@ApiOperation({ summary: 'Update an account' })
|
@ApiOperation({ summary: 'Update an account' })
|
||||||
@RequireCapability('financials.accounts.edit')
|
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateAccountDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateAccountDto) {
|
||||||
return this.accountsService.update(id, dto);
|
return this.accountsService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { AssessmentGroupsService } from './assessment-groups.service';
|
import { AssessmentGroupsService } from './assessment-groups.service';
|
||||||
|
|
||||||
@ApiTags('assessment-groups')
|
@ApiTags('assessment-groups')
|
||||||
@@ -12,30 +11,23 @@ export class AssessmentGroupsController {
|
|||||||
constructor(private service: AssessmentGroupsService) {}
|
constructor(private service: AssessmentGroupsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequireCapability('assessments.groups.view')
|
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
@RequireCapability('assessments.groups.view')
|
|
||||||
getSummary() { return this.service.getSummary(); }
|
getSummary() { return this.service.getSummary(); }
|
||||||
|
|
||||||
@Get('default')
|
@Get('default')
|
||||||
@RequireCapability('assessments.groups.view')
|
|
||||||
getDefault() { return this.service.getDefault(); }
|
getDefault() { return this.service.getDefault(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequireCapability('assessments.groups.view')
|
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequireCapability('assessments.groups.edit')
|
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequireCapability('assessments.groups.edit')
|
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
|
|
||||||
@Put(':id/set-default')
|
@Put(':id/set-default')
|
||||||
@RequireCapability('assessments.groups.edit')
|
|
||||||
setDefault(@Param('id') id: string) { return this.service.setDefault(id); }
|
setDefault(@Param('id') id: string) { return this.service.setDefault(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { AuthService } from './auth.service';
|
|||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { OrganizationsService } from '../organizations/organizations.service';
|
import { OrganizationsService } from '../organizations/organizations.service';
|
||||||
import { AdminAnalyticsService } from './admin-analytics.service';
|
import { AdminAnalyticsService } from './admin-analytics.service';
|
||||||
import { IdeasService } from '../ideas/ideas.service';
|
|
||||||
import * as bcrypt from 'bcryptjs';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
@ApiTags('admin')
|
@ApiTags('admin')
|
||||||
@@ -18,7 +17,6 @@ export class AdminController {
|
|||||||
private usersService: UsersService,
|
private usersService: UsersService,
|
||||||
private orgService: OrganizationsService,
|
private orgService: OrganizationsService,
|
||||||
private analyticsService: AdminAnalyticsService,
|
private analyticsService: AdminAnalyticsService,
|
||||||
private ideasService: IdeasService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async requireSuperadmin(req: any) {
|
private async requireSuperadmin(req: any) {
|
||||||
@@ -198,45 +196,4 @@ export class AdminController {
|
|||||||
|
|
||||||
return { success: true, organization: org };
|
return { success: true, organization: org };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ideation ──
|
|
||||||
|
|
||||||
@Get('ideas')
|
|
||||||
async listAllIdeas(@Req() req: any) {
|
|
||||||
await this.requireSuperadmin(req);
|
|
||||||
return this.ideasService.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put('ideas/:id/status')
|
|
||||||
async updateIdeaStatus(
|
|
||||||
@Req() req: any,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() body: { status: string },
|
|
||||||
) {
|
|
||||||
await this.requireSuperadmin(req);
|
|
||||||
const idea = await this.ideasService.updateStatus(id, body.status);
|
|
||||||
return { success: true, idea };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put('ideas/:id/note')
|
|
||||||
async updateIdeaNote(
|
|
||||||
@Req() req: any,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() body: { adminNote: string },
|
|
||||||
) {
|
|
||||||
await this.requireSuperadmin(req);
|
|
||||||
const idea = await this.ideasService.updateNote(id, body.adminNote);
|
|
||||||
return { success: true, idea };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Put('organizations/:id/settings')
|
|
||||||
async updateOrgSettings(
|
|
||||||
@Req() req: any,
|
|
||||||
@Param('id') id: string,
|
|
||||||
@Body() body: Record<string, any>,
|
|
||||||
) {
|
|
||||||
await this.requireSuperadmin(req);
|
|
||||||
const org = await this.orgService.updateSettings(id, body);
|
|
||||||
return { success: true, organization: org };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,11 @@ import { JwtStrategy } from './strategies/jwt.strategy';
|
|||||||
import { LocalStrategy } from './strategies/local.strategy';
|
import { LocalStrategy } from './strategies/local.strategy';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { OrganizationsModule } from '../organizations/organizations.module';
|
import { OrganizationsModule } from '../organizations/organizations.module';
|
||||||
import { IdeasModule } from '../ideas/ideas.module';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
UsersModule,
|
UsersModule,
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
IdeasModule,
|
|
||||||
PassportModule,
|
PassportModule,
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { EmailService } from '../email/email.service';
|
|||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { User } from '../users/entities/user.entity';
|
import { User } from '../users/entities/user.entity';
|
||||||
import { RefreshTokenService } from './refresh-token.service';
|
import { RefreshTokenService } from './refresh-token.service';
|
||||||
import { resolveCapabilitiesArray } from '../../common/permissions';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@@ -163,12 +162,6 @@ export class AuthService {
|
|||||||
// Generate new refresh token for org switch
|
// Generate new refresh token for org switch
|
||||||
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
|
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
|
||||||
|
|
||||||
const orgSettings = membership.organization.settings || {};
|
|
||||||
const capabilities = resolveCapabilitiesArray(
|
|
||||||
membership.role,
|
|
||||||
orgSettings.permissionOverrides,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken: this.jwtService.sign(payload),
|
accessToken: this.jwtService.sign(payload),
|
||||||
refreshToken,
|
refreshToken,
|
||||||
@@ -176,8 +169,7 @@ export class AuthService {
|
|||||||
id: membership.organization.id,
|
id: membership.organization.id,
|
||||||
name: membership.organization.name,
|
name: membership.organization.name,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
settings: orgSettings,
|
settings: membership.organization.settings || {},
|
||||||
capabilities,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -476,16 +468,12 @@ export class AuthService {
|
|||||||
hasSeenIntro: user.hasSeenIntro || false,
|
hasSeenIntro: user.hasSeenIntro || false,
|
||||||
mfaEnabled: user.mfaEnabled || false,
|
mfaEnabled: user.mfaEnabled || false,
|
||||||
},
|
},
|
||||||
organizations: orgs.map((uo) => {
|
organizations: orgs.map((uo) => ({
|
||||||
const settings = uo.organization?.settings || {};
|
id: uo.organizationId,
|
||||||
return {
|
name: uo.organization?.name,
|
||||||
id: uo.organizationId,
|
status: uo.organization?.status,
|
||||||
name: uo.organization?.name,
|
role: uo.role,
|
||||||
status: uo.organization?.status,
|
})),
|
||||||
role: uo.role,
|
|
||||||
capabilities: resolveCapabilitiesArray(uo.role, settings.permissionOverrides),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Response } from 'express';
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { BoardPlanningService } from './board-planning.service';
|
import { BoardPlanningService } from './board-planning.service';
|
||||||
import { BoardPlanningProjectionService } from './board-planning-projection.service';
|
import { BoardPlanningProjectionService } from './board-planning-projection.service';
|
||||||
import { BudgetPlanningService } from './budget-planning.service';
|
import { BudgetPlanningService } from './budget-planning.service';
|
||||||
@@ -23,32 +22,27 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('scenarios')
|
@Get('scenarios')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
@RequireCapability('planning.scenarios.view')
|
|
||||||
listScenarios(@Query('type') type?: string) {
|
listScenarios(@Query('type') type?: string) {
|
||||||
return this.service.listScenarios(type);
|
return this.service.listScenarios(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('scenarios/:id')
|
@Get('scenarios/:id')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
@RequireCapability('planning.scenarios.view')
|
|
||||||
getScenario(@Param('id') id: string) {
|
getScenario(@Param('id') id: string) {
|
||||||
return this.service.getScenario(id);
|
return this.service.getScenario(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios')
|
@Post('scenarios')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
createScenario(@Body() dto: any, @Req() req: any) {
|
createScenario(@Body() dto: any, @Req() req: any) {
|
||||||
return this.service.createScenario(dto, req.user.sub);
|
return this.service.createScenario(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('scenarios/:id')
|
@Put('scenarios/:id')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
updateScenario(@Param('id') id: string, @Body() dto: any) {
|
updateScenario(@Param('id') id: string, @Body() dto: any) {
|
||||||
return this.service.updateScenario(id, dto);
|
return this.service.updateScenario(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('scenarios/:id')
|
@Delete('scenarios/:id')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
deleteScenario(@Param('id') id: string) {
|
deleteScenario(@Param('id') id: string) {
|
||||||
return this.service.deleteScenario(id);
|
return this.service.deleteScenario(id);
|
||||||
}
|
}
|
||||||
@@ -57,31 +51,26 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('scenarios/:scenarioId/investments')
|
@Get('scenarios/:scenarioId/investments')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
@RequireCapability('planning.scenarios.view')
|
|
||||||
listInvestments(@Param('scenarioId') scenarioId: string) {
|
listInvestments(@Param('scenarioId') scenarioId: string) {
|
||||||
return this.service.listInvestments(scenarioId);
|
return this.service.listInvestments(scenarioId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios/:scenarioId/investments')
|
@Post('scenarios/:scenarioId/investments')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
return this.service.addInvestment(scenarioId, dto);
|
return this.service.addInvestment(scenarioId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios/:scenarioId/investments/from-recommendation')
|
@Post('scenarios/:scenarioId/investments/from-recommendation')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
|
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('investments/:id')
|
@Put('investments/:id')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
updateInvestment(@Param('id') id: string, @Body() dto: any) {
|
updateInvestment(@Param('id') id: string, @Body() dto: any) {
|
||||||
return this.service.updateInvestment(id, dto);
|
return this.service.updateInvestment(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('investments/:id')
|
@Delete('investments/:id')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
removeInvestment(@Param('id') id: string) {
|
removeInvestment(@Param('id') id: string) {
|
||||||
return this.service.removeInvestment(id);
|
return this.service.removeInvestment(id);
|
||||||
}
|
}
|
||||||
@@ -90,25 +79,21 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('scenarios/:scenarioId/assessments')
|
@Get('scenarios/:scenarioId/assessments')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
@RequireCapability('planning.scenarios.view')
|
|
||||||
listAssessments(@Param('scenarioId') scenarioId: string) {
|
listAssessments(@Param('scenarioId') scenarioId: string) {
|
||||||
return this.service.listAssessments(scenarioId);
|
return this.service.listAssessments(scenarioId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios/:scenarioId/assessments')
|
@Post('scenarios/:scenarioId/assessments')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
|
||||||
return this.service.addAssessment(scenarioId, dto);
|
return this.service.addAssessment(scenarioId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('assessments/:id')
|
@Put('assessments/:id')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
updateAssessment(@Param('id') id: string, @Body() dto: any) {
|
updateAssessment(@Param('id') id: string, @Body() dto: any) {
|
||||||
return this.service.updateAssessment(id, dto);
|
return this.service.updateAssessment(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('assessments/:id')
|
@Delete('assessments/:id')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
removeAssessment(@Param('id') id: string) {
|
removeAssessment(@Param('id') id: string) {
|
||||||
return this.service.removeAssessment(id);
|
return this.service.removeAssessment(id);
|
||||||
}
|
}
|
||||||
@@ -117,13 +102,11 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('scenarios/:id/projection')
|
@Get('scenarios/:id/projection')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
@RequireCapability('planning.scenarios.view')
|
|
||||||
getProjection(@Param('id') id: string) {
|
getProjection(@Param('id') id: string) {
|
||||||
return this.projection.getProjection(id);
|
return this.projection.getProjection(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('scenarios/:id/projection/refresh')
|
@Post('scenarios/:id/projection/refresh')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
refreshProjection(@Param('id') id: string) {
|
refreshProjection(@Param('id') id: string) {
|
||||||
return this.projection.computeProjection(id);
|
return this.projection.computeProjection(id);
|
||||||
}
|
}
|
||||||
@@ -132,7 +115,6 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('compare')
|
@Get('compare')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
@RequireCapability('planning.scenarios.view')
|
|
||||||
compareScenarios(@Query('ids') ids: string) {
|
compareScenarios(@Query('ids') ids: string) {
|
||||||
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
|
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
return this.projection.compareScenarios(scenarioIds);
|
return this.projection.compareScenarios(scenarioIds);
|
||||||
@@ -141,7 +123,6 @@ export class BoardPlanningController {
|
|||||||
// ── Execute Investment ──
|
// ── Execute Investment ──
|
||||||
|
|
||||||
@Post('investments/:id/execute')
|
@Post('investments/:id/execute')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
executeInvestment(
|
executeInvestment(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: { executionDate: string },
|
@Body() dto: { executionDate: string },
|
||||||
@@ -154,51 +135,43 @@ export class BoardPlanningController {
|
|||||||
|
|
||||||
@Get('budget-plans')
|
@Get('budget-plans')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
@RequireCapability('planning.scenarios.view')
|
|
||||||
listBudgetPlans() {
|
listBudgetPlans() {
|
||||||
return this.budgetPlanning.listPlans();
|
return this.budgetPlanning.listPlans();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('budget-plans/available-years')
|
@Get('budget-plans/available-years')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
@RequireCapability('planning.scenarios.view')
|
|
||||||
getAvailableYears() {
|
getAvailableYears() {
|
||||||
return this.budgetPlanning.getAvailableYears();
|
return this.budgetPlanning.getAvailableYears();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('budget-plans/:year')
|
@Get('budget-plans/:year')
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
@RequireCapability('planning.scenarios.view')
|
|
||||||
getBudgetPlan(@Param('year') year: string) {
|
getBudgetPlan(@Param('year') year: string) {
|
||||||
return this.budgetPlanning.getPlan(parseInt(year, 10));
|
return this.budgetPlanning.getPlan(parseInt(year, 10));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('budget-plans')
|
@Post('budget-plans')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
createBudgetPlan(@Body() dto: { fiscalYear: number; baseYear: number; inflationRate?: number }, @Req() req: any) {
|
createBudgetPlan(@Body() dto: { fiscalYear: number; baseYear: number; inflationRate?: number }, @Req() req: any) {
|
||||||
return this.budgetPlanning.createPlan(dto.fiscalYear, dto.baseYear, dto.inflationRate ?? 2.5, req.user.sub);
|
return this.budgetPlanning.createPlan(dto.fiscalYear, dto.baseYear, dto.inflationRate ?? 2.5, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('budget-plans/:year/lines')
|
@Put('budget-plans/:year/lines')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
|
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
|
||||||
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
|
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('budget-plans/:year/inflation')
|
@Put('budget-plans/:year/inflation')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
|
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
|
||||||
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
|
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('budget-plans/:year/status')
|
@Put('budget-plans/:year/status')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
|
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
|
||||||
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
|
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('budget-plans/:year/import')
|
@Post('budget-plans/:year/import')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
importBudgetPlanLines(
|
importBudgetPlanLines(
|
||||||
@Param('year') year: string,
|
@Param('year') year: string,
|
||||||
@Body() lines: any[],
|
@Body() lines: any[],
|
||||||
@@ -208,7 +181,6 @@ export class BoardPlanningController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('budget-plans/:year/template')
|
@Get('budget-plans/:year/template')
|
||||||
@RequireCapability('planning.scenarios.view')
|
|
||||||
async getBudgetPlanTemplate(
|
async getBudgetPlanTemplate(
|
||||||
@Param('year') year: string,
|
@Param('year') year: string,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
@@ -222,7 +194,6 @@ export class BoardPlanningController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete('budget-plans/:year')
|
@Delete('budget-plans/:year')
|
||||||
@RequireCapability('planning.scenarios.edit')
|
|
||||||
deleteBudgetPlan(@Param('year') year: string) {
|
deleteBudgetPlan(@Param('year') year: string) {
|
||||||
return this.budgetPlanning.deletePlan(parseInt(year, 10));
|
return this.budgetPlanning.deletePlan(parseInt(year, 10));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Controller, Get, Put, Post, Body, Param, Query, Res, UseGuards, ParseIn
|
|||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { BudgetsService } from './budgets.service';
|
import { BudgetsService } from './budgets.service';
|
||||||
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
|
||||||
|
|
||||||
@@ -15,7 +14,6 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Post(':year/import')
|
@Post(':year/import')
|
||||||
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
|
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
|
||||||
@RequireCapability('financials.budgets.edit')
|
|
||||||
importBudget(
|
importBudget(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Body() lines: any[],
|
@Body() lines: any[],
|
||||||
@@ -25,7 +23,6 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Get(':year/template')
|
@Get(':year/template')
|
||||||
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
|
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
|
||||||
@RequireCapability('financials.budgets.view')
|
|
||||||
async getTemplate(
|
async getTemplate(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
@@ -40,7 +37,6 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Get(':year/vs-actual')
|
@Get(':year/vs-actual')
|
||||||
@ApiOperation({ summary: 'Budget vs actual comparison' })
|
@ApiOperation({ summary: 'Budget vs actual comparison' })
|
||||||
@RequireCapability('financials.budgets.view')
|
|
||||||
budgetVsActual(
|
budgetVsActual(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Query('month') month?: string,
|
@Query('month') month?: string,
|
||||||
@@ -50,14 +46,12 @@ export class BudgetsController {
|
|||||||
|
|
||||||
@Get(':year')
|
@Get(':year')
|
||||||
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
|
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
|
||||||
@RequireCapability('financials.budgets.view')
|
|
||||||
findByYear(@Param('year', ParseIntPipe) year: number) {
|
findByYear(@Param('year', ParseIntPipe) year: number) {
|
||||||
return this.budgetsService.findByYear(year);
|
return this.budgetsService.findByYear(year);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':year')
|
@Put(':year')
|
||||||
@ApiOperation({ summary: 'Upsert budgets for a fiscal year' })
|
@ApiOperation({ summary: 'Upsert budgets for a fiscal year' })
|
||||||
@RequireCapability('financials.budgets.edit')
|
|
||||||
upsert(
|
upsert(
|
||||||
@Param('year', ParseIntPipe) year: number,
|
@Param('year', ParseIntPipe) year: number,
|
||||||
@Body() budgets: UpsertBudgetDto[],
|
@Body() budgets: UpsertBudgetDto[],
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { CapitalProjectsService } from './capital-projects.service';
|
import { CapitalProjectsService } from './capital-projects.service';
|
||||||
|
|
||||||
@ApiTags('capital-projects')
|
@ApiTags('capital-projects')
|
||||||
@@ -12,18 +11,14 @@ export class CapitalProjectsController {
|
|||||||
constructor(private service: CapitalProjectsService) {}
|
constructor(private service: CapitalProjectsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequireCapability('planning.projects.view')
|
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequireCapability('planning.projects.view')
|
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequireCapability('planning.projects.edit')
|
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequireCapability('planning.projects.edit')
|
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateIdeaDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(255)
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { Injectable, ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { Idea } from './entities/idea.entity';
|
|
||||||
import { Organization } from '../organizations/entities/organization.entity';
|
|
||||||
import { CreateIdeaDto } from './dto/create-idea.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class IdeasService {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(Idea)
|
|
||||||
private ideasRepository: Repository<Idea>,
|
|
||||||
@InjectRepository(Organization)
|
|
||||||
private orgRepository: Repository<Organization>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async create(orgId: string, userId: string, dto: CreateIdeaDto): Promise<Idea> {
|
|
||||||
const org = await this.orgRepository.findOne({ where: { id: orgId } });
|
|
||||||
if (!org) {
|
|
||||||
throw new NotFoundException('Organization not found');
|
|
||||||
}
|
|
||||||
if (org.settings?.ideationEnabled !== true) {
|
|
||||||
throw new ForbiddenException('Ideation is not enabled for this organization');
|
|
||||||
}
|
|
||||||
|
|
||||||
const idea = this.ideasRepository.create({
|
|
||||||
orgId,
|
|
||||||
userId,
|
|
||||||
title: dto.title,
|
|
||||||
description: dto.description,
|
|
||||||
});
|
|
||||||
return this.ideasRepository.save(idea);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByOrg(orgId: string): Promise<Idea[]> {
|
|
||||||
return this.ideasRepository.find({
|
|
||||||
where: { orgId },
|
|
||||||
order: { createdAt: 'DESC' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAll(): Promise<any[]> {
|
|
||||||
return this.ideasRepository
|
|
||||||
.createQueryBuilder('idea')
|
|
||||||
.leftJoin('idea.organization', 'org')
|
|
||||||
.leftJoin('idea.user', 'user')
|
|
||||||
.select([
|
|
||||||
'idea.id AS id',
|
|
||||||
'idea.title AS title',
|
|
||||||
'idea.description AS description',
|
|
||||||
'idea.status AS status',
|
|
||||||
'idea.createdAt AS "createdAt"',
|
|
||||||
'idea.adminNote AS "adminNote"',
|
|
||||||
'org.id AS "orgId"',
|
|
||||||
'org.name AS "orgName"',
|
|
||||||
'user.id AS "userId"',
|
|
||||||
'user.email AS "userEmail"',
|
|
||||||
'user.firstName AS "userFirstName"',
|
|
||||||
'user.lastName AS "userLastName"',
|
|
||||||
])
|
|
||||||
.orderBy('idea.createdAt', 'DESC')
|
|
||||||
.getRawMany();
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateStatus(id: string, status: string): Promise<Idea> {
|
|
||||||
const validStatuses = ['new', 'reviewed', 'accepted', 'rejected'];
|
|
||||||
if (!validStatuses.includes(status)) {
|
|
||||||
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const idea = await this.ideasRepository.findOne({ where: { id } });
|
|
||||||
if (!idea) {
|
|
||||||
throw new NotFoundException('Idea not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
idea.status = status;
|
|
||||||
return this.ideasRepository.save(idea);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateNote(id: string, adminNote: string): Promise<Idea> {
|
|
||||||
const idea = await this.ideasRepository.findOne({ where: { id } });
|
|
||||||
if (!idea) {
|
|
||||||
throw new NotFoundException('Idea not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
idea.adminNote = adminNote;
|
|
||||||
return this.ideasRepository.save(idea);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
|
|||||||
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { InvestmentPlanningService } from './investment-planning.service';
|
import { InvestmentPlanningService } from './investment-planning.service';
|
||||||
|
|
||||||
@ApiTags('investment-planning')
|
@ApiTags('investment-planning')
|
||||||
@@ -14,28 +13,24 @@ export class InvestmentPlanningController {
|
|||||||
|
|
||||||
@Get('snapshot')
|
@Get('snapshot')
|
||||||
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
|
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
|
||||||
@RequireCapability('planning.investments.view')
|
|
||||||
getSnapshot() {
|
getSnapshot() {
|
||||||
return this.service.getFinancialSnapshot();
|
return this.service.getFinancialSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('cd-rates')
|
@Get('cd-rates')
|
||||||
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
|
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
|
||||||
@RequireCapability('planning.investments.view')
|
|
||||||
getCdRates() {
|
getCdRates() {
|
||||||
return this.service.getCdRates();
|
return this.service.getCdRates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-rates')
|
@Get('market-rates')
|
||||||
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
|
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
|
||||||
@RequireCapability('planning.investments.view')
|
|
||||||
getMarketRates() {
|
getMarketRates() {
|
||||||
return this.service.getMarketRates();
|
return this.service.getMarketRates();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('saved-recommendation')
|
@Get('saved-recommendation')
|
||||||
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
|
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
|
||||||
@RequireCapability('planning.investments.view')
|
|
||||||
getSavedRecommendation() {
|
getSavedRecommendation() {
|
||||||
return this.service.getSavedRecommendation();
|
return this.service.getSavedRecommendation();
|
||||||
}
|
}
|
||||||
@@ -43,7 +38,6 @@ export class InvestmentPlanningController {
|
|||||||
@Post('recommendations')
|
@Post('recommendations')
|
||||||
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
|
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
|
||||||
@AllowViewer()
|
@AllowViewer()
|
||||||
@RequireCapability('planning.investments.edit')
|
|
||||||
triggerRecommendations(@Req() req: any) {
|
triggerRecommendations(@Req() req: any) {
|
||||||
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
|
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { InvestmentsService } from './investments.service';
|
import { InvestmentsService } from './investments.service';
|
||||||
|
|
||||||
@ApiTags('investments')
|
@ApiTags('investments')
|
||||||
@@ -12,18 +11,14 @@ export class InvestmentsController {
|
|||||||
constructor(private service: InvestmentsService) {}
|
constructor(private service: InvestmentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequireCapability('planning.investments.view')
|
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequireCapability('planning.investments.view')
|
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequireCapability('planning.investments.edit')
|
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequireCapability('planning.investments.edit')
|
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { InvoicesService } from './invoices.service';
|
import { InvoicesService } from './invoices.service';
|
||||||
|
|
||||||
@ApiTags('invoices')
|
@ApiTags('invoices')
|
||||||
@@ -12,27 +11,22 @@ export class InvoicesController {
|
|||||||
constructor(private invoicesService: InvoicesService) {}
|
constructor(private invoicesService: InvoicesService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequireCapability('transactions.view')
|
|
||||||
findAll() { return this.invoicesService.findAll(); }
|
findAll() { return this.invoicesService.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequireCapability('transactions.view')
|
|
||||||
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
|
||||||
|
|
||||||
@Post('generate-preview')
|
@Post('generate-preview')
|
||||||
@RequireCapability('transactions.edit')
|
|
||||||
generatePreview(@Body() dto: { month: number; year: number }) {
|
generatePreview(@Body() dto: { month: number; year: number }) {
|
||||||
return this.invoicesService.generatePreview(dto);
|
return this.invoicesService.generatePreview(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('generate-bulk')
|
@Post('generate-bulk')
|
||||||
@RequireCapability('transactions.edit')
|
|
||||||
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
|
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
|
||||||
return this.invoicesService.generateBulk(dto, req.user.sub);
|
return this.invoicesService.generateBulk(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('apply-late-fees')
|
@Post('apply-late-fees')
|
||||||
@RequireCapability('transactions.edit')
|
|
||||||
applyLateFees(@Body() dto: { grace_period_days: number; late_fee_amount: number }, @Request() req: any) {
|
applyLateFees(@Body() dto: { grace_period_days: number; late_fee_amount: number }, @Request() req: any) {
|
||||||
return this.invoicesService.applyLateFees(dto, req.user.sub);
|
return this.invoicesService.applyLateFees(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { JournalEntriesService } from './journal-entries.service';
|
import { JournalEntriesService } from './journal-entries.service';
|
||||||
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
|
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
|
||||||
import { VoidJournalEntryDto } from './dto/void-journal-entry.dto';
|
import { VoidJournalEntryDto } from './dto/void-journal-entry.dto';
|
||||||
@@ -17,7 +16,6 @@ export class JournalEntriesController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List journal entries' })
|
@ApiOperation({ summary: 'List journal entries' })
|
||||||
@RequireCapability('transactions.view')
|
|
||||||
findAll(
|
findAll(
|
||||||
@Query('from') from?: string,
|
@Query('from') from?: string,
|
||||||
@Query('to') to?: string,
|
@Query('to') to?: string,
|
||||||
@@ -29,28 +27,24 @@ export class JournalEntriesController {
|
|||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get journal entry by ID' })
|
@ApiOperation({ summary: 'Get journal entry by ID' })
|
||||||
@RequireCapability('transactions.view')
|
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.jeService.findOne(id);
|
return this.jeService.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a journal entry' })
|
@ApiOperation({ summary: 'Create a journal entry' })
|
||||||
@RequireCapability('transactions.edit')
|
|
||||||
create(@Body() dto: CreateJournalEntryDto, @Request() req: any) {
|
create(@Body() dto: CreateJournalEntryDto, @Request() req: any) {
|
||||||
return this.jeService.create(dto, req.user.sub);
|
return this.jeService.create(dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/post')
|
@Post(':id/post')
|
||||||
@ApiOperation({ summary: 'Post (finalize) a journal entry' })
|
@ApiOperation({ summary: 'Post (finalize) a journal entry' })
|
||||||
@RequireCapability('transactions.edit')
|
|
||||||
post(@Param('id') id: string, @Request() req: any) {
|
post(@Param('id') id: string, @Request() req: any) {
|
||||||
return this.jeService.post(id, req.user.sub);
|
return this.jeService.post(id, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/void')
|
@Post(':id/void')
|
||||||
@ApiOperation({ summary: 'Void a journal entry' })
|
@ApiOperation({ summary: 'Void a journal entry' })
|
||||||
@RequireCapability('transactions.edit')
|
|
||||||
void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) {
|
void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) {
|
||||||
return this.jeService.void(id, req.user.sub, dto.reason);
|
return this.jeService.void(id, req.user.sub, dto.reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { MonthlyActualsService } from './monthly-actuals.service';
|
import { MonthlyActualsService } from './monthly-actuals.service';
|
||||||
|
|
||||||
@ApiTags('monthly-actuals')
|
@ApiTags('monthly-actuals')
|
||||||
@@ -13,14 +12,12 @@ export class MonthlyActualsController {
|
|||||||
|
|
||||||
@Get(':year/:month')
|
@Get(':year/:month')
|
||||||
@ApiOperation({ summary: 'Get monthly actuals grid for a specific month' })
|
@ApiOperation({ summary: 'Get monthly actuals grid for a specific month' })
|
||||||
@RequireCapability('financials.actuals.view')
|
|
||||||
async getGrid(@Param('year') year: string, @Param('month') month: string) {
|
async getGrid(@Param('year') year: string, @Param('month') month: string) {
|
||||||
return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month));
|
return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':year/:month')
|
@Post(':year/:month')
|
||||||
@ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' })
|
@ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' })
|
||||||
@RequireCapability('financials.actuals.edit')
|
|
||||||
async save(
|
async save(
|
||||||
@Param('year') year: string,
|
@Param('year') year: string,
|
||||||
@Param('month') month: string,
|
@Param('month') month: string,
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|||||||
import { OrganizationsService } from './organizations.service';
|
import { OrganizationsService } from './organizations.service';
|
||||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { resolveCapabilitiesArray, ALL_CAPABILITIES } from '../../common/permissions';
|
|
||||||
|
|
||||||
@ApiTags('organizations')
|
@ApiTags('organizations')
|
||||||
@Controller('organizations')
|
@Controller('organizations')
|
||||||
@@ -25,87 +23,54 @@ export class OrganizationsController {
|
|||||||
return this.orgService.findByUser(req.user.sub);
|
return this.orgService.findByUser(req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('my-capabilities')
|
|
||||||
@ApiOperation({ summary: 'Get resolved capabilities for current user in current org' })
|
|
||||||
async getMyCapabilities(@Request() req: any) {
|
|
||||||
const org = await this.orgService.findById(req.user.orgId);
|
|
||||||
const settings = org?.settings || {};
|
|
||||||
const capabilities = resolveCapabilitiesArray(req.user.role, settings.permissionOverrides);
|
|
||||||
return { role: req.user.role, capabilities };
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch('settings')
|
@Patch('settings')
|
||||||
@ApiOperation({ summary: 'Update settings for the current organization' })
|
@ApiOperation({ summary: 'Update settings for the current organization' })
|
||||||
@RequireCapability('settings.org.edit')
|
|
||||||
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
|
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
|
||||||
// Validate permissionOverrides if present
|
this.requireTenantAdmin(req);
|
||||||
if (body.permissionOverrides) {
|
|
||||||
this.validatePermissionOverrides(body.permissionOverrides);
|
|
||||||
}
|
|
||||||
return this.orgService.updateSettings(req.user.orgId, body);
|
return this.orgService.updateSettings(req.user.orgId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Org Member Management ──
|
// ── Org Member Management ──
|
||||||
|
|
||||||
|
private requireTenantAdmin(req: any) {
|
||||||
|
const role = req.user.role;
|
||||||
|
if (!['president', 'admin', 'treasurer'].includes(role) && !req.user.isSuperadmin) {
|
||||||
|
throw new ForbiddenException('Only organization administrators can manage members');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Get('members')
|
@Get('members')
|
||||||
@ApiOperation({ summary: 'List members of current organization' })
|
@ApiOperation({ summary: 'List members of current organization' })
|
||||||
@RequireCapability('settings.members.view')
|
|
||||||
async getMembers(@Request() req: any) {
|
async getMembers(@Request() req: any) {
|
||||||
|
this.requireTenantAdmin(req);
|
||||||
return this.orgService.getMembers(req.user.orgId);
|
return this.orgService.getMembers(req.user.orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('members')
|
@Post('members')
|
||||||
@ApiOperation({ summary: 'Add a member to the current organization' })
|
@ApiOperation({ summary: 'Add a member to the current organization' })
|
||||||
@RequireCapability('settings.members.manage')
|
|
||||||
async addMember(
|
async addMember(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Body() body: { email: string; firstName: string; lastName: string; password: string; role: string },
|
@Body() body: { email: string; firstName: string; lastName: string; password: string; role: string },
|
||||||
) {
|
) {
|
||||||
|
this.requireTenantAdmin(req);
|
||||||
return this.orgService.addMember(req.user.orgId, body);
|
return this.orgService.addMember(req.user.orgId, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('members/:id/role')
|
@Put('members/:id/role')
|
||||||
@ApiOperation({ summary: 'Update a member role' })
|
@ApiOperation({ summary: 'Update a member role' })
|
||||||
@RequireCapability('settings.members.manage')
|
|
||||||
async updateMemberRole(
|
async updateMemberRole(
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() body: { role: string },
|
@Body() body: { role: string },
|
||||||
) {
|
) {
|
||||||
|
this.requireTenantAdmin(req);
|
||||||
return this.orgService.updateMemberRole(req.user.orgId, id, body.role);
|
return this.orgService.updateMemberRole(req.user.orgId, id, body.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('members/:id')
|
@Delete('members/:id')
|
||||||
@ApiOperation({ summary: 'Remove a member from the organization' })
|
@ApiOperation({ summary: 'Remove a member from the organization' })
|
||||||
@RequireCapability('settings.members.manage')
|
|
||||||
async removeMember(@Request() req: any, @Param('id') id: string) {
|
async removeMember(@Request() req: any, @Param('id') id: string) {
|
||||||
|
this.requireTenantAdmin(req);
|
||||||
return this.orgService.removeMember(req.user.orgId, id, req.user.sub);
|
return this.orgService.removeMember(req.user.orgId, id, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
private validatePermissionOverrides(overrides: any) {
|
|
||||||
if (typeof overrides !== 'object' || overrides === null) {
|
|
||||||
throw new ForbiddenException('permissionOverrides must be an object');
|
|
||||||
}
|
|
||||||
const validRoles = ['president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer'];
|
|
||||||
for (const role of Object.keys(overrides)) {
|
|
||||||
if (!validRoles.includes(role)) {
|
|
||||||
throw new ForbiddenException(`Invalid role in permissionOverrides: ${role}`);
|
|
||||||
}
|
|
||||||
const entry = overrides[role];
|
|
||||||
if (entry.grant) {
|
|
||||||
for (const cap of entry.grant) {
|
|
||||||
if (!ALL_CAPABILITIES.has(cap)) {
|
|
||||||
throw new ForbiddenException(`Unknown capability in grant: ${cap}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (entry.revoke) {
|
|
||||||
for (const cap of entry.revoke) {
|
|
||||||
if (!ALL_CAPABILITIES.has(cap)) {
|
|
||||||
throw new ForbiddenException(`Unknown capability in revoke: ${cap}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { PaymentsService } from './payments.service';
|
import { PaymentsService } from './payments.service';
|
||||||
|
|
||||||
@ApiTags('payments')
|
@ApiTags('payments')
|
||||||
@@ -12,24 +11,19 @@ export class PaymentsController {
|
|||||||
constructor(private paymentsService: PaymentsService) {}
|
constructor(private paymentsService: PaymentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequireCapability('transactions.view')
|
|
||||||
findAll() { return this.paymentsService.findAll(); }
|
findAll() { return this.paymentsService.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequireCapability('transactions.view')
|
|
||||||
findOne(@Param('id') id: string) { return this.paymentsService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.paymentsService.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequireCapability('transactions.edit')
|
|
||||||
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
|
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequireCapability('transactions.edit')
|
|
||||||
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
|
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
|
||||||
return this.paymentsService.update(id, dto, req.user.sub);
|
return this.paymentsService.update(id, dto, req.user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequireCapability('transactions.edit')
|
|
||||||
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
|
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Controller, Get, Post, Put, Body, Param, Res, UseGuards } from '@nestjs
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { ProjectsService } from './projects.service';
|
import { ProjectsService } from './projects.service';
|
||||||
|
|
||||||
@ApiTags('projects')
|
@ApiTags('projects')
|
||||||
@@ -13,11 +12,9 @@ export class ProjectsController {
|
|||||||
constructor(private service: ProjectsService) {}
|
constructor(private service: ProjectsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequireCapability('planning.projects.view')
|
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
@RequireCapability('planning.projects.view')
|
|
||||||
async exportCSV(@Res() res: Response) {
|
async exportCSV(@Res() res: Response) {
|
||||||
const csv = await this.service.exportCSV();
|
const csv = await this.service.exportCSV();
|
||||||
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="projects.csv"' });
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="projects.csv"' });
|
||||||
@@ -25,27 +22,21 @@ export class ProjectsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('planning')
|
@Get('planning')
|
||||||
@RequireCapability('planning.projects.view')
|
|
||||||
findForPlanning() { return this.service.findForPlanning(); }
|
findForPlanning() { return this.service.findForPlanning(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequireCapability('planning.projects.view')
|
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
@RequireCapability('planning.projects.edit')
|
|
||||||
importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); }
|
importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequireCapability('planning.projects.edit')
|
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequireCapability('planning.projects.edit')
|
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
|
|
||||||
@Put(':id/planned-date')
|
@Put(':id/planned-date')
|
||||||
@RequireCapability('planning.projects.edit')
|
|
||||||
updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) {
|
updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) {
|
||||||
return this.service.updatePlannedDate(id, dto.planned_date);
|
return this.service.updatePlannedDate(id, dto.planned_date);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { ReportsService } from './reports.service';
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
@ApiTags('reports')
|
@ApiTags('reports')
|
||||||
@@ -12,13 +11,11 @@ export class ReportsController {
|
|||||||
constructor(private reportsService: ReportsService) {}
|
constructor(private reportsService: ReportsService) {}
|
||||||
|
|
||||||
@Get('balance-sheet')
|
@Get('balance-sheet')
|
||||||
@RequireCapability('reports.view')
|
|
||||||
getBalanceSheet(@Query('as_of') asOf?: string) {
|
getBalanceSheet(@Query('as_of') asOf?: string) {
|
||||||
return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]);
|
return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('income-statement')
|
@Get('income-statement')
|
||||||
@RequireCapability('reports.view')
|
|
||||||
getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) {
|
getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const defaultFrom = `${now.getFullYear()}-01-01`;
|
const defaultFrom = `${now.getFullYear()}-01-01`;
|
||||||
@@ -27,7 +24,6 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow-sankey')
|
@Get('cash-flow-sankey')
|
||||||
@RequireCapability('reports.view')
|
|
||||||
getCashFlowSankey(
|
getCashFlowSankey(
|
||||||
@Query('year') year?: string,
|
@Query('year') year?: string,
|
||||||
@Query('source') source?: string,
|
@Query('source') source?: string,
|
||||||
@@ -41,7 +37,6 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow')
|
@Get('cash-flow')
|
||||||
@RequireCapability('reports.view')
|
|
||||||
getCashFlowStatement(
|
getCashFlowStatement(
|
||||||
@Query('from') from?: string,
|
@Query('from') from?: string,
|
||||||
@Query('to') to?: string,
|
@Query('to') to?: string,
|
||||||
@@ -56,31 +51,26 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('aging')
|
@Get('aging')
|
||||||
@RequireCapability('reports.view')
|
|
||||||
getAgingReport() {
|
getAgingReport() {
|
||||||
return this.reportsService.getAgingReport();
|
return this.reportsService.getAgingReport();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('year-end')
|
@Get('year-end')
|
||||||
@RequireCapability('reports.view')
|
|
||||||
getYearEndSummary(@Query('year') year?: string) {
|
getYearEndSummary(@Query('year') year?: string) {
|
||||||
return this.reportsService.getYearEndSummary(parseInt(year || '') || new Date().getFullYear());
|
return this.reportsService.getYearEndSummary(parseInt(year || '') || new Date().getFullYear());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('dashboard')
|
@Get('dashboard')
|
||||||
@RequireCapability('reports.view')
|
|
||||||
getDashboardKPIs() {
|
getDashboardKPIs() {
|
||||||
return this.reportsService.getDashboardKPIs();
|
return this.reportsService.getDashboardKPIs();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('upcoming-investment-activities')
|
@Get('upcoming-investment-activities')
|
||||||
@RequireCapability('reports.view')
|
|
||||||
getUpcomingInvestmentActivities() {
|
getUpcomingInvestmentActivities() {
|
||||||
return this.reportsService.getUpcomingInvestmentActivities();
|
return this.reportsService.getUpcomingInvestmentActivities();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('cash-flow-forecast')
|
@Get('cash-flow-forecast')
|
||||||
@RequireCapability('reports.view')
|
|
||||||
getCashFlowForecast(
|
getCashFlowForecast(
|
||||||
@Query('startYear') startYear?: string,
|
@Query('startYear') startYear?: string,
|
||||||
@Query('months') months?: string,
|
@Query('months') months?: string,
|
||||||
@@ -91,7 +81,6 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('capital-planning')
|
@Get('capital-planning')
|
||||||
@RequireCapability('reports.view')
|
|
||||||
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
|
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
|
||||||
return this.reportsService.getCapitalPlanningReport(
|
return this.reportsService.getCapitalPlanningReport(
|
||||||
parseInt(startYear || '') || undefined,
|
parseInt(startYear || '') || undefined,
|
||||||
@@ -99,7 +88,6 @@ export class ReportsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('quarterly')
|
@Get('quarterly')
|
||||||
@RequireCapability('reports.view')
|
|
||||||
getQuarterlyFinancial(
|
getQuarterlyFinancial(
|
||||||
@Query('year') year?: string,
|
@Query('year') year?: string,
|
||||||
@Query('quarter') quarter?: string,
|
@Query('quarter') quarter?: string,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { ReserveComponentsService } from './reserve-components.service';
|
import { ReserveComponentsService } from './reserve-components.service';
|
||||||
|
|
||||||
@ApiTags('reserve-components')
|
@ApiTags('reserve-components')
|
||||||
@@ -12,18 +11,14 @@ export class ReserveComponentsController {
|
|||||||
constructor(private service: ReserveComponentsService) {}
|
constructor(private service: ReserveComponentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequireCapability('planning.projects.view')
|
|
||||||
findAll() { return this.service.findAll(); }
|
findAll() { return this.service.findAll(); }
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequireCapability('planning.projects.view')
|
|
||||||
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
findOne(@Param('id') id: string) { return this.service.findOne(id); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequireCapability('planning.projects.edit')
|
|
||||||
create(@Body() dto: any) { return this.service.create(dto); }
|
create(@Body() dto: any) { return this.service.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequireCapability('planning.projects.edit')
|
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Res, UseGuards } from
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { UnitsService } from './units.service';
|
import { UnitsService } from './units.service';
|
||||||
|
|
||||||
@ApiTags('units')
|
@ApiTags('units')
|
||||||
@@ -13,11 +12,9 @@ export class UnitsController {
|
|||||||
constructor(private unitsService: UnitsService) {}
|
constructor(private unitsService: UnitsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequireCapability('assessments.units.view')
|
|
||||||
findAll() { return this.unitsService.findAll(); }
|
findAll() { return this.unitsService.findAll(); }
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
@RequireCapability('assessments.units.view')
|
|
||||||
async exportCSV(@Res() res: Response) {
|
async exportCSV(@Res() res: Response) {
|
||||||
const csv = await this.unitsService.exportCSV();
|
const csv = await this.unitsService.exportCSV();
|
||||||
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="units.csv"' });
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="units.csv"' });
|
||||||
@@ -25,22 +22,17 @@ export class UnitsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequireCapability('assessments.units.view')
|
|
||||||
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
@RequireCapability('assessments.units.edit')
|
|
||||||
importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); }
|
importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequireCapability('assessments.units.edit')
|
|
||||||
create(@Body() dto: any) { return this.unitsService.create(dto); }
|
create(@Body() dto: any) { return this.unitsService.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequireCapability('assessments.units.edit')
|
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequireCapability('assessments.units.edit')
|
|
||||||
delete(@Param('id') id: string) { return this.unitsService.delete(id); }
|
delete(@Param('id') id: string) { return this.unitsService.delete(id); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Controller, Get, Post, Put, Body, Param, Query, Res, UseGuards } from '
|
|||||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequireCapability } from '../../common/decorators/capability.decorator';
|
|
||||||
import { VendorsService } from './vendors.service';
|
import { VendorsService } from './vendors.service';
|
||||||
|
|
||||||
@ApiTags('vendors')
|
@ApiTags('vendors')
|
||||||
@@ -13,11 +12,9 @@ export class VendorsController {
|
|||||||
constructor(private vendorsService: VendorsService) {}
|
constructor(private vendorsService: VendorsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequireCapability('reference.vendors.view')
|
|
||||||
findAll() { return this.vendorsService.findAll(); }
|
findAll() { return this.vendorsService.findAll(); }
|
||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
@RequireCapability('reference.vendors.view')
|
|
||||||
async exportCSV(@Res() res: Response) {
|
async exportCSV(@Res() res: Response) {
|
||||||
const csv = await this.vendorsService.exportCSV();
|
const csv = await this.vendorsService.exportCSV();
|
||||||
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="vendors.csv"' });
|
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="vendors.csv"' });
|
||||||
@@ -25,24 +22,19 @@ export class VendorsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('1099-data')
|
@Get('1099-data')
|
||||||
@RequireCapability('reference.vendors.view')
|
|
||||||
get1099Data(@Query('year') year: string) {
|
get1099Data(@Query('year') year: string) {
|
||||||
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
|
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@RequireCapability('reference.vendors.view')
|
|
||||||
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
|
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
|
||||||
|
|
||||||
@Post('import')
|
@Post('import')
|
||||||
@RequireCapability('reference.vendors.edit')
|
|
||||||
importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); }
|
importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); }
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequireCapability('reference.vendors.edit')
|
|
||||||
create(@Body() dto: any) { return this.vendorsService.create(dto); }
|
create(@Body() dto: any) { return this.vendorsService.create(dto); }
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequireCapability('reference.vendors.edit')
|
|
||||||
update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); }
|
update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ CREATE TABLE shared.user_organizations (
|
|||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||||
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
organization_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
||||||
role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')),
|
role VARCHAR(50) NOT NULL CHECK (role IN ('president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer')),
|
||||||
is_active BOOLEAN DEFAULT TRUE,
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
UNIQUE(user_id, organization_id)
|
UNIQUE(user_id, organization_id)
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
-- 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);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- Add private admin note column to ideas table
|
|
||||||
ALTER TABLE shared.ideas ADD COLUMN IF NOT EXISTS admin_note TEXT;
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
-- Migration 020: Add vice_president role to user_organizations
|
|
||||||
-- This adds the vice_president role to the CHECK constraint on the role column.
|
|
||||||
|
|
||||||
ALTER TABLE shared.user_organizations
|
|
||||||
DROP CONSTRAINT IF EXISTS user_organizations_role_check;
|
|
||||||
|
|
||||||
ALTER TABLE shared.user_organizations
|
|
||||||
ADD CONSTRAINT user_organizations_role_check
|
|
||||||
CHECK (role IN ('president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer'));
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
# Gitea Actions Runner Setup — HOALedgerIQ Production Server
|
|
||||||
|
|
||||||
This guide walks through setting up a self-hosted Gitea Actions runner on the production server so the deployment workflow (`.gitea/workflows/deploy.yml`) can execute automatically.
|
|
||||||
|
|
||||||
The runner uses **host execution mode** — jobs run directly on the server (not inside Docker containers) so the deploy script has access to Docker, the git repo, and the local filesystem.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Ubuntu Linux production server
|
|
||||||
- Gitea instance (e.g., `https://git.sensetostyle.com`)
|
|
||||||
- Docker and Docker Compose installed on the server
|
|
||||||
- The HOALedgerIQ repo cloned at `/opt/hoa-ledgeriq`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 1: Enable Actions in Gitea
|
|
||||||
|
|
||||||
Ensure Actions are enabled in your Gitea configuration (`/etc/gitea/app.ini`):
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[actions]
|
|
||||||
ENABLED = true
|
|
||||||
```
|
|
||||||
|
|
||||||
Restart Gitea after making changes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl restart gitea
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 2: Get a Registration Token
|
|
||||||
|
|
||||||
1. Log into your Gitea instance
|
|
||||||
2. Navigate to **Site Administration** → **Actions** → **Runners**
|
|
||||||
3. Copy the **Registration Token**
|
|
||||||
|
|
||||||
> **Tip:** For tighter security, you can get a repo-scoped token instead:
|
|
||||||
> Repo → **Settings** → **Actions** → **Runners** → copy the token shown there.
|
|
||||||
> This limits the runner to only execute workflows from that specific repository.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3: Install the Act Runner Binary
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Download the latest act_runner for x86_64 Linux
|
|
||||||
wget https://dl.gitea.com/act_runner/latest/act_runner-linux-amd64
|
|
||||||
|
|
||||||
# Make executable and install to system path
|
|
||||||
chmod +x act_runner-linux-amd64
|
|
||||||
sudo mv act_runner-linux-amd64 /usr/local/bin/act_runner
|
|
||||||
|
|
||||||
# Verify installation
|
|
||||||
act_runner --version
|
|
||||||
```
|
|
||||||
|
|
||||||
> For ARM64 servers, use `act_runner-linux-arm64` instead.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 4: Generate and Edit the Configuration
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo mkdir -p /etc/act_runner
|
|
||||||
act_runner generate-config > /tmp/config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit `/tmp/config.yaml` and set the **labels to use host execution mode**:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
runner:
|
|
||||||
labels:
|
|
||||||
- "ubuntu-latest:host"
|
|
||||||
- "ubuntu-22.04:host"
|
|
||||||
```
|
|
||||||
|
|
||||||
The `:host` suffix tells the runner to execute jobs directly on the server rather than spinning up Docker containers. This is required because the deploy script needs access to:
|
|
||||||
|
|
||||||
- The Docker socket (to run `docker compose`)
|
|
||||||
- The git repository at `/opt/hoa-ledgeriq`
|
|
||||||
- The backup scripts and database
|
|
||||||
|
|
||||||
Move the config into place and lock down permissions:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo mv /tmp/config.yaml /etc/act_runner/config.yaml
|
|
||||||
sudo chmod 600 /etc/act_runner/config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 5: Register the Runner
|
|
||||||
|
|
||||||
```bash
|
|
||||||
act_runner register \
|
|
||||||
--no-interactive \
|
|
||||||
--instance "https://git.sensetostyle.com" \
|
|
||||||
--token "YOUR_REGISTRATION_TOKEN_HERE" \
|
|
||||||
--name "hoaledgeriq-prod" \
|
|
||||||
--labels "ubuntu-latest:host,ubuntu-22.04:host" \
|
|
||||||
--config /etc/act_runner/config.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
This creates a `.runner` file in the current directory containing the registration state.
|
|
||||||
|
|
||||||
> **Interactive alternative:** Run `act_runner register --config /etc/act_runner/config.yaml` and follow the prompts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 6: Set Up as a Systemd Service
|
|
||||||
|
|
||||||
Create the service file at `/etc/systemd/system/act_runner.service`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Gitea Actions Runner (HOALedgerIQ Prod)
|
|
||||||
Documentation=https://docs.gitea.com/usage/actions/act-runner
|
|
||||||
After=docker.service network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=root
|
|
||||||
WorkingDirectory=/opt/hoa-ledgeriq
|
|
||||||
ExecStart=/usr/local/bin/act_runner daemon --config /etc/act_runner/config.yaml
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
> **Security note on `User=root`:** The deploy script needs to run `docker compose`, `git reset --hard`, etc. If you have a dedicated deploy user in the `docker` group with write access to `/opt/hoa-ledgeriq`, use that instead. Running as root is the simplest option but grants maximum privileges.
|
|
||||||
|
|
||||||
Enable and start the service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable act_runner
|
|
||||||
sudo systemctl start act_runner
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 7: Verify the Runner Is Online
|
|
||||||
|
|
||||||
Check the service is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl status act_runner
|
|
||||||
```
|
|
||||||
|
|
||||||
View logs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo journalctl -u act_runner -f
|
|
||||||
```
|
|
||||||
|
|
||||||
Then confirm in Gitea:
|
|
||||||
|
|
||||||
1. Go to **Site Administration** → **Actions** → **Runners**
|
|
||||||
2. You should see **"hoaledgeriq-prod"** listed with status **Online**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 8: Test the Workflow
|
|
||||||
|
|
||||||
1. Go to your repo on Gitea → **Actions** tab
|
|
||||||
2. Select the **"Deploy to Production"** workflow
|
|
||||||
3. Click **Run Workflow**
|
|
||||||
4. If this is the first deployment against an existing database, check the **"Mark existing migrations as applied"** box
|
|
||||||
5. Monitor the run in the Actions tab
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Runner shows as Offline
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check service status and logs
|
|
||||||
sudo systemctl status act_runner
|
|
||||||
sudo journalctl -u act_runner -n 50
|
|
||||||
|
|
||||||
# Verify the instance URL is reachable from the server
|
|
||||||
wget -qO- https://git.sensetostyle.com/api/v1/version
|
|
||||||
```
|
|
||||||
|
|
||||||
### Workflow stuck on "Waiting for runner"
|
|
||||||
|
|
||||||
- Verify the runner labels match what the workflow expects. The workflow uses `runs-on: ubuntu-latest` which must match the `ubuntu-latest:host` label.
|
|
||||||
- Check the runner is registered at the correct scope (instance-wide, org-level, or repo-level).
|
|
||||||
|
|
||||||
### Permission denied errors during deploy
|
|
||||||
|
|
||||||
- Ensure the systemd service `User` has Docker access (`usermod -aG docker <user>`)
|
|
||||||
- Ensure the user has write access to `/opt/hoa-ledgeriq`
|
|
||||||
|
|
||||||
### Re-registering after token expiry
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl stop act_runner
|
|
||||||
# Get a new token from Gitea admin panel, then:
|
|
||||||
act_runner register \
|
|
||||||
--no-interactive \
|
|
||||||
--instance "https://git.sensetostyle.com" \
|
|
||||||
--token "NEW_TOKEN_HERE" \
|
|
||||||
--name "hoaledgeriq-prod" \
|
|
||||||
--labels "ubuntu-latest:host,ubuntu-22.04:host" \
|
|
||||||
--config /etc/act_runner/config.yaml
|
|
||||||
sudo systemctl start act_runner
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Best Practices
|
|
||||||
|
|
||||||
| Concern | Recommendation |
|
|
||||||
|---------|----------------|
|
|
||||||
| Runner user | Use a dedicated user with `docker` group access rather than `root` when possible |
|
|
||||||
| Registration token | Rotate periodically in the Gitea admin panel |
|
|
||||||
| Config file | Keep `/etc/act_runner/config.yaml` at mode `600` (owner-read only) |
|
|
||||||
| Runner scope | Register at the **repo level** instead of instance-wide so only this repo can trigger deployments |
|
|
||||||
| Workflow triggers | The deploy workflow uses `workflow_dispatch` (manual only) — no automatic triggers on push |
|
|
||||||
| Network | Ensure Gitea is accessed over HTTPS with valid SSL certificates |
|
|
||||||
@@ -29,7 +29,6 @@ import { SettingsPage } from './pages/settings/SettingsPage';
|
|||||||
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
|
||||||
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
|
||||||
import { AdminPage } from './pages/admin/AdminPage';
|
import { AdminPage } from './pages/admin/AdminPage';
|
||||||
import { AdminIdeasPage } from './pages/admin/AdminIdeasPage';
|
|
||||||
import { AdminShadowAiPage } from './pages/admin/AdminShadowAiPage';
|
import { AdminShadowAiPage } from './pages/admin/AdminShadowAiPage';
|
||||||
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
|
import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage';
|
||||||
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage';
|
||||||
@@ -42,7 +41,6 @@ import { AssessmentScenarioDetailPage } from './pages/board-planning/AssessmentS
|
|||||||
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
|
import { ScenarioComparisonPage } from './pages/board-planning/ScenarioComparisonPage';
|
||||||
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
|
import { BudgetPlanningPage } from './pages/board-planning/BudgetPlanningPage';
|
||||||
import { PricingPage } from './pages/pricing/PricingPage';
|
import { PricingPage } from './pages/pricing/PricingPage';
|
||||||
import { PermissionSettingsPage } from './pages/settings/PermissionSettingsPage';
|
|
||||||
import { OnboardingPage } from './pages/onboarding/OnboardingPage';
|
import { OnboardingPage } from './pages/onboarding/OnboardingPage';
|
||||||
import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage';
|
import { OnboardingPendingPage } from './pages/onboarding/OnboardingPendingPage';
|
||||||
|
|
||||||
@@ -136,7 +134,6 @@ export function App() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<AdminPage />} />
|
<Route index element={<AdminPage />} />
|
||||||
<Route path="ideas" element={<AdminIdeasPage />} />
|
|
||||||
<Route path="shadow-ai" element={<AdminShadowAiPage />} />
|
<Route path="shadow-ai" element={<AdminShadowAiPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
@@ -183,7 +180,6 @@ export function App() {
|
|||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="preferences" element={<UserPreferencesPage />} />
|
<Route path="preferences" element={<UserPreferencesPage />} />
|
||||||
<Route path="org-members" element={<OrgMembersPage />} />
|
<Route path="org-members" element={<OrgMembersPage />} />
|
||||||
<Route path="settings/permissions" element={<PermissionSettingsPage />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Modal, TextInput, Textarea, Button, Stack } from '@mantine/core';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
|
||||||
import api from '../../services/api';
|
|
||||||
|
|
||||||
interface IdeaModalProps {
|
|
||||||
opened: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function IdeaModal({ opened, onClose }: IdeaModalProps) {
|
|
||||||
const [title, setTitle] = useState('');
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
|
|
||||||
const submitIdea = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const { data } = await api.post('/ideas', { title, description });
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({ message: 'Idea submitted — thank you!', color: 'green' });
|
|
||||||
setTitle('');
|
|
||||||
setDescription('');
|
|
||||||
onClose();
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
notifications.show({
|
|
||||||
message: err.response?.data?.message || 'Failed to submit idea',
|
|
||||||
color: 'red',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setTitle('');
|
|
||||||
setDescription('');
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal opened={opened} onClose={handleClose} title="Submit an Idea" size="md">
|
|
||||||
<Stack>
|
|
||||||
<TextInput
|
|
||||||
label="Title"
|
|
||||||
placeholder="Brief summary of your idea"
|
|
||||||
required
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
|
||||||
maxLength={255}
|
|
||||||
/>
|
|
||||||
<Textarea
|
|
||||||
label="Description"
|
|
||||||
placeholder="Describe your idea in more detail (optional)"
|
|
||||||
minRows={4}
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={() => submitIdea.mutate()}
|
|
||||||
loading={submitIdea.isPending}
|
|
||||||
disabled={!title.trim()}
|
|
||||||
>
|
|
||||||
Submit Idea
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
IconEyeOff,
|
IconEyeOff,
|
||||||
IconSun,
|
IconSun,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
IconBulb,
|
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
@@ -19,7 +18,6 @@ import { usePreferencesStore } from '../../stores/preferencesStore';
|
|||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { AppTour } from '../onboarding/AppTour';
|
import { AppTour } from '../onboarding/AppTour';
|
||||||
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
import { OnboardingWizard } from '../onboarding/OnboardingWizard';
|
||||||
import { IdeaModal } from '../ideas/IdeaModal';
|
|
||||||
import logoSrc from '../../assets/logo.png';
|
import logoSrc from '../../assets/logo.png';
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
@@ -30,10 +28,6 @@ export function AppLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isImpersonating = !!impersonationOriginal;
|
const isImpersonating = !!impersonationOriginal;
|
||||||
|
|
||||||
// ── Ideation State ──
|
|
||||||
const [ideaModalOpened, { open: openIdeaModal, close: closeIdeaModal }] = useDisclosure(false);
|
|
||||||
const ideationEnabled = currentOrg?.settings?.ideationEnabled === true;
|
|
||||||
|
|
||||||
// ── Onboarding State ──
|
// ── Onboarding State ──
|
||||||
const [showTour, setShowTour] = useState(false);
|
const [showTour, setShowTour] = useState(false);
|
||||||
const [showWizard, setShowWizard] = useState(false);
|
const [showWizard, setShowWizard] = useState(false);
|
||||||
@@ -77,9 +71,8 @@ export function AppLayout() {
|
|||||||
navigate('/admin');
|
navigate('/admin');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Capability-based check: can this user manage members?
|
// Tenant admins (president role) can manage org members
|
||||||
const capabilities = currentOrg?.capabilities || [];
|
const isTenantAdmin = currentOrg?.role === 'president' || currentOrg?.role === 'admin';
|
||||||
const isTenantAdmin = user?.isSuperadmin || capabilities.includes('settings.members.manage');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
@@ -128,13 +121,6 @@ export function AppLayout() {
|
|||||||
{currentOrg && (
|
{currentOrg && (
|
||||||
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
|
||||||
)}
|
)}
|
||||||
{ideationEnabled && (
|
|
||||||
<Tooltip label="Submit an idea">
|
|
||||||
<ActionIcon variant="default" size="lg" onClick={openIdeaModal} aria-label="Submit idea">
|
|
||||||
<IconBulb size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -223,9 +209,6 @@ export function AppLayout() {
|
|||||||
{/* ── Onboarding Components ── */}
|
{/* ── Onboarding Components ── */}
|
||||||
<AppTour run={showTour} onComplete={handleTourComplete} />
|
<AppTour run={showTour} onComplete={handleTourComplete} />
|
||||||
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
<OnboardingWizard opened={showWizard} onComplete={handleWizardComplete} />
|
||||||
|
|
||||||
{/* ── Ideation Modal ── */}
|
|
||||||
<IdeaModal opened={ideaModalOpened} onClose={closeIdeaModal} />
|
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,63 +20,59 @@ import {
|
|||||||
IconCalculator,
|
IconCalculator,
|
||||||
IconGitCompare,
|
IconGitCompare,
|
||||||
IconScale,
|
IconScale,
|
||||||
IconBulb,
|
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { CAPABILITIES } from '../../permissions/capabilities';
|
|
||||||
|
|
||||||
const C = CAPABILITIES;
|
|
||||||
|
|
||||||
const navSections = [
|
const navSections = [
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard', capability: C.DASHBOARD_VIEW },
|
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Financials',
|
label: 'Financials',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts', capability: C.FINANCIALS_ACCOUNTS_VIEW },
|
{ label: 'Accounts', icon: IconListDetails, path: '/accounts', tourId: 'nav-accounts' },
|
||||||
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow', capability: C.FINANCIALS_CASHFLOW_VIEW },
|
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
|
||||||
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals', capability: C.FINANCIALS_ACTUALS_VIEW },
|
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' },
|
||||||
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets', capability: C.FINANCIALS_BUDGETS_VIEW },
|
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026', tourId: 'nav-budgets' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Assessments',
|
label: 'Assessments',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Units / Homeowners', icon: IconHome, path: '/units', capability: C.ASSESSMENTS_UNITS_VIEW },
|
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
|
||||||
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups', capability: C.ASSESSMENTS_GROUPS_VIEW },
|
{ label: 'Assessment Groups', icon: IconCategory, path: '/assessment-groups', tourId: 'nav-assessment-groups' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Board Planning',
|
label: 'Board Planning',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets', capability: C.PLANNING_BUDGETS_VIEW },
|
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets' },
|
||||||
{
|
{
|
||||||
label: 'Projects', icon: IconShieldCheck, path: '/projects', capability: C.PLANNING_PROJECTS_VIEW,
|
label: 'Projects', icon: IconShieldCheck, path: '/projects',
|
||||||
children: [
|
children: [
|
||||||
{ label: 'Capital Planning', path: '/capital-projects' },
|
{ label: 'Capital Planning', path: '/capital-projects' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments', capability: C.PLANNING_SCENARIOS_VIEW,
|
label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments',
|
||||||
},
|
},
|
||||||
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning', capability: C.PLANNING_INVESTMENTS_VIEW },
|
{ label: 'Investment Planning', icon: IconSparkles, path: '/investment-planning', tourId: 'nav-investment-planning' },
|
||||||
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments', capability: C.PLANNING_SCENARIOS_VIEW },
|
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
|
||||||
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare', capability: C.PLANNING_SCENARIOS_VIEW },
|
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Board Reference',
|
label: 'Board Reference',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Vendors', icon: IconUsers, path: '/vendors', capability: C.REFERENCE_VENDORS_VIEW },
|
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Transactions',
|
label: 'Transactions',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions', capability: C.TRANSACTIONS_VIEW },
|
{ label: 'Transactions', icon: IconReceipt, path: '/transactions', tourId: 'nav-transactions' },
|
||||||
// Invoices and Payments hidden — see PARKING-LOT.md for future re-enablement
|
// Invoices and Payments hidden — see PARKING-LOT.md for future re-enablement
|
||||||
// { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
// { label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
|
||||||
// { label: 'Payments', icon: IconCash, path: '/payments' },
|
// { label: 'Payments', icon: IconCash, path: '/payments' },
|
||||||
@@ -89,7 +85,6 @@ const navSections = [
|
|||||||
label: 'Reports',
|
label: 'Reports',
|
||||||
icon: IconChartSankey,
|
icon: IconChartSankey,
|
||||||
tourId: 'nav-reports',
|
tourId: 'nav-reports',
|
||||||
capability: C.REPORTS_VIEW,
|
|
||||||
children: [
|
children: [
|
||||||
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
|
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
|
||||||
{ label: 'Income Statement', path: '/reports/income-statement' },
|
{ label: 'Income Statement', path: '/reports/income-statement' },
|
||||||
@@ -118,15 +113,6 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
const organizations = useAuthStore((s) => s.organizations);
|
const organizations = useAuthStore((s) => s.organizations);
|
||||||
const isAdminOnly = location.pathname.startsWith('/admin') && !currentOrg;
|
const isAdminOnly = location.pathname.startsWith('/admin') && !currentOrg;
|
||||||
|
|
||||||
const capabilities = currentOrg?.capabilities || [];
|
|
||||||
const isSuperadmin = user?.isSuperadmin;
|
|
||||||
|
|
||||||
const hasCapability = (cap?: string) => {
|
|
||||||
if (!cap) return true;
|
|
||||||
if (isSuperadmin) return true;
|
|
||||||
return capabilities.includes(cap);
|
|
||||||
};
|
|
||||||
|
|
||||||
const go = (path: string) => {
|
const go = (path: string) => {
|
||||||
navigate(path);
|
navigate(path);
|
||||||
onNavigate?.();
|
onNavigate?.();
|
||||||
@@ -146,13 +132,6 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
onClick={() => go('/admin')}
|
onClick={() => go('/admin')}
|
||||||
color="red"
|
color="red"
|
||||||
/>
|
/>
|
||||||
<NavLink
|
|
||||||
label="Idea Submissions"
|
|
||||||
leftSection={<IconBulb size={18} />}
|
|
||||||
active={location.pathname === '/admin/ideas'}
|
|
||||||
onClick={() => go('/admin/ideas')}
|
|
||||||
color="yellow"
|
|
||||||
/>
|
|
||||||
<NavLink
|
<NavLink
|
||||||
label="AI Benchmarking"
|
label="AI Benchmarking"
|
||||||
leftSection={<IconScale size={18} />}
|
leftSection={<IconScale size={18} />}
|
||||||
@@ -177,10 +156,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea p="sm" data-tour="sidebar-nav">
|
<ScrollArea p="sm" data-tour="sidebar-nav">
|
||||||
{navSections.map((section, sIdx) => {
|
{navSections.map((section, sIdx) => (
|
||||||
const visibleItems = section.items.filter((item: any) => hasCapability(item.capability));
|
|
||||||
if (visibleItems.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div key={sIdx}>
|
<div key={sIdx}>
|
||||||
{section.label && (
|
{section.label && (
|
||||||
<>
|
<>
|
||||||
@@ -190,7 +166,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{visibleItems.map((item: any) =>
|
{section.items.map((item: any) =>
|
||||||
item.children && !item.path ? (
|
item.children && !item.path ? (
|
||||||
// Collapsible group without a parent route (e.g. Reports)
|
// Collapsible group without a parent route (e.g. Reports)
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -246,8 +222,7 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
|
|
||||||
{user?.isSuperadmin && (
|
{user?.isSuperadmin && (
|
||||||
<>
|
<>
|
||||||
@@ -262,20 +237,6 @@ export function Sidebar({ onNavigate }: SidebarProps) {
|
|||||||
onClick={() => go('/admin')}
|
onClick={() => go('/admin')}
|
||||||
color="red"
|
color="red"
|
||||||
/>
|
/>
|
||||||
<NavLink
|
|
||||||
label="Idea Submissions"
|
|
||||||
leftSection={<IconBulb size={18} />}
|
|
||||||
active={location.pathname === '/admin/ideas'}
|
|
||||||
onClick={() => go('/admin/ideas')}
|
|
||||||
color="yellow"
|
|
||||||
/>
|
|
||||||
<NavLink
|
|
||||||
label="AI Benchmarking"
|
|
||||||
leftSection={<IconScale size={18} />}
|
|
||||||
active={location.pathname === '/admin/shadow-ai'}
|
|
||||||
onClick={() => go('/admin/shadow-ai')}
|
|
||||||
color="violet"
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
|
const INVESTMENT_TYPES = ['inv_cd', 'inv_money_market', 'inv_treasury', 'inv_savings', 'inv_brokerage'];
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ export function AccountsPage() {
|
|||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [transferOpened, { open: openTransfer, close: closeTransfer }] = useDisclosure(false);
|
const [transferOpened, { open: openTransfer, close: closeTransfer }] = useDisclosure(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
// ── Accounts query ──
|
// ── Accounts query ──
|
||||||
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
const { data: accounts = [], isLoading } = useQuery<Account[]>({
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Title, Text, Card, Table, Group, Stack, Badge, Loader, Center,
|
|
||||||
Select, TextInput, Textarea, Button, Modal, SimpleGrid, ActionIcon,
|
|
||||||
Tooltip, Paper,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
|
||||||
import {
|
|
||||||
IconBulb, IconSearch, IconNote, IconFilter,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import api from '../../services/api';
|
|
||||||
|
|
||||||
interface AdminIdea {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
adminNote: string | null;
|
|
||||||
orgId: string;
|
|
||||||
orgName: string;
|
|
||||||
userId: string;
|
|
||||||
userEmail: string;
|
|
||||||
userFirstName: string;
|
|
||||||
userLastName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColor: Record<string, string> = {
|
|
||||||
new: 'blue',
|
|
||||||
reviewed: 'yellow',
|
|
||||||
accepted: 'green',
|
|
||||||
rejected: 'red',
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
{ value: 'new', label: 'New' },
|
|
||||||
{ value: 'reviewed', label: 'Reviewed' },
|
|
||||||
{ value: 'accepted', label: 'Accepted' },
|
|
||||||
{ value: 'rejected', label: 'Rejected' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatDate(dateStr: string | null | undefined): string {
|
|
||||||
if (!dateStr) return '—';
|
|
||||||
return new Date(dateStr).toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(dateStr: string | null | undefined): string {
|
|
||||||
if (!dateStr) return '—';
|
|
||||||
return new Date(dateStr).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminIdeasPage() {
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
|
||||||
const [selectedIdea, setSelectedIdea] = useState<AdminIdea | null>(null);
|
|
||||||
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
|
||||||
const [noteText, setNoteText] = useState('');
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const { data: ideas, isLoading } = useQuery<AdminIdea[]>({
|
|
||||||
queryKey: ['admin-ideas'],
|
|
||||||
queryFn: async () => { const { data } = await api.get('/admin/ideas'); return data; },
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateStatus = useMutation({
|
|
||||||
mutationFn: async ({ id, status }: { id: string; status: string }) => {
|
|
||||||
await api.put(`/admin/ideas/${id}/status`, { status });
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
|
|
||||||
notifications.show({ message: 'Status updated', color: 'green' });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateNote = useMutation({
|
|
||||||
mutationFn: async ({ id, adminNote }: { id: string; adminNote: string }) => {
|
|
||||||
await api.put(`/admin/ideas/${id}/note`, { adminNote });
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
|
|
||||||
notifications.show({ message: 'Note saved', color: 'green' });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const openIdeaDetail = (idea: AdminIdea) => {
|
|
||||||
setSelectedIdea(idea);
|
|
||||||
setNoteText(idea.adminNote || '');
|
|
||||||
openDetail();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveNote = () => {
|
|
||||||
if (selectedIdea) {
|
|
||||||
updateNote.mutate({ id: selectedIdea.id, adminNote: noteText });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filtered = (ideas || []).filter((idea) => {
|
|
||||||
const matchesSearch = !search ||
|
|
||||||
idea.title.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
idea.description?.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
idea.orgName.toLowerCase().includes(search.toLowerCase()) ||
|
|
||||||
idea.userEmail.toLowerCase().includes(search.toLowerCase());
|
|
||||||
const matchesStatus = !statusFilter || idea.status === statusFilter;
|
|
||||||
return matchesSearch && matchesStatus;
|
|
||||||
});
|
|
||||||
|
|
||||||
const counts = {
|
|
||||||
total: ideas?.length || 0,
|
|
||||||
new: ideas?.filter(i => i.status === 'new').length || 0,
|
|
||||||
reviewed: ideas?.filter(i => i.status === 'reviewed').length || 0,
|
|
||||||
accepted: ideas?.filter(i => i.status === 'accepted').length || 0,
|
|
||||||
rejected: ideas?.filter(i => i.status === 'rejected').length || 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <Center h={400}><Loader /></Center>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Group>
|
|
||||||
<IconBulb size={28} />
|
|
||||||
<Title order={2}>Idea Submissions</Title>
|
|
||||||
</Group>
|
|
||||||
<Badge size="lg" variant="light">{counts.total} total</Badge>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{/* Summary cards */}
|
|
||||||
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
|
||||||
<Paper withBorder p="md" radius="md">
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>New</Text>
|
|
||||||
<Text size="xl" fw={700} c="blue">{counts.new}</Text>
|
|
||||||
</Paper>
|
|
||||||
<Paper withBorder p="md" radius="md">
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reviewed</Text>
|
|
||||||
<Text size="xl" fw={700} c="yellow">{counts.reviewed}</Text>
|
|
||||||
</Paper>
|
|
||||||
<Paper withBorder p="md" radius="md">
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Accepted</Text>
|
|
||||||
<Text size="xl" fw={700} c="green">{counts.accepted}</Text>
|
|
||||||
</Paper>
|
|
||||||
<Paper withBorder p="md" radius="md">
|
|
||||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Rejected</Text>
|
|
||||||
<Text size="xl" fw={700} c="red">{counts.rejected}</Text>
|
|
||||||
</Paper>
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<Group>
|
|
||||||
<TextInput
|
|
||||||
placeholder="Search ideas, tenants, users..."
|
|
||||||
leftSection={<IconSearch size={16} />}
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="All statuses"
|
|
||||||
leftSection={<IconFilter size={16} />}
|
|
||||||
data={statusOptions}
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={setStatusFilter}
|
|
||||||
clearable
|
|
||||||
w={160}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{/* Ideas table */}
|
|
||||||
<Card withBorder p={0}>
|
|
||||||
<Table striped highlightOnHover>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th>Date</Table.Th>
|
|
||||||
<Table.Th>Tenant</Table.Th>
|
|
||||||
<Table.Th>Submitted By</Table.Th>
|
|
||||||
<Table.Th>Title</Table.Th>
|
|
||||||
<Table.Th>Status</Table.Th>
|
|
||||||
<Table.Th w={40}></Table.Th>
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Td colSpan={6}>
|
|
||||||
<Text ta="center" c="dimmed" py="lg">
|
|
||||||
{ideas?.length === 0 ? 'No ideas submitted yet' : 'No ideas match your filters'}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
) : (
|
|
||||||
filtered.map((idea) => (
|
|
||||||
<Table.Tr
|
|
||||||
key={idea.id}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={() => openIdeaDetail(idea)}
|
|
||||||
>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="xs">{formatDate(idea.createdAt)}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm" fw={500}>{idea.orgName}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm">{idea.userFirstName} {idea.userLastName}</Text>
|
|
||||||
<Text size="xs" c="dimmed">{idea.userEmail}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Text size="sm" fw={500} lineClamp={1}>{idea.title}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
<Badge size="sm" variant="light" color={statusColor[idea.status]}>
|
|
||||||
{idea.status}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
|
||||||
<Table.Td>
|
|
||||||
{idea.adminNote && (
|
|
||||||
<Tooltip label="Has admin note">
|
|
||||||
<IconNote size={16} color="gray" />
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Detail Modal */}
|
|
||||||
<Modal
|
|
||||||
opened={detailOpened}
|
|
||||||
onClose={closeDetail}
|
|
||||||
title={<Text fw={600}>Idea Detail</Text>}
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{selectedIdea && (
|
|
||||||
<Stack>
|
|
||||||
<Card withBorder>
|
|
||||||
<SimpleGrid cols={2} spacing="xs">
|
|
||||||
<Text size="xs" c="dimmed">Tenant</Text>
|
|
||||||
<Text size="sm" fw={500}>{selectedIdea.orgName}</Text>
|
|
||||||
<Text size="xs" c="dimmed">Submitted By</Text>
|
|
||||||
<Text size="sm">{selectedIdea.userFirstName} {selectedIdea.userLastName} ({selectedIdea.userEmail})</Text>
|
|
||||||
<Text size="xs" c="dimmed">Date</Text>
|
|
||||||
<Text size="sm">{formatDateTime(selectedIdea.createdAt)}</Text>
|
|
||||||
</SimpleGrid>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card withBorder>
|
|
||||||
<Text fw={600} mb="xs">Title</Text>
|
|
||||||
<Text size="sm">{selectedIdea.title}</Text>
|
|
||||||
{selectedIdea.description && (
|
|
||||||
<>
|
|
||||||
<Text fw={600} mt="md" mb="xs">Description</Text>
|
|
||||||
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{selectedIdea.description}</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card withBorder>
|
|
||||||
<Text fw={600} mb="xs">Status</Text>
|
|
||||||
<Select
|
|
||||||
data={statusOptions}
|
|
||||||
value={selectedIdea.status}
|
|
||||||
onChange={(val) => {
|
|
||||||
if (val && val !== selectedIdea.status) {
|
|
||||||
updateStatus.mutate({ id: selectedIdea.id, status: val }, {
|
|
||||||
onSuccess: () => {
|
|
||||||
setSelectedIdea({ ...selectedIdea, status: val });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
w={200}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card withBorder>
|
|
||||||
<Group justify="space-between" mb="xs">
|
|
||||||
<Text fw={600}>Private Admin Note</Text>
|
|
||||||
<Text size="xs" c="dimmed">Only visible to super admins</Text>
|
|
||||||
</Group>
|
|
||||||
<Textarea
|
|
||||||
placeholder="Add internal notes — sprint reference, thoughts, follow-up actions..."
|
|
||||||
minRows={3}
|
|
||||||
value={noteText}
|
|
||||||
onChange={(e) => setNoteText(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="light"
|
|
||||||
mt="xs"
|
|
||||||
onClick={handleSaveNote}
|
|
||||||
loading={updateNote.isPending}
|
|
||||||
disabled={noteText === (selectedIdea.adminNote || '')}
|
|
||||||
>
|
|
||||||
Save Note
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
</Stack>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
IconCrown, IconPlus, IconArchive, IconChevronDown,
|
IconCrown, IconPlus, IconArchive, IconChevronDown,
|
||||||
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
|
IconCircleCheck, IconBan, IconArchiveOff, IconDashboard,
|
||||||
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
|
IconHeartRateMonitor, IconSparkles, IconCalendar, IconActivity,
|
||||||
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye, IconBulb,
|
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -211,16 +211,6 @@ export function AdminPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleIdeation = useMutation({
|
|
||||||
mutationFn: async ({ orgId, enabled }: { orgId: string; enabled: boolean }) => {
|
|
||||||
await api.put(`/admin/organizations/${orgId}/settings`, { ideationEnabled: enabled });
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-tenant-detail', selectedOrgId] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const impersonateUser = useMutation({
|
const impersonateUser = useMutation({
|
||||||
mutationFn: async (userId: string) => {
|
mutationFn: async (userId: string) => {
|
||||||
const { data } = await api.post(`/admin/impersonate/${userId}`);
|
const { data } = await api.post(`/admin/impersonate/${userId}`);
|
||||||
@@ -792,27 +782,6 @@ export function AdminPage() {
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card withBorder>
|
|
||||||
<Text fw={600} mb="xs">Feature Toggles</Text>
|
|
||||||
<Group justify="space-between">
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconBulb size={16} />
|
|
||||||
<div>
|
|
||||||
<Text size="sm">Ideation</Text>
|
|
||||||
<Text size="xs" c="dimmed">Allow users to submit feature ideas</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
<Switch
|
|
||||||
checked={tenantDetail.organization.settings?.ideationEnabled === true}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (selectedOrgId) {
|
|
||||||
toggleIdeation.mutate({ orgId: selectedOrgId, enabled: e.currentTarget.checked });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card withBorder>
|
<Card withBorder>
|
||||||
<Text fw={600} mb="xs">Subscription</Text>
|
<Text fw={600} mb="xs">Subscription</Text>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ function ModelSlotCard({ slot, model, isLoading }: { slot: string; model?: Shado
|
|||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
<TextInput label="Display Name" placeholder="e.g. GPT-4o" value={name} onChange={(e) => setName(e.target.value)} size="sm" />
|
<TextInput label="Display Name" placeholder="e.g. GPT-4o" value={name} onChange={(e) => setName(e.target.value)} size="sm" />
|
||||||
<TextInput label="API URL" description="Base URL only — /chat/completions is added automatically" placeholder="https://openrouter.ai/api/v1" value={apiUrl} onChange={(e) => setApiUrl(e.target.value)} size="sm" />
|
<TextInput label="API URL" placeholder="https://api.openai.com/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" />
|
<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" />
|
<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)} />
|
<Switch label="Active" checked={isActive} onChange={(e) => setIsActive(e.currentTarget.checked)} />
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface AssessmentGroup {
|
interface AssessmentGroup {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -79,7 +79,7 @@ export function AssessmentGroupsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
|
const [editing, setEditing] = useState<AssessmentGroup | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.ASSESSMENTS_GROUPS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
|
const { data: groups = [], isLoading } = useQuery<AssessmentGroup[]>({
|
||||||
queryKey: ['assessment-groups'],
|
queryKey: ['assessment-groups'],
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
interface PlanLine {
|
interface PlanLine {
|
||||||
@@ -87,7 +87,7 @@ const statusColors: Record<string, string> = {
|
|||||||
|
|
||||||
export function BudgetPlanningPage() {
|
export function BudgetPlanningPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_BUDGETS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||||
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, IconArrowRight } f
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
|
|
||||||
interface BudgetLine {
|
interface BudgetLine {
|
||||||
@@ -40,7 +40,7 @@ export function BudgetsPage() {
|
|||||||
const [editData, setEditData] = useState<BudgetLine[] | null>(null); // null = not editing
|
const [editData, setEditData] = useState<BudgetLine[] | null>(null); // null = not editing
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_BUDGETS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||||
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types & constants
|
// Types & constants
|
||||||
@@ -252,7 +252,7 @@ export function CapitalProjectsPage() {
|
|||||||
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
|
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
|
||||||
const printModeRef = useRef(false);
|
const printModeRef = useRef(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
// ---- Data fetching ----
|
// ---- Data fetching ----
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ import {
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
||||||
import { useHasAnyCapability, CAPABILITIES } from '../../permissions';
|
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
|
|
||||||
interface HealthScore {
|
interface HealthScore {
|
||||||
@@ -351,11 +350,7 @@ interface DashboardData {
|
|||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const currentOrg = useAuthStore((s) => s.currentOrg);
|
const currentOrg = useAuthStore((s) => s.currentOrg);
|
||||||
const isReadOnly = !useHasAnyCapability(
|
const isReadOnly = useIsReadOnly();
|
||||||
CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT,
|
|
||||||
CAPABILITIES.FINANCIALS_BUDGETS_EDIT,
|
|
||||||
CAPABILITIES.FINANCIALS_ACTUALS_EDIT,
|
|
||||||
);
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
|
|
||||||
@@ -385,7 +385,7 @@ export function InvestmentPlanningPage() {
|
|||||||
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
||||||
const [newScenarioName, setNewScenarioName] = useState('');
|
const [newScenarioName, setNewScenarioName] = useState('');
|
||||||
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
|
const [investmentStartDate, setInvestmentStartDate] = useState<Date | null>(new Date());
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_INVESTMENTS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
// Load investment scenarios for the "Add to Plan" modal
|
// Load investment scenarios for the "Add to Plan" modal
|
||||||
const { data: investmentScenarios } = useQuery<any[]>({
|
const { data: investmentScenarios } = useQuery<any[]>({
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface Investment {
|
interface Investment {
|
||||||
id: string; name: string; institution: string; account_number_last4: string;
|
id: string; name: string; institution: string; account_number_last4: string;
|
||||||
@@ -26,7 +26,7 @@ export function InvestmentsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<Investment | null>(null);
|
const [editing, setEditing] = useState<Investment | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_INVESTMENTS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: investments = [], isLoading } = useQuery<Investment[]>({
|
const { data: investments = [], isLoading } = useQuery<Investment[]>({
|
||||||
queryKey: ['investments'],
|
queryKey: ['investments'],
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
import { IconSend, IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface Invoice {
|
interface Invoice {
|
||||||
id: string; invoice_number: string; unit_number: string; unit_id: string;
|
id: string; invoice_number: string; unit_number: string; unit_id: string;
|
||||||
@@ -65,7 +65,7 @@ export function InvoicesPage() {
|
|||||||
const [preview, setPreview] = useState<Preview | null>(null);
|
const [preview, setPreview] = useState<Preview | null>(null);
|
||||||
const [previewLoading, setPreviewLoading] = useState(false);
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
|
||||||
queryKey: ['invoices'],
|
queryKey: ['invoices'],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
import { usePreferencesStore } from '../../stores/preferencesStore';
|
import { usePreferencesStore } from '../../stores/preferencesStore';
|
||||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ export function MonthlyActualsPage() {
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
|
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.FINANCIALS_ACTUALS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
|
||||||
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
|
||||||
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ import {
|
|||||||
IconShieldCheck, IconInfoCircle,
|
IconShieldCheck, IconInfoCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore, useIsReadOnly } from '../../stores/authStore';
|
||||||
import { useCanEdit, useHasCapability, CAPABILITIES } from '../../permissions';
|
|
||||||
|
|
||||||
interface OrgMember {
|
interface OrgMember {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,21 +29,19 @@ interface OrgMember {
|
|||||||
|
|
||||||
const ROLE_OPTIONS = [
|
const ROLE_OPTIONS = [
|
||||||
{ value: 'president', label: 'President' },
|
{ value: 'president', label: 'President' },
|
||||||
{ value: 'vice_president', label: 'Vice President' },
|
|
||||||
{ value: 'treasurer', label: 'Treasurer' },
|
{ value: 'treasurer', label: 'Treasurer' },
|
||||||
{ value: 'secretary', label: 'Secretary' },
|
{ value: 'secretary', label: 'Secretary' },
|
||||||
{ value: 'member_at_large', label: 'Member at Large' },
|
{ value: 'board_member', label: 'Board Member' },
|
||||||
{ value: 'manager', label: 'Property Manager' },
|
{ value: 'property_manager', label: 'Property Manager' },
|
||||||
{ value: 'viewer', label: 'Viewer (Read-Only)' },
|
{ value: 'viewer', label: 'Viewer (Read-Only)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const roleColors: Record<string, string> = {
|
const roleColors: Record<string, string> = {
|
||||||
president: 'red',
|
president: 'red',
|
||||||
vice_president: 'grape',
|
|
||||||
treasurer: 'blue',
|
treasurer: 'blue',
|
||||||
secretary: 'green',
|
secretary: 'green',
|
||||||
member_at_large: 'violet',
|
board_member: 'violet',
|
||||||
manager: 'orange',
|
property_manager: 'orange',
|
||||||
viewer: 'gray',
|
viewer: 'gray',
|
||||||
admin: 'red',
|
admin: 'red',
|
||||||
};
|
};
|
||||||
@@ -56,9 +52,7 @@ export function OrgMembersPage() {
|
|||||||
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
|
const [editingMember, setEditingMember] = useState<OrgMember | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { user, currentOrg } = useAuthStore();
|
const { user, currentOrg } = useAuthStore();
|
||||||
const navigate = useNavigate();
|
const isReadOnly = useIsReadOnly();
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.SETTINGS_MEMBERS_MANAGE);
|
|
||||||
const canManagePermissions = useHasCapability(CAPABILITIES.SETTINGS_PERMISSIONS_MANAGE);
|
|
||||||
|
|
||||||
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
|
const { data: members = [], isLoading } = useQuery<OrgMember[]>({
|
||||||
queryKey: ['org-members'],
|
queryKey: ['org-members'],
|
||||||
@@ -74,7 +68,7 @@ export function OrgMembersPage() {
|
|||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
password: '',
|
password: '',
|
||||||
role: 'member_at_large',
|
role: 'board_member',
|
||||||
},
|
},
|
||||||
validate: {
|
validate: {
|
||||||
email: (v) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Valid email required'),
|
email: (v) => (/^\S+@\S+\.\S+$/.test(v) ? null : 'Valid email required'),
|
||||||
@@ -86,7 +80,7 @@ export function OrgMembersPage() {
|
|||||||
|
|
||||||
const editForm = useForm({
|
const editForm = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
role: 'member_at_large',
|
role: 'board_member',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,18 +163,11 @@ export function OrgMembersPage() {
|
|||||||
<Title order={2}>Organization Members</Title>
|
<Title order={2}>Organization Members</Title>
|
||||||
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
|
<Text c="dimmed" size="sm">Manage who has access to {currentOrg?.name}</Text>
|
||||||
</div>
|
</div>
|
||||||
<Group>
|
{!isReadOnly && (
|
||||||
{canManagePermissions && (
|
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
|
||||||
<Button variant="light" leftSection={<IconShieldCheck size={16} />} onClick={() => navigate('/settings/permissions')}>
|
Add Member
|
||||||
Role Permissions
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
)}
|
|
||||||
{!isReadOnly && (
|
|
||||||
<Button leftSection={<IconUserPlus size={16} />} onClick={openAdd}>
|
|
||||||
Add Member
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface Payment {
|
interface Payment {
|
||||||
id: string; unit_id: string; unit_number: string; invoice_id: string;
|
id: string; unit_id: string; unit_number: string; invoice_id: string;
|
||||||
@@ -23,7 +23,7 @@ export function PaymentsPage() {
|
|||||||
const [editing, setEditing] = useState<Payment | null>(null);
|
const [editing, setEditing] = useState<Payment | null>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<Payment | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: payments = [], isLoading } = useQuery<Payment[]>({
|
const { data: payments = [], isLoading } = useQuery<Payment[]>({
|
||||||
queryKey: ['payments'],
|
queryKey: ['payments'],
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { IconPlus, IconEdit, IconUpload, IconDownload, IconLock, IconLockOpen, I
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types & constants
|
// Types & constants
|
||||||
@@ -79,7 +79,7 @@ export function ProjectsPage() {
|
|||||||
const [editing, setEditing] = useState<Project | null>(null);
|
const [editing, setEditing] = useState<Project | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
// ---- Data fetching ----
|
// ---- Data fetching ----
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
import { IconPlus, IconEdit } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface ReserveComponent {
|
interface ReserveComponent {
|
||||||
id: string; name: string; category: string; description: string;
|
id: string; name: string; category: string; description: string;
|
||||||
@@ -27,7 +27,7 @@ export function ReservesPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [editing, setEditing] = useState<ReserveComponent | null>(null);
|
const [editing, setEditing] = useState<ReserveComponent | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.PLANNING_PROJECTS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
|
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
|
||||||
queryKey: ['reserve-components'],
|
queryKey: ['reserve-components'],
|
||||||
|
|||||||
@@ -1,250 +0,0 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
Title, Text, Card, Stack, Group, Table, Checkbox, Button, Alert,
|
|
||||||
Badge, Tooltip, Divider, Loader, Center,
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { notifications } from '@mantine/notifications';
|
|
||||||
import { IconShieldCheck, IconRefresh, IconInfoCircle } from '@tabler/icons-react';
|
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
|
||||||
import { CAPABILITY_AREAS } from '../../permissions/capabilities';
|
|
||||||
import { DEFAULT_ROLE_CAPABILITIES } from '../../permissions/default-role-capabilities';
|
|
||||||
import api from '../../services/api';
|
|
||||||
|
|
||||||
/** Roles shown as columns (homeowner hidden from UI per product decision) */
|
|
||||||
const DISPLAY_ROLES = [
|
|
||||||
{ value: 'president', label: 'President' },
|
|
||||||
{ value: 'vice_president', label: 'Vice President' },
|
|
||||||
{ value: 'treasurer', label: 'Treasurer' },
|
|
||||||
{ value: 'secretary', label: 'Secretary' },
|
|
||||||
{ value: 'member_at_large', label: 'Member at Large' },
|
|
||||||
{ value: 'manager', label: 'Property Manager' },
|
|
||||||
{ value: 'viewer', label: 'Viewer' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface PermissionOverrides {
|
|
||||||
[role: string]: {
|
|
||||||
grant?: string[];
|
|
||||||
revoke?: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCheckedState(overrides: PermissionOverrides): Record<string, Record<string, boolean>> {
|
|
||||||
const state: Record<string, Record<string, boolean>> = {};
|
|
||||||
for (const role of DISPLAY_ROLES) {
|
|
||||||
const defaults = new Set(DEFAULT_ROLE_CAPABILITIES[role.value] || []);
|
|
||||||
const roleOverride = overrides[role.value];
|
|
||||||
|
|
||||||
if (roleOverride?.grant) {
|
|
||||||
for (const cap of roleOverride.grant) defaults.add(cap);
|
|
||||||
}
|
|
||||||
if (roleOverride?.revoke) {
|
|
||||||
for (const cap of roleOverride.revoke) defaults.delete(cap);
|
|
||||||
}
|
|
||||||
|
|
||||||
state[role.value] = {};
|
|
||||||
for (const area of CAPABILITY_AREAS) {
|
|
||||||
for (const cap of area.capabilities) {
|
|
||||||
state[role.value][cap.key] = defaults.has(cap.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOverridesFromState(checkedState: Record<string, Record<string, boolean>>): PermissionOverrides {
|
|
||||||
const overrides: PermissionOverrides = {};
|
|
||||||
for (const role of DISPLAY_ROLES) {
|
|
||||||
const defaults = new Set(DEFAULT_ROLE_CAPABILITIES[role.value] || []);
|
|
||||||
const grant: string[] = [];
|
|
||||||
const revoke: string[] = [];
|
|
||||||
|
|
||||||
for (const [cap, checked] of Object.entries(checkedState[role.value] || {})) {
|
|
||||||
const isDefault = defaults.has(cap);
|
|
||||||
if (checked && !isDefault) grant.push(cap);
|
|
||||||
if (!checked && isDefault) revoke.push(cap);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (grant.length > 0 || revoke.length > 0) {
|
|
||||||
overrides[role.value] = {};
|
|
||||||
if (grant.length > 0) overrides[role.value].grant = grant;
|
|
||||||
if (revoke.length > 0) overrides[role.value].revoke = revoke;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return overrides;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PermissionSettingsPage() {
|
|
||||||
const { currentOrg, setOrgSettings } = useAuthStore();
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
|
|
||||||
const existingOverrides: PermissionOverrides = useMemo(
|
|
||||||
() => currentOrg?.settings?.permissionOverrides || {},
|
|
||||||
[currentOrg?.settings?.permissionOverrides],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [checkedState, setCheckedState] = useState<Record<string, Record<string, boolean>>>(() =>
|
|
||||||
buildCheckedState(existingOverrides),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCheckedState(buildCheckedState(existingOverrides));
|
|
||||||
setLoaded(true);
|
|
||||||
}, [existingOverrides]);
|
|
||||||
|
|
||||||
const currentOverrides = useMemo(() => buildOverridesFromState(checkedState), [checkedState]);
|
|
||||||
const hasChanges = JSON.stringify(currentOverrides) !== JSON.stringify(existingOverrides);
|
|
||||||
|
|
||||||
const toggleCapability = (role: string, cap: string) => {
|
|
||||||
setCheckedState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[role]: {
|
|
||||||
...prev[role],
|
|
||||||
[cap]: !prev[role]?.[cap],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetRole = (roleValue: string) => {
|
|
||||||
const defaults = new Set(DEFAULT_ROLE_CAPABILITIES[roleValue] || []);
|
|
||||||
const newRoleState: Record<string, boolean> = {};
|
|
||||||
for (const area of CAPABILITY_AREAS) {
|
|
||||||
for (const cap of area.capabilities) {
|
|
||||||
newRoleState[cap.key] = defaults.has(cap.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setCheckedState((prev) => ({ ...prev, [roleValue]: newRoleState }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const overrides = buildOverridesFromState(checkedState);
|
|
||||||
const res = await api.patch('/organizations/settings', { permissionOverrides: overrides });
|
|
||||||
setOrgSettings(res.data);
|
|
||||||
notifications.show({ title: 'Saved', message: 'Permission settings updated. Members will see changes on next login or page refresh.', color: 'green' });
|
|
||||||
} catch (err: any) {
|
|
||||||
notifications.show({ title: 'Error', message: err.response?.data?.message || 'Failed to save', color: 'red' });
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isOverridden = (role: string, cap: string) => {
|
|
||||||
const isDefault = (DEFAULT_ROLE_CAPABILITIES[role] || []).includes(cap);
|
|
||||||
const isChecked = checkedState[role]?.[cap] ?? false;
|
|
||||||
return isChecked !== isDefault;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!loaded) {
|
|
||||||
return <Center mt="xl"><Loader /></Center>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
<Group justify="space-between" align="center">
|
|
||||||
<Group gap="xs">
|
|
||||||
<IconShieldCheck size={28} />
|
|
||||||
<Title order={2}>Role Permissions</Title>
|
|
||||||
</Group>
|
|
||||||
<Group>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftSection={<IconRefresh size={16} />}
|
|
||||||
onClick={() => setCheckedState(buildCheckedState(existingOverrides))}
|
|
||||||
disabled={!hasChanges}
|
|
||||||
>
|
|
||||||
Discard Changes
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
loading={saving}
|
|
||||||
disabled={!hasChanges}
|
|
||||||
>
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
|
|
||||||
Customize which capabilities each role has in your organization.
|
|
||||||
Highlighted cells differ from the system defaults. Use "Reset" to revert a role to defaults.
|
|
||||||
The <strong>Viewer</strong> role is always read-only regardless of settings.
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Card withBorder p={0} style={{ overflow: 'auto' }}>
|
|
||||||
<Table striped highlightOnHover withColumnBorders style={{ minWidth: 900 }}>
|
|
||||||
<Table.Thead>
|
|
||||||
<Table.Tr>
|
|
||||||
<Table.Th style={{ position: 'sticky', left: 0, background: 'var(--mantine-color-body)', zIndex: 1, minWidth: 200 }}>
|
|
||||||
Capability
|
|
||||||
</Table.Th>
|
|
||||||
{DISPLAY_ROLES.map((role) => (
|
|
||||||
<Table.Th key={role.value} style={{ textAlign: 'center', minWidth: 110 }}>
|
|
||||||
<Stack gap={4} align="center">
|
|
||||||
<Text size="xs" fw={600}>{role.label}</Text>
|
|
||||||
<Tooltip label={`Reset ${role.label} to defaults`}>
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
size="compact-xs"
|
|
||||||
onClick={() => resetRole(role.value)}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
</Table.Th>
|
|
||||||
))}
|
|
||||||
</Table.Tr>
|
|
||||||
</Table.Thead>
|
|
||||||
<Table.Tbody>
|
|
||||||
{CAPABILITY_AREAS.map((area) => (
|
|
||||||
<>
|
|
||||||
<Table.Tr key={`area-${area.label}`}>
|
|
||||||
<Table.Td
|
|
||||||
colSpan={DISPLAY_ROLES.length + 1}
|
|
||||||
style={{ background: 'var(--mantine-color-gray-1)', fontWeight: 700 }}
|
|
||||||
>
|
|
||||||
<Text size="sm" fw={700} tt="uppercase">{area.label}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
</Table.Tr>
|
|
||||||
{area.capabilities.map((cap) => (
|
|
||||||
<Table.Tr key={cap.key}>
|
|
||||||
<Table.Td style={{ position: 'sticky', left: 0, background: 'var(--mantine-color-body)', zIndex: 1 }}>
|
|
||||||
<Text size="sm">{cap.label}</Text>
|
|
||||||
</Table.Td>
|
|
||||||
{DISPLAY_ROLES.map((role) => {
|
|
||||||
const checked = checkedState[role.value]?.[cap.key] ?? false;
|
|
||||||
const overridden = isOverridden(role.value, cap.key);
|
|
||||||
return (
|
|
||||||
<Table.Td
|
|
||||||
key={role.value}
|
|
||||||
style={{
|
|
||||||
textAlign: 'center',
|
|
||||||
background: overridden ? 'var(--mantine-color-yellow-0)' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => toggleCapability(role.value, cap.key)}
|
|
||||||
styles={{ input: { cursor: 'pointer' } }}
|
|
||||||
/>
|
|
||||||
</Table.Td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Table.Tr>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
))}
|
|
||||||
</Table.Tbody>
|
|
||||||
</Table>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{hasChanges && (
|
|
||||||
<Alert color="yellow" variant="light">
|
|
||||||
You have unsaved changes. Click "Save Changes" to apply.
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -237,7 +237,7 @@ export function SettingsPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Version</Text>
|
<Text size="sm" c="dimmed">Version</Text>
|
||||||
<Badge variant="light">2026.4.6</Badge>
|
<Badge variant="light">2026.03.18</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">API</Text>
|
<Text size="sm" c="dimmed">API</Text>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from
|
|||||||
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface JournalEntryLine {
|
interface JournalEntryLine {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -49,7 +49,7 @@ export function TransactionsPage() {
|
|||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
const [viewId, setViewId] = useState<string | null>(null);
|
const [viewId, setViewId] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.TRANSACTIONS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
|
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
|
||||||
queryKey: ['journal-entries'],
|
queryKey: ['journal-entries'],
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle, IconUpload,
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
|
|
||||||
interface Unit {
|
interface Unit {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -43,7 +43,7 @@ export function UnitsPage() {
|
|||||||
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<Unit | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.ASSESSMENTS_UNITS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
const { data: units = [], isLoading } = useQuery<Unit[]>({
|
||||||
queryKey: ['units'],
|
queryKey: ['units'],
|
||||||
|
|||||||
4
frontend/src/pages/vendors/VendorsPage.tsx
vendored
4
frontend/src/pages/vendors/VendorsPage.tsx
vendored
@@ -10,7 +10,7 @@ import { notifications } from '@mantine/notifications';
|
|||||||
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload, IconUsers, IconBulb, IconRocket } from '@tabler/icons-react';
|
import { IconPlus, IconEdit, IconSearch, IconUpload, IconDownload, IconUsers, IconBulb, IconRocket } from '@tabler/icons-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import api from '../../services/api';
|
import api from '../../services/api';
|
||||||
import { useCanEdit, CAPABILITIES } from '../../permissions';
|
import { useIsReadOnly } from '../../stores/authStore';
|
||||||
import { parseCSV, downloadBlob } from '../../utils/csv';
|
import { parseCSV, downloadBlob } from '../../utils/csv';
|
||||||
|
|
||||||
interface Vendor {
|
interface Vendor {
|
||||||
@@ -26,7 +26,7 @@ export function VendorsPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isReadOnly = !useCanEdit(CAPABILITIES.REFERENCE_VENDORS_EDIT);
|
const isReadOnly = useIsReadOnly();
|
||||||
|
|
||||||
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
|
||||||
queryKey: ['vendors'],
|
queryKey: ['vendors'],
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { ReactNode } from 'react';
|
|
||||||
import { useHasCapability, useHasAnyCapability } from './useCapability';
|
|
||||||
|
|
||||||
interface CapabilityGateProps {
|
|
||||||
/** Single capability required */
|
|
||||||
capability?: string;
|
|
||||||
/** Multiple capabilities — user needs at least one */
|
|
||||||
anyOf?: string[];
|
|
||||||
/** Content shown when user has the capability */
|
|
||||||
children: ReactNode;
|
|
||||||
/** Optional fallback shown when user lacks the capability */
|
|
||||||
fallback?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CapabilityGate({ capability, anyOf, children, fallback = null }: CapabilityGateProps) {
|
|
||||||
const hasSingle = useHasCapability(capability || '');
|
|
||||||
const hasAny = useHasAnyCapability(...(anyOf || []));
|
|
||||||
|
|
||||||
const allowed = capability ? hasSingle : anyOf ? hasAny : true;
|
|
||||||
|
|
||||||
return allowed ? <>{children}</> : <>{fallback}</>;
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
/**
|
|
||||||
* Capability taxonomy for the HOA Financial Platform.
|
|
||||||
*
|
|
||||||
* This file mirrors backend/src/common/permissions/capabilities.ts.
|
|
||||||
* Keep both files in sync when adding new capabilities.
|
|
||||||
*/
|
|
||||||
export const CAPABILITIES = {
|
|
||||||
DASHBOARD_VIEW: 'dashboard.view',
|
|
||||||
|
|
||||||
FINANCIALS_ACCOUNTS_VIEW: 'financials.accounts.view',
|
|
||||||
FINANCIALS_ACCOUNTS_EDIT: 'financials.accounts.edit',
|
|
||||||
FINANCIALS_CASHFLOW_VIEW: 'financials.cashflow.view',
|
|
||||||
FINANCIALS_CASHFLOW_EDIT: 'financials.cashflow.edit',
|
|
||||||
FINANCIALS_ACTUALS_VIEW: 'financials.actuals.view',
|
|
||||||
FINANCIALS_ACTUALS_EDIT: 'financials.actuals.edit',
|
|
||||||
FINANCIALS_BUDGETS_VIEW: 'financials.budgets.view',
|
|
||||||
FINANCIALS_BUDGETS_EDIT: 'financials.budgets.edit',
|
|
||||||
FINANCIALS_BUDGETS_APPROVE: 'financials.budgets.approve',
|
|
||||||
|
|
||||||
ASSESSMENTS_UNITS_VIEW: 'assessments.units.view',
|
|
||||||
ASSESSMENTS_UNITS_EDIT: 'assessments.units.edit',
|
|
||||||
ASSESSMENTS_GROUPS_VIEW: 'assessments.groups.view',
|
|
||||||
ASSESSMENTS_GROUPS_EDIT: 'assessments.groups.edit',
|
|
||||||
|
|
||||||
PLANNING_BUDGETS_VIEW: 'planning.budgets.view',
|
|
||||||
PLANNING_BUDGETS_EDIT: 'planning.budgets.edit',
|
|
||||||
PLANNING_PROJECTS_VIEW: 'planning.projects.view',
|
|
||||||
PLANNING_PROJECTS_EDIT: 'planning.projects.edit',
|
|
||||||
PLANNING_SCENARIOS_VIEW: 'planning.scenarios.view',
|
|
||||||
PLANNING_SCENARIOS_EDIT: 'planning.scenarios.edit',
|
|
||||||
PLANNING_SCENARIOS_APPROVE: 'planning.scenarios.approve',
|
|
||||||
PLANNING_INVESTMENTS_VIEW: 'planning.investments.view',
|
|
||||||
PLANNING_INVESTMENTS_EDIT: 'planning.investments.edit',
|
|
||||||
|
|
||||||
REFERENCE_VENDORS_VIEW: 'reference.vendors.view',
|
|
||||||
REFERENCE_VENDORS_EDIT: 'reference.vendors.edit',
|
|
||||||
|
|
||||||
TRANSACTIONS_VIEW: 'transactions.view',
|
|
||||||
TRANSACTIONS_EDIT: 'transactions.edit',
|
|
||||||
TRANSACTIONS_APPROVE: 'transactions.approve',
|
|
||||||
|
|
||||||
REPORTS_VIEW: 'reports.view',
|
|
||||||
|
|
||||||
SETTINGS_ORG_VIEW: 'settings.org.view',
|
|
||||||
SETTINGS_ORG_EDIT: 'settings.org.edit',
|
|
||||||
SETTINGS_MEMBERS_VIEW: 'settings.members.view',
|
|
||||||
SETTINGS_MEMBERS_MANAGE: 'settings.members.manage',
|
|
||||||
SETTINGS_PERMISSIONS_MANAGE: 'settings.permissions.manage',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
|
|
||||||
|
|
||||||
export const ALL_CAPABILITIES = new Set<string>(Object.values(CAPABILITIES));
|
|
||||||
|
|
||||||
/** Human-readable labels for capability areas (for admin UI) */
|
|
||||||
export const CAPABILITY_AREAS: { label: string; capabilities: { key: string; label: string }[] }[] = [
|
|
||||||
{
|
|
||||||
label: 'Dashboard',
|
|
||||||
capabilities: [
|
|
||||||
{ key: CAPABILITIES.DASHBOARD_VIEW, label: 'View Dashboard' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Financials',
|
|
||||||
capabilities: [
|
|
||||||
{ key: CAPABILITIES.FINANCIALS_ACCOUNTS_VIEW, label: 'View Accounts' },
|
|
||||||
{ key: CAPABILITIES.FINANCIALS_ACCOUNTS_EDIT, label: 'Edit Accounts' },
|
|
||||||
{ key: CAPABILITIES.FINANCIALS_CASHFLOW_VIEW, label: 'View Cash Flow' },
|
|
||||||
{ key: CAPABILITIES.FINANCIALS_CASHFLOW_EDIT, label: 'Edit Cash Flow' },
|
|
||||||
{ key: CAPABILITIES.FINANCIALS_ACTUALS_VIEW, label: 'View Monthly Actuals' },
|
|
||||||
{ key: CAPABILITIES.FINANCIALS_ACTUALS_EDIT, label: 'Edit Monthly Actuals' },
|
|
||||||
{ key: CAPABILITIES.FINANCIALS_BUDGETS_VIEW, label: 'View Budgets' },
|
|
||||||
{ key: CAPABILITIES.FINANCIALS_BUDGETS_EDIT, label: 'Edit Budgets' },
|
|
||||||
{ key: CAPABILITIES.FINANCIALS_BUDGETS_APPROVE, label: 'Approve Budgets' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Assessments',
|
|
||||||
capabilities: [
|
|
||||||
{ key: CAPABILITIES.ASSESSMENTS_UNITS_VIEW, label: 'View Units' },
|
|
||||||
{ key: CAPABILITIES.ASSESSMENTS_UNITS_EDIT, label: 'Edit Units' },
|
|
||||||
{ key: CAPABILITIES.ASSESSMENTS_GROUPS_VIEW, label: 'View Assessment Groups' },
|
|
||||||
{ key: CAPABILITIES.ASSESSMENTS_GROUPS_EDIT, label: 'Edit Assessment Groups' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Board Planning',
|
|
||||||
capabilities: [
|
|
||||||
{ key: CAPABILITIES.PLANNING_BUDGETS_VIEW, label: 'View Budget Planning' },
|
|
||||||
{ key: CAPABILITIES.PLANNING_BUDGETS_EDIT, label: 'Edit Budget Planning' },
|
|
||||||
{ key: CAPABILITIES.PLANNING_PROJECTS_VIEW, label: 'View Projects' },
|
|
||||||
{ key: CAPABILITIES.PLANNING_PROJECTS_EDIT, label: 'Edit Projects' },
|
|
||||||
{ key: CAPABILITIES.PLANNING_SCENARIOS_VIEW, label: 'View Scenarios' },
|
|
||||||
{ key: CAPABILITIES.PLANNING_SCENARIOS_EDIT, label: 'Edit Scenarios' },
|
|
||||||
{ key: CAPABILITIES.PLANNING_SCENARIOS_APPROVE, label: 'Approve Scenarios' },
|
|
||||||
{ key: CAPABILITIES.PLANNING_INVESTMENTS_VIEW, label: 'View Investments' },
|
|
||||||
{ key: CAPABILITIES.PLANNING_INVESTMENTS_EDIT, label: 'Edit Investments' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Board Reference',
|
|
||||||
capabilities: [
|
|
||||||
{ key: CAPABILITIES.REFERENCE_VENDORS_VIEW, label: 'View Vendors' },
|
|
||||||
{ key: CAPABILITIES.REFERENCE_VENDORS_EDIT, label: 'Edit Vendors' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Transactions',
|
|
||||||
capabilities: [
|
|
||||||
{ key: CAPABILITIES.TRANSACTIONS_VIEW, label: 'View Transactions' },
|
|
||||||
{ key: CAPABILITIES.TRANSACTIONS_EDIT, label: 'Edit Transactions' },
|
|
||||||
{ key: CAPABILITIES.TRANSACTIONS_APPROVE, label: 'Approve Transactions' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Reports',
|
|
||||||
capabilities: [
|
|
||||||
{ key: CAPABILITIES.REPORTS_VIEW, label: 'View Reports' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Administration',
|
|
||||||
capabilities: [
|
|
||||||
{ key: CAPABILITIES.SETTINGS_ORG_VIEW, label: 'View Org Settings' },
|
|
||||||
{ key: CAPABILITIES.SETTINGS_ORG_EDIT, label: 'Edit Org Settings' },
|
|
||||||
{ key: CAPABILITIES.SETTINGS_MEMBERS_VIEW, label: 'View Members' },
|
|
||||||
{ key: CAPABILITIES.SETTINGS_MEMBERS_MANAGE, label: 'Manage Members' },
|
|
||||||
{ key: CAPABILITIES.SETTINGS_PERMISSIONS_MANAGE, label: 'Manage Permissions' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { CAPABILITIES } from './capabilities';
|
|
||||||
|
|
||||||
const C = CAPABILITIES;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default capability sets per role.
|
|
||||||
*
|
|
||||||
* Mirrors backend/src/common/permissions/default-role-capabilities.ts.
|
|
||||||
* Keep both files in sync.
|
|
||||||
*/
|
|
||||||
export const DEFAULT_ROLE_CAPABILITIES: Record<string, readonly string[]> = {
|
|
||||||
president: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
|
||||||
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
|
||||||
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
|
||||||
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
|
|
||||||
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
|
||||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
|
||||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
|
|
||||||
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
|
|
||||||
C.SETTINGS_PERMISSIONS_MANAGE,
|
|
||||||
],
|
|
||||||
|
|
||||||
admin: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
|
||||||
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
|
||||||
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
|
||||||
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
|
|
||||||
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
|
||||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
|
||||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
|
|
||||||
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
|
|
||||||
C.SETTINGS_PERMISSIONS_MANAGE,
|
|
||||||
],
|
|
||||||
|
|
||||||
vice_president: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW,
|
|
||||||
C.PLANNING_BUDGETS_VIEW,
|
|
||||||
C.PLANNING_PROJECTS_VIEW,
|
|
||||||
C.PLANNING_SCENARIOS_VIEW,
|
|
||||||
C.PLANNING_INVESTMENTS_VIEW,
|
|
||||||
C.REFERENCE_VENDORS_VIEW,
|
|
||||||
C.TRANSACTIONS_VIEW,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
C.SETTINGS_ORG_VIEW,
|
|
||||||
C.SETTINGS_MEMBERS_VIEW,
|
|
||||||
],
|
|
||||||
|
|
||||||
treasurer: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
|
|
||||||
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
|
|
||||||
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
|
|
||||||
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT,
|
|
||||||
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
|
|
||||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
|
||||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
C.SETTINGS_MEMBERS_VIEW,
|
|
||||||
],
|
|
||||||
|
|
||||||
secretary: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW,
|
|
||||||
C.PLANNING_BUDGETS_VIEW,
|
|
||||||
C.PLANNING_PROJECTS_VIEW,
|
|
||||||
C.PLANNING_SCENARIOS_VIEW,
|
|
||||||
C.PLANNING_INVESTMENTS_VIEW,
|
|
||||||
C.REFERENCE_VENDORS_VIEW,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
],
|
|
||||||
|
|
||||||
member_at_large: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW,
|
|
||||||
C.PLANNING_BUDGETS_VIEW,
|
|
||||||
C.PLANNING_PROJECTS_VIEW,
|
|
||||||
C.PLANNING_SCENARIOS_VIEW,
|
|
||||||
C.PLANNING_INVESTMENTS_VIEW,
|
|
||||||
C.REFERENCE_VENDORS_VIEW,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
],
|
|
||||||
|
|
||||||
manager: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW,
|
|
||||||
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
|
|
||||||
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
],
|
|
||||||
|
|
||||||
homeowner: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
],
|
|
||||||
|
|
||||||
viewer: [
|
|
||||||
C.DASHBOARD_VIEW,
|
|
||||||
C.FINANCIALS_ACCOUNTS_VIEW,
|
|
||||||
C.FINANCIALS_CASHFLOW_VIEW,
|
|
||||||
C.FINANCIALS_ACTUALS_VIEW,
|
|
||||||
C.FINANCIALS_BUDGETS_VIEW,
|
|
||||||
C.ASSESSMENTS_UNITS_VIEW,
|
|
||||||
C.ASSESSMENTS_GROUPS_VIEW,
|
|
||||||
C.PLANNING_BUDGETS_VIEW,
|
|
||||||
C.PLANNING_PROJECTS_VIEW,
|
|
||||||
C.PLANNING_SCENARIOS_VIEW,
|
|
||||||
C.PLANNING_INVESTMENTS_VIEW,
|
|
||||||
C.REFERENCE_VENDORS_VIEW,
|
|
||||||
C.TRANSACTIONS_VIEW,
|
|
||||||
C.REPORTS_VIEW,
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export { CAPABILITIES, ALL_CAPABILITIES, CAPABILITY_AREAS } from './capabilities';
|
|
||||||
export type { Capability } from './capabilities';
|
|
||||||
export { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
|
||||||
export { resolveCapabilities } from './resolve-permissions';
|
|
||||||
export type { PermissionOverrides } from './resolve-permissions';
|
|
||||||
export { useHasCapability, useHasAnyCapability, useHasAllCapabilities, useCanEdit } from './useCapability';
|
|
||||||
export { CapabilityGate } from './CapabilityGate';
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { ALL_CAPABILITIES } from './capabilities';
|
|
||||||
import { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
|
|
||||||
|
|
||||||
export interface PermissionOverrides {
|
|
||||||
[role: string]: {
|
|
||||||
grant?: string[];
|
|
||||||
revoke?: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve effective capabilities for a role, applying tenant overrides.
|
|
||||||
*
|
|
||||||
* Mirrors backend/src/common/permissions/resolve-permissions.ts.
|
|
||||||
*/
|
|
||||||
export function resolveCapabilities(
|
|
||||||
role: string,
|
|
||||||
overrides?: PermissionOverrides | null,
|
|
||||||
): Set<string> {
|
|
||||||
const defaults = DEFAULT_ROLE_CAPABILITIES[role] || [];
|
|
||||||
const result = new Set<string>(defaults);
|
|
||||||
|
|
||||||
if (overrides && overrides[role]) {
|
|
||||||
const roleOverride = overrides[role];
|
|
||||||
|
|
||||||
if (roleOverride.grant) {
|
|
||||||
for (const cap of roleOverride.grant) {
|
|
||||||
if (ALL_CAPABILITIES.has(cap)) {
|
|
||||||
result.add(cap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roleOverride.revoke) {
|
|
||||||
for (const cap of roleOverride.revoke) {
|
|
||||||
result.delete(cap);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { useAuthStore } from '../stores/authStore';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user has a specific capability.
|
|
||||||
* Superadmins always return true.
|
|
||||||
*/
|
|
||||||
export function useHasCapability(capability: string): boolean {
|
|
||||||
const user = useAuthStore((s) => s.user);
|
|
||||||
const capabilities = useAuthStore((s) => s.currentOrg?.capabilities);
|
|
||||||
if (user?.isSuperadmin) return true;
|
|
||||||
return capabilities?.includes(capability) ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user has ANY of the given capabilities.
|
|
||||||
* Superadmins always return true.
|
|
||||||
*/
|
|
||||||
export function useHasAnyCapability(...caps: string[]): boolean {
|
|
||||||
const user = useAuthStore((s) => s.user);
|
|
||||||
const capabilities = useAuthStore((s) => s.currentOrg?.capabilities);
|
|
||||||
if (user?.isSuperadmin) return true;
|
|
||||||
if (!capabilities) return false;
|
|
||||||
return caps.some((c) => capabilities.includes(c));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user has ALL of the given capabilities.
|
|
||||||
* Superadmins always return true.
|
|
||||||
*/
|
|
||||||
export function useHasAllCapabilities(...caps: string[]): boolean {
|
|
||||||
const user = useAuthStore((s) => s.user);
|
|
||||||
const capabilities = useAuthStore((s) => s.currentOrg?.capabilities);
|
|
||||||
if (user?.isSuperadmin) return true;
|
|
||||||
if (!capabilities) return false;
|
|
||||||
return caps.every((c) => capabilities.includes(c));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a specific capability string matches the user's capability for edit actions.
|
|
||||||
* This replaces the old useIsReadOnly() for more granular checks.
|
|
||||||
*/
|
|
||||||
export function useCanEdit(editCapability: string): boolean {
|
|
||||||
return useHasCapability(editCapability);
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ interface Organization {
|
|||||||
status?: string;
|
status?: string;
|
||||||
planLevel?: string;
|
planLevel?: string;
|
||||||
settings?: Record<string, any>;
|
settings?: Record<string, any>;
|
||||||
capabilities?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -120,7 +119,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ledgeriq-auth',
|
name: 'ledgeriq-auth',
|
||||||
version: 6,
|
version: 5,
|
||||||
migrate: () => ({
|
migrate: () => ({
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
{
|
|
||||||
"_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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
-- =============================================================================
|
|
||||||
-- 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;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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,189 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,377 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,6 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|||||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
BACKUP_DIR="$PROJECT_DIR/backups"
|
BACKUP_DIR="$PROJECT_DIR/backups"
|
||||||
KEEP_DAYS=0 # 0 = keep forever
|
KEEP_DAYS=0 # 0 = keep forever
|
||||||
FORCE_YES=false # skip interactive confirmations (for automation)
|
|
||||||
DB_USER="${POSTGRES_USER:-hoafinance}"
|
DB_USER="${POSTGRES_USER:-hoafinance}"
|
||||||
DB_NAME="${POSTGRES_DB:-hoafinance}"
|
DB_NAME="${POSTGRES_DB:-hoafinance}"
|
||||||
COMPOSE_CMD="docker compose"
|
COMPOSE_CMD="docker compose"
|
||||||
@@ -52,9 +51,9 @@ ensure_postgres_running() {
|
|||||||
|
|
||||||
format_size() {
|
format_size() {
|
||||||
local bytes=$1
|
local bytes=$1
|
||||||
if (( bytes >= 1073741824 )); then printf "%d.%d GB" $((bytes / 1073741824)) $(( (bytes % 1073741824) * 10 / 1073741824 ))
|
if (( bytes >= 1073741824 )); then printf "%.1f GB" "$(echo "$bytes / 1073741824" | bc -l)"
|
||||||
elif (( bytes >= 1048576 )); then printf "%d.%d MB" $((bytes / 1048576)) $(( (bytes % 1048576) * 10 / 1048576 ))
|
elif (( bytes >= 1048576 )); then printf "%.1f MB" "$(echo "$bytes / 1048576" | bc -l)"
|
||||||
elif (( bytes >= 1024 )); then printf "%d.%d KB" $((bytes / 1024)) $(( (bytes % 1024) * 10 / 1024 ))
|
elif (( bytes >= 1024 )); then printf "%.1f KB" "$(echo "$bytes / 1024" | bc -l)"
|
||||||
else printf "%d B" "$bytes"
|
else printf "%d B" "$bytes"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -122,12 +121,8 @@ do_restore() {
|
|||||||
warn "This will DESTROY the current '${DB_NAME}' database and replace it"
|
warn "This will DESTROY the current '${DB_NAME}' database and replace it"
|
||||||
warn "with the contents of: $(basename "$file")"
|
warn "with the contents of: $(basename "$file")"
|
||||||
echo ""
|
echo ""
|
||||||
if [ "$FORCE_YES" = true ]; then
|
read -rp "Type 'yes' to continue: " confirm
|
||||||
info "Skipping confirmation (--yes flag set)"
|
[ "$confirm" = "yes" ] || { info "Aborted."; exit 0; }
|
||||||
else
|
|
||||||
read -rp "Type 'yes' to continue: " confirm
|
|
||||||
[ "$confirm" = "yes" ] || { info "Aborted."; exit 0; }
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
info "Step 1/4 — Terminating active connections ..."
|
info "Step 1/4 — Terminating active connections ..."
|
||||||
@@ -234,7 +229,6 @@ Usage:
|
|||||||
Options:
|
Options:
|
||||||
--dir DIR Backup directory (default: ./backups)
|
--dir DIR Backup directory (default: ./backups)
|
||||||
--keep DAYS Auto-delete backups older than DAYS (default: keep all)
|
--keep DAYS Auto-delete backups older than DAYS (default: keep all)
|
||||||
--yes, -y Skip interactive confirmation prompts (for automation)
|
|
||||||
|
|
||||||
Supported restore formats:
|
Supported restore formats:
|
||||||
.dump.gz Custom-format pg_dump, gzipped (default backup format)
|
.dump.gz Custom-format pg_dump, gzipped (default backup format)
|
||||||
@@ -261,7 +255,6 @@ while [ $# -gt 0 ]; do
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
--dir) BACKUP_DIR="$2"; shift 2 ;;
|
--dir) BACKUP_DIR="$2"; shift 2 ;;
|
||||||
--keep) KEEP_DAYS="$2"; shift 2 ;;
|
--keep) KEEP_DAYS="$2"; shift 2 ;;
|
||||||
--yes|-y) FORCE_YES=true; shift ;;
|
|
||||||
--help) usage ;;
|
--help) usage ;;
|
||||||
*)
|
*)
|
||||||
if [ "$COMMAND" = "restore" ] && [ -z "$RESTORE_FILE" ]; then
|
if [ "$COMMAND" = "restore" ] && [ -z "$RESTORE_FILE" ]; then
|
||||||
|
|||||||
@@ -1,434 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# deploy-prod.sh — Production deployment script for HOA LedgerIQ
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./scripts/deploy-prod.sh [--seed-existing]
|
|
||||||
#
|
|
||||||
# This script performs a full production deployment:
|
|
||||||
# 1. Takes a pre-upgrade database backup
|
|
||||||
# 2. Pulls the latest code from the main branch
|
|
||||||
# 3. Rebuilds and restarts Docker containers
|
|
||||||
# 4. Runs any pending database migrations (tracked in shared.schema_migrations)
|
|
||||||
# 5. Verifies the application is healthy
|
|
||||||
# 6. Takes a post-upgrade database backup
|
|
||||||
#
|
|
||||||
# On failure (migration error or health check), the script automatically:
|
|
||||||
# - Restores the pre-upgrade database backup
|
|
||||||
# - Reverts the code to the previous commit
|
|
||||||
# - Rebuilds containers from the reverted code
|
|
||||||
#
|
|
||||||
# Flags:
|
|
||||||
# --seed-existing Mark all existing migration files as applied without
|
|
||||||
# executing them. Use this ONLY on the first deployment
|
|
||||||
# against an existing database where migrations were
|
|
||||||
# previously applied manually.
|
|
||||||
#
|
|
||||||
# Environment:
|
|
||||||
# PROJECT_DIR Override the project directory (default: /opt/hoa-ledgeriq)
|
|
||||||
# POSTGRES_USER Database user (default: hoafinance)
|
|
||||||
# POSTGRES_DB Database name (default: hoafinance)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ---- Defaults ----
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
PROJECT_DIR="${PROJECT_DIR:-/opt/hoa-ledgeriq}"
|
|
||||||
COMPOSE_CMD="docker compose -f $PROJECT_DIR/docker-compose.yml -f $PROJECT_DIR/docker-compose.prod.yml"
|
|
||||||
DB_USER="${POSTGRES_USER:-hoafinance}"
|
|
||||||
DB_NAME="${POSTGRES_DB:-hoafinance}"
|
|
||||||
MIGRATION_DIR="$PROJECT_DIR/db/migrations"
|
|
||||||
HEALTH_URL="http://localhost:3000/api"
|
|
||||||
HEALTH_RETRIES=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 ""
|
|
||||||
Reference in New Issue
Block a user