Compare commits
8 Commits
5ae6f672be
...
4797669591
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4797669591 | ||
| 629d112850 | |||
| 32506d6a2e | |||
| 9a60970837 | |||
| 1ade446187 | |||
|
|
d430b96b51 | ||
|
|
140cd7acb7 | ||
| 06bc0181f8 |
229
CLAUDE.md
Normal file
229
CLAUDE.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# CLAUDE.md – HOA Financial Platform (HOALedgerIQ)
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Multi-tenant SaaS platform for HOA (Homeowners Association) financial management. Handles chart of accounts, journal entries, budgets, invoices, payments, reserve planning, and board scenario planning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack & Framework
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
| --------- | --------------------------------------------------- |
|
||||||
|
| Backend | **NestJS 10** (TypeScript), runs on port 3000 |
|
||||||
|
| Frontend | **React 18** + Vite 5 + Mantine UI + Zustand |
|
||||||
|
| Database | **PostgreSQL** via **TypeORM 0.3** |
|
||||||
|
| Cache | **Redis** (BullMQ for queues) |
|
||||||
|
| Auth | **Passport.js** – JWT access + httpOnly refresh |
|
||||||
|
| Payments | **Stripe** (checkout, subscriptions, webhooks) |
|
||||||
|
| Email | **Resend** |
|
||||||
|
| AI | NVIDIA API (Qwen model) for investment advisor |
|
||||||
|
| Monitoring| **New Relic** APM (app name: `HOALedgerIQ_App`) |
|
||||||
|
| Infra | Docker Compose (dev + prod), Nginx reverse proxy |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth Pattern
|
||||||
|
|
||||||
|
- **Access token**: JWT, 1-hour TTL, payload `{ sub, email, orgId, role, isSuperadmin }`
|
||||||
|
- **Refresh token**: 64-byte random, SHA256-hashed in DB, 30-day TTL, sent as httpOnly cookie `ledgeriq_rt`
|
||||||
|
- **MFA**: TOTP via `otplib`, challenge token (5-min TTL), recovery codes
|
||||||
|
- **Passkeys**: WebAuthn via `@simplewebauthn/server`
|
||||||
|
- **SSO**: Google OAuth 2.0, Azure AD
|
||||||
|
- **Password hashing**: bcryptjs, cost 12
|
||||||
|
- **Rate limiting**: 100 req/min global (Throttler), custom per endpoint
|
||||||
|
|
||||||
|
### Guards & Middleware
|
||||||
|
|
||||||
|
- `TenantMiddleware` – extracts `orgId` from JWT, sets tenant schema (60s cache)
|
||||||
|
- `JwtAuthGuard` – Passport JWT guard on all protected routes
|
||||||
|
- `WriteAccessGuard` – blocks write ops for `viewer` role and `past_due` orgs
|
||||||
|
- `@AllowViewer()` decorator – exempts read endpoints from WriteAccessGuard
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
`president`, `treasurer`, `secretary`, `member_at_large`, `manager`, `homeowner`, `admin`, `viewer`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-Tenant Architecture
|
||||||
|
|
||||||
|
- **Shared schema** (`shared`): users, organizations, user_organizations, refresh_tokens, invite_tokens, login_history, cd_rates
|
||||||
|
- **Tenant schemas** (dynamic, per org): accounts, journal_entries, budgets, invoices, payments, units, vendors, etc.
|
||||||
|
- Schema name stored in `shared.organizations.schema_name`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route Map (180+ endpoints)
|
||||||
|
|
||||||
|
### Auth (`/api/auth`)
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| ------ | ----------------------- | -------------------------------- |
|
||||||
|
| POST | /login | Email/password login |
|
||||||
|
| POST | /refresh | Refresh access token (cookie) |
|
||||||
|
| POST | /logout | Revoke refresh token |
|
||||||
|
| POST | /logout-everywhere | Revoke all sessions |
|
||||||
|
| GET | /profile | Current user profile |
|
||||||
|
| POST | /register | Register (disabled by default) |
|
||||||
|
| POST | /activate | Activate invited user |
|
||||||
|
| POST | /forgot-password | Request password reset |
|
||||||
|
| POST | /reset-password | Reset with token |
|
||||||
|
| PATCH | /change-password | Change password (authed) |
|
||||||
|
| POST | /switch-org | Switch active organization |
|
||||||
|
|
||||||
|
### Auth MFA (`/api/auth/mfa`)
|
||||||
|
| POST | /setup | POST /enable | POST /verify | POST /disable | GET /status |
|
||||||
|
|
||||||
|
### Auth Passkeys (`/api/auth/passkeys`)
|
||||||
|
| POST /register-options | POST /register | POST /login-options | POST /login | GET / | DELETE /:id |
|
||||||
|
|
||||||
|
### Admin (`/api/admin`) – superadmin only
|
||||||
|
| GET /metrics | GET /users | GET /organizations | PUT /organizations/:id/subscription | POST /impersonate/:userId | POST /tenants |
|
||||||
|
|
||||||
|
### Organizations (`/api/organizations`)
|
||||||
|
| POST / | GET / | PATCH /settings | GET /members | POST /members | PUT /members/:id/role | DELETE /members/:id |
|
||||||
|
|
||||||
|
### Accounts (`/api/accounts`)
|
||||||
|
| GET / | GET /trial-balance | POST / | PUT /:id | PUT /:id/set-primary | POST /bulk-opening-balances | POST /:id/opening-balance | POST /:id/adjust-balance |
|
||||||
|
|
||||||
|
### Journal Entries (`/api/journal-entries`)
|
||||||
|
| GET / | GET /:id | POST / | POST /:id/post | POST /:id/void |
|
||||||
|
|
||||||
|
### Budgets (`/api/budgets`)
|
||||||
|
| GET /:year | PUT /:year | GET /:year/vs-actual | POST /:year/import | GET /:year/template |
|
||||||
|
|
||||||
|
### Invoices (`/api/invoices`)
|
||||||
|
| GET / | GET /:id | POST /generate-preview | POST /generate-bulk | POST /apply-late-fees |
|
||||||
|
|
||||||
|
### Payments (`/api/payments`)
|
||||||
|
| GET / | GET /:id | POST / | PUT /:id | DELETE /:id |
|
||||||
|
|
||||||
|
### Units (`/api/units`)
|
||||||
|
| GET / | GET /:id | POST / | PUT /:id | DELETE /:id | GET /export | POST /import |
|
||||||
|
|
||||||
|
### Vendors (`/api/vendors`)
|
||||||
|
| GET / | GET /:id | POST / | PUT /:id | GET /export | POST /import | GET /1099-data |
|
||||||
|
|
||||||
|
### Reports (`/api/reports`)
|
||||||
|
| GET /dashboard | GET /balance-sheet | GET /income-statement | GET /cash-flow | GET /cash-flow-sankey | GET /aging | GET /year-end | GET /cash-flow-forecast | GET /quarterly |
|
||||||
|
|
||||||
|
### Board Planning (`/api/board-planning`)
|
||||||
|
Scenarios CRUD, scenario investments, scenario assessments, projections, budget plans – 28 endpoints total.
|
||||||
|
|
||||||
|
### Other Modules
|
||||||
|
- `/api/fiscal-periods` – list, close, lock
|
||||||
|
- `/api/reserve-components` – CRUD
|
||||||
|
- `/api/capital-projects` – CRUD
|
||||||
|
- `/api/projects` – CRUD + planning + import/export
|
||||||
|
- `/api/assessment-groups` – CRUD + summary + default
|
||||||
|
- `/api/monthly-actuals` – GET/POST /:year/:month
|
||||||
|
- `/api/health-scores` – latest + calculate
|
||||||
|
- `/api/investment-planning` – snapshot, market-rates, recommendations
|
||||||
|
- `/api/investment-accounts` – CRUD
|
||||||
|
- `/api/attachments` – upload, list, download, delete (10MB limit)
|
||||||
|
- `/api/onboarding` – progress get/patch
|
||||||
|
- `/api/billing` – trial, checkout, webhook, subscription, portal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
- **Connection pool**: min 5, max 30, 30s idle, 5s connect timeout
|
||||||
|
- **Migrations**: SQL files in `db/migrations/` (manual execution, no ORM runner)
|
||||||
|
- **Init script**: `db/init/00-init.sql` (shared schema DDL)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key File Paths
|
||||||
|
|
||||||
|
| Purpose | Path |
|
||||||
|
| ---------------------- | ------------------------------------------------- |
|
||||||
|
| NestJS bootstrap | `backend/src/main.ts` |
|
||||||
|
| Root module | `backend/src/app.module.ts` |
|
||||||
|
| Auth controller | `backend/src/modules/auth/auth.controller.ts` |
|
||||||
|
| Auth service | `backend/src/modules/auth/auth.service.ts` |
|
||||||
|
| Refresh token svc | `backend/src/modules/auth/refresh-token.service.ts` |
|
||||||
|
| JWT strategy | `backend/src/modules/auth/strategies/jwt.strategy.ts` |
|
||||||
|
| Tenant middleware | `backend/src/database/tenant.middleware.ts` |
|
||||||
|
| Write-access guard | `backend/src/common/guards/write-access.guard.ts` |
|
||||||
|
| DB schema init | `db/init/00-init.sql` |
|
||||||
|
| Env example | `.env.example` |
|
||||||
|
| Docker compose (dev) | `docker-compose.yml` |
|
||||||
|
| Frontend entry | `frontend/src/main.tsx` |
|
||||||
|
| Frontend pages | `frontend/src/pages/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables (critical)
|
||||||
|
|
||||||
|
```
|
||||||
|
DATABASE_URL – PostgreSQL connection string
|
||||||
|
REDIS_URL – Redis connection
|
||||||
|
JWT_SECRET – JWT signing key
|
||||||
|
INVITE_TOKEN_SECRET – Invite token signing
|
||||||
|
STRIPE_SECRET_KEY – Stripe API key
|
||||||
|
STRIPE_WEBHOOK_SECRET – Stripe webhook verification
|
||||||
|
RESEND_API_KEY – Email service
|
||||||
|
NEW_RELIC_APP_NAME – "HOALedgerIQ_App"
|
||||||
|
NEW_RELIC_LICENSE_KEY – New Relic license
|
||||||
|
APP_URL – Base URL for email links
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Relic
|
||||||
|
|
||||||
|
- **App name**: `HOALedgerIQ_App` (env: `NEW_RELIC_APP_NAME`)
|
||||||
|
- Enabled via `NEW_RELIC_ENABLED=true`
|
||||||
|
- NRQL query library: `load-tests/analysis/nrql-queries.sql`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Load Testing
|
||||||
|
|
||||||
|
### Run k6 scenarios
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Auth + Dashboard flow (staging)
|
||||||
|
k6 run --env TARGET_ENV=staging load-tests/scenarios/auth-dashboard-flow.js
|
||||||
|
|
||||||
|
# CRUD flow (staging)
|
||||||
|
k6 run --env TARGET_ENV=staging load-tests/scenarios/crud-flow.js
|
||||||
|
|
||||||
|
# Local dev
|
||||||
|
k6 run --env TARGET_ENV=local load-tests/scenarios/auth-dashboard-flow.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conventions
|
||||||
|
|
||||||
|
- Scenarios live in `load-tests/scenarios/`
|
||||||
|
- Config in `load-tests/config/environments.json` (staging/production/local thresholds)
|
||||||
|
- Test users parameterized from `load-tests/config/user-pool.csv`
|
||||||
|
- Baseline results stored in `load-tests/analysis/baseline.json`
|
||||||
|
- NRQL queries for New Relic in `load-tests/analysis/nrql-queries.sql`
|
||||||
|
- All k6 scripts use `SharedArray` for user pool, `http.batch()` for parallel requests
|
||||||
|
- Custom metrics: `*_duration` trends + `*_error_rate` rates per journey
|
||||||
|
- Thresholds: p95 latency + error rate per environment
|
||||||
|
|
||||||
|
### User Pool CSV Format
|
||||||
|
|
||||||
|
```
|
||||||
|
email,password,orgId,role
|
||||||
|
```
|
||||||
|
|
||||||
|
Roles match the app: `treasurer`, `admin`, `president`, `manager`, `member_at_large`, `viewer`, `homeowner`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fix Conventions
|
||||||
|
|
||||||
|
- Backend tests: `npm run test` (Jest, `*.spec.ts` co-located with source)
|
||||||
|
- E2E tests: `npm run test:e2e`
|
||||||
|
- Backend build: `npm run build` (NestJS CLI)
|
||||||
|
- Frontend dev: `npm run dev` (Vite, port 5173)
|
||||||
|
- Frontend build: `npm run build`
|
||||||
|
- Always run `npm run build` in `backend/` after changes to verify compilation
|
||||||
|
- TypeORM entities use decorators (`@Entity`, `@Column`, etc.)
|
||||||
|
- Multi-tenant: any new module touching tenant data must use `TenantService` to get the correct schema connection
|
||||||
|
- New endpoints need `@UseGuards(JwtAuthGuard)` and should respect `WriteAccessGuard`
|
||||||
|
- Use `@AllowViewer()` on read-only endpoints
|
||||||
@@ -33,6 +33,7 @@ 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';
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ import { ScheduleModule } from '@nestjs/schedule';
|
|||||||
BillingModule,
|
BillingModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
OnboardingModule,
|
OnboardingModule,
|
||||||
|
IdeasModule,
|
||||||
ShadowAiModule,
|
ShadowAiModule,
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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')
|
||||||
@@ -17,6 +18,7 @@ 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) {
|
||||||
@@ -196,4 +198,45 @@ 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,11 +17,13 @@ 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],
|
||||||
|
|||||||
12
backend/src/modules/ideas/dto/create-idea.dto.ts
Normal file
12
backend/src/modules/ideas/dto/create-idea.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateIdeaDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(255)
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
49
backend/src/modules/ideas/entities/idea.entity.ts
Normal file
49
backend/src/modules/ideas/entities/idea.entity.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
ManyToOne,
|
||||||
|
JoinColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Organization } from '../../organizations/entities/organization.entity';
|
||||||
|
import { User } from '../../users/entities/user.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'shared', name: 'ideas' })
|
||||||
|
export class Idea {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@Column({ name: 'org_id' })
|
||||||
|
orgId: string;
|
||||||
|
|
||||||
|
@Column({ name: 'user_id' })
|
||||||
|
userId: string;
|
||||||
|
|
||||||
|
@Column({ length: 255 })
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
@Column({ type: 'text', nullable: true })
|
||||||
|
description: string;
|
||||||
|
|
||||||
|
@Column({ length: 20, default: 'new' })
|
||||||
|
status: string;
|
||||||
|
|
||||||
|
@Column({ name: 'admin_note', type: 'text', nullable: true })
|
||||||
|
adminNote: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => Organization)
|
||||||
|
@JoinColumn({ name: 'org_id' })
|
||||||
|
organization: Organization;
|
||||||
|
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
27
backend/src/modules/ideas/ideas.controller.ts
Normal file
27
backend/src/modules/ideas/ideas.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Controller, Get, Post, Body, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { IdeasService } from './ideas.service';
|
||||||
|
import { CreateIdeaDto } from './dto/create-idea.dto';
|
||||||
|
|
||||||
|
@ApiTags('ideas')
|
||||||
|
@Controller('ideas')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class IdeasController {
|
||||||
|
constructor(private ideasService: IdeasService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(@Req() req: any, @Body() dto: CreateIdeaDto) {
|
||||||
|
const orgId = req.user.orgId;
|
||||||
|
const userId = req.user.userId || req.user.sub;
|
||||||
|
const idea = await this.ideasService.create(orgId, userId, dto);
|
||||||
|
return { success: true, idea };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async findByOrg(@Req() req: any) {
|
||||||
|
const orgId = req.user.orgId;
|
||||||
|
return this.ideasService.findByOrg(orgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend/src/modules/ideas/ideas.module.ts
Normal file
14
backend/src/modules/ideas/ideas.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { Idea } from './entities/idea.entity';
|
||||||
|
import { Organization } from '../organizations/entities/organization.entity';
|
||||||
|
import { IdeasController } from './ideas.controller';
|
||||||
|
import { IdeasService } from './ideas.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Idea, Organization])],
|
||||||
|
controllers: [IdeasController],
|
||||||
|
providers: [IdeasService],
|
||||||
|
exports: [IdeasService],
|
||||||
|
})
|
||||||
|
export class IdeasModule {}
|
||||||
89
backend/src/modules/ideas/ideas.service.ts
Normal file
89
backend/src/modules/ideas/ideas.service.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable, ForbiddenException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Idea } from './entities/idea.entity';
|
||||||
|
import { Organization } from '../organizations/entities/organization.entity';
|
||||||
|
import { CreateIdeaDto } from './dto/create-idea.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IdeasService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Idea)
|
||||||
|
private ideasRepository: Repository<Idea>,
|
||||||
|
@InjectRepository(Organization)
|
||||||
|
private orgRepository: Repository<Organization>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(orgId: string, userId: string, dto: CreateIdeaDto): Promise<Idea> {
|
||||||
|
const org = await this.orgRepository.findOne({ where: { id: orgId } });
|
||||||
|
if (!org) {
|
||||||
|
throw new NotFoundException('Organization not found');
|
||||||
|
}
|
||||||
|
if (org.settings?.ideationEnabled !== true) {
|
||||||
|
throw new ForbiddenException('Ideation is not enabled for this organization');
|
||||||
|
}
|
||||||
|
|
||||||
|
const idea = this.ideasRepository.create({
|
||||||
|
orgId,
|
||||||
|
userId,
|
||||||
|
title: dto.title,
|
||||||
|
description: dto.description,
|
||||||
|
});
|
||||||
|
return this.ideasRepository.save(idea);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByOrg(orgId: string): Promise<Idea[]> {
|
||||||
|
return this.ideasRepository.find({
|
||||||
|
where: { orgId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(): Promise<any[]> {
|
||||||
|
return this.ideasRepository
|
||||||
|
.createQueryBuilder('idea')
|
||||||
|
.leftJoin('idea.organization', 'org')
|
||||||
|
.leftJoin('idea.user', 'user')
|
||||||
|
.select([
|
||||||
|
'idea.id AS id',
|
||||||
|
'idea.title AS title',
|
||||||
|
'idea.description AS description',
|
||||||
|
'idea.status AS status',
|
||||||
|
'idea.createdAt AS "createdAt"',
|
||||||
|
'idea.adminNote AS "adminNote"',
|
||||||
|
'org.id AS "orgId"',
|
||||||
|
'org.name AS "orgName"',
|
||||||
|
'user.id AS "userId"',
|
||||||
|
'user.email AS "userEmail"',
|
||||||
|
'user.firstName AS "userFirstName"',
|
||||||
|
'user.lastName AS "userLastName"',
|
||||||
|
])
|
||||||
|
.orderBy('idea.createdAt', 'DESC')
|
||||||
|
.getRawMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(id: string, status: string): Promise<Idea> {
|
||||||
|
const validStatuses = ['new', 'reviewed', 'accepted', 'rejected'];
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
throw new BadRequestException(`Invalid status. Must be one of: ${validStatuses.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const idea = await this.ideasRepository.findOne({ where: { id } });
|
||||||
|
if (!idea) {
|
||||||
|
throw new NotFoundException('Idea not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
idea.status = status;
|
||||||
|
return this.ideasRepository.save(idea);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateNote(id: string, adminNote: string): Promise<Idea> {
|
||||||
|
const idea = await this.ideasRepository.findOne({ where: { id } });
|
||||||
|
if (!idea) {
|
||||||
|
throw new NotFoundException('Idea not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
idea.adminNote = adminNote;
|
||||||
|
return this.ideasRepository.save(idea);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
db/migrations/018-ideas.sql
Normal file
15
db/migrations/018-ideas.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- Ideation feature: shared ideas table for cross-tenant idea submissions
|
||||||
|
CREATE TABLE IF NOT EXISTS shared.ideas (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
org_id UUID NOT NULL REFERENCES shared.organizations(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES shared.users(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'new',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ideas_org_id ON shared.ideas(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ideas_status ON shared.ideas(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ideas_created_at ON shared.ideas(created_at DESC);
|
||||||
2
db/migrations/019-ideas-admin-note.sql
Normal file
2
db/migrations/019-ideas-admin-note.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add private admin note column to ideas table
|
||||||
|
ALTER TABLE shared.ideas ADD COLUMN IF NOT EXISTS admin_note TEXT;
|
||||||
@@ -29,6 +29,7 @@ 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';
|
||||||
@@ -134,6 +135,7 @@ 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>
|
||||||
|
|
||||||
|
|||||||
69
frontend/src/components/ideas/IdeaModal.tsx
Normal file
69
frontend/src/components/ideas/IdeaModal.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal, TextInput, Textarea, Button, Stack } from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface IdeaModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IdeaModal({ opened, onClose }: IdeaModalProps) {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
|
||||||
|
const submitIdea = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data } = await api.post('/ideas', { title, description });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({ message: 'Idea submitted — thank you!', color: 'green' });
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
notifications.show({
|
||||||
|
message: err.response?.data?.message || 'Failed to submit idea',
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={handleClose} title="Submit an Idea" size="md">
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
label="Title"
|
||||||
|
placeholder="Brief summary of your idea"
|
||||||
|
required
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||||
|
maxLength={255}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Description"
|
||||||
|
placeholder="Describe your idea in more detail (optional)"
|
||||||
|
minRows={4}
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => submitIdea.mutate()}
|
||||||
|
loading={submitIdea.isPending}
|
||||||
|
disabled={!title.trim()}
|
||||||
|
>
|
||||||
|
Submit Idea
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ 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';
|
||||||
@@ -18,6 +19,7 @@ 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() {
|
||||||
@@ -28,6 +30,10 @@ 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);
|
||||||
@@ -121,6 +127,13 @@ 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"
|
||||||
@@ -209,6 +222,9 @@ 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,6 +20,7 @@ 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';
|
||||||
|
|
||||||
@@ -132,6 +133,13 @@ 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} />}
|
||||||
@@ -237,6 +245,20 @@ 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>
|
||||||
|
|||||||
308
frontend/src/pages/admin/AdminIdeasPage.tsx
Normal file
308
frontend/src/pages/admin/AdminIdeasPage.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Title, Text, Card, Table, Group, Stack, Badge, Loader, Center,
|
||||||
|
Select, TextInput, Textarea, Button, Modal, SimpleGrid, ActionIcon,
|
||||||
|
Tooltip, Paper,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconBulb, IconSearch, IconNote, IconFilter,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '../../services/api';
|
||||||
|
|
||||||
|
interface AdminIdea {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
adminNote: string | null;
|
||||||
|
orgId: string;
|
||||||
|
orgName: string;
|
||||||
|
userId: string;
|
||||||
|
userEmail: string;
|
||||||
|
userFirstName: string;
|
||||||
|
userLastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor: Record<string, string> = {
|
||||||
|
new: 'blue',
|
||||||
|
reviewed: 'yellow',
|
||||||
|
accepted: 'green',
|
||||||
|
rejected: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'new', label: 'New' },
|
||||||
|
{ value: 'reviewed', label: 'Reviewed' },
|
||||||
|
{ value: 'accepted', label: 'Accepted' },
|
||||||
|
{ value: 'rejected', label: 'Rejected' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
return new Date(dateStr).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string | null | undefined): string {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminIdeasPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
|
const [selectedIdea, setSelectedIdea] = useState<AdminIdea | null>(null);
|
||||||
|
const [detailOpened, { open: openDetail, close: closeDetail }] = useDisclosure(false);
|
||||||
|
const [noteText, setNoteText] = useState('');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: ideas, isLoading } = useQuery<AdminIdea[]>({
|
||||||
|
queryKey: ['admin-ideas'],
|
||||||
|
queryFn: async () => { const { data } = await api.get('/admin/ideas'); return data; },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStatus = useMutation({
|
||||||
|
mutationFn: async ({ id, status }: { id: string; status: string }) => {
|
||||||
|
await api.put(`/admin/ideas/${id}/status`, { status });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
|
||||||
|
notifications.show({ message: 'Status updated', color: 'green' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateNote = useMutation({
|
||||||
|
mutationFn: async ({ id, adminNote }: { id: string; adminNote: string }) => {
|
||||||
|
await api.put(`/admin/ideas/${id}/note`, { adminNote });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-ideas'] });
|
||||||
|
notifications.show({ message: 'Note saved', color: 'green' });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const openIdeaDetail = (idea: AdminIdea) => {
|
||||||
|
setSelectedIdea(idea);
|
||||||
|
setNoteText(idea.adminNote || '');
|
||||||
|
openDetail();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveNote = () => {
|
||||||
|
if (selectedIdea) {
|
||||||
|
updateNote.mutate({ id: selectedIdea.id, adminNote: noteText });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = (ideas || []).filter((idea) => {
|
||||||
|
const matchesSearch = !search ||
|
||||||
|
idea.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
idea.description?.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
idea.orgName.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
idea.userEmail.toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchesStatus = !statusFilter || idea.status === statusFilter;
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
total: ideas?.length || 0,
|
||||||
|
new: ideas?.filter(i => i.status === 'new').length || 0,
|
||||||
|
reviewed: ideas?.filter(i => i.status === 'reviewed').length || 0,
|
||||||
|
accepted: ideas?.filter(i => i.status === 'accepted').length || 0,
|
||||||
|
rejected: ideas?.filter(i => i.status === 'rejected').length || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <Center h={400}><Loader /></Center>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<IconBulb size={28} />
|
||||||
|
<Title order={2}>Idea Submissions</Title>
|
||||||
|
</Group>
|
||||||
|
<Badge size="lg" variant="light">{counts.total} total</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>New</Text>
|
||||||
|
<Text size="xl" fw={700} c="blue">{counts.new}</Text>
|
||||||
|
</Paper>
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reviewed</Text>
|
||||||
|
<Text size="xl" fw={700} c="yellow">{counts.reviewed}</Text>
|
||||||
|
</Paper>
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Accepted</Text>
|
||||||
|
<Text size="xl" fw={700} c="green">{counts.accepted}</Text>
|
||||||
|
</Paper>
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Rejected</Text>
|
||||||
|
<Text size="xl" fw={700} c="red">{counts.rejected}</Text>
|
||||||
|
</Paper>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Group>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search ideas, tenants, users..."
|
||||||
|
leftSection={<IconSearch size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="All statuses"
|
||||||
|
leftSection={<IconFilter size={16} />}
|
||||||
|
data={statusOptions}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={setStatusFilter}
|
||||||
|
clearable
|
||||||
|
w={160}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Ideas table */}
|
||||||
|
<Card withBorder p={0}>
|
||||||
|
<Table striped highlightOnHover>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Date</Table.Th>
|
||||||
|
<Table.Th>Tenant</Table.Th>
|
||||||
|
<Table.Th>Submitted By</Table.Th>
|
||||||
|
<Table.Th>Title</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th w={40}></Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={6}>
|
||||||
|
<Text ta="center" c="dimmed" py="lg">
|
||||||
|
{ideas?.length === 0 ? 'No ideas submitted yet' : 'No ideas match your filters'}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
) : (
|
||||||
|
filtered.map((idea) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={idea.id}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => openIdeaDetail(idea)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs">{formatDate(idea.createdAt)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={500}>{idea.orgName}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{idea.userFirstName} {idea.userLastName}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{idea.userEmail}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={500} lineClamp={1}>{idea.title}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge size="sm" variant="light" color={statusColor[idea.status]}>
|
||||||
|
{idea.status}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{idea.adminNote && (
|
||||||
|
<Tooltip label="Has admin note">
|
||||||
|
<IconNote size={16} color="gray" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
<Modal
|
||||||
|
opened={detailOpened}
|
||||||
|
onClose={closeDetail}
|
||||||
|
title={<Text fw={600}>Idea Detail</Text>}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{selectedIdea && (
|
||||||
|
<Stack>
|
||||||
|
<Card withBorder>
|
||||||
|
<SimpleGrid cols={2} spacing="xs">
|
||||||
|
<Text size="xs" c="dimmed">Tenant</Text>
|
||||||
|
<Text size="sm" fw={500}>{selectedIdea.orgName}</Text>
|
||||||
|
<Text size="xs" c="dimmed">Submitted By</Text>
|
||||||
|
<Text size="sm">{selectedIdea.userFirstName} {selectedIdea.userLastName} ({selectedIdea.userEmail})</Text>
|
||||||
|
<Text size="xs" c="dimmed">Date</Text>
|
||||||
|
<Text size="sm">{formatDateTime(selectedIdea.createdAt)}</Text>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600} mb="xs">Title</Text>
|
||||||
|
<Text size="sm">{selectedIdea.title}</Text>
|
||||||
|
{selectedIdea.description && (
|
||||||
|
<>
|
||||||
|
<Text fw={600} mt="md" mb="xs">Description</Text>
|
||||||
|
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>{selectedIdea.description}</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Text fw={600} mb="xs">Status</Text>
|
||||||
|
<Select
|
||||||
|
data={statusOptions}
|
||||||
|
value={selectedIdea.status}
|
||||||
|
onChange={(val) => {
|
||||||
|
if (val && val !== selectedIdea.status) {
|
||||||
|
updateStatus.mutate({ id: selectedIdea.id, status: val }, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setSelectedIdea({ ...selectedIdea, status: val });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
w={200}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card withBorder>
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text fw={600}>Private Admin Note</Text>
|
||||||
|
<Text size="xs" c="dimmed">Only visible to super admins</Text>
|
||||||
|
</Group>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Add internal notes — sprint reference, thoughts, follow-up actions..."
|
||||||
|
minRows={3}
|
||||||
|
value={noteText}
|
||||||
|
onChange={(e) => setNoteText(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
mt="xs"
|
||||||
|
onClick={handleSaveNote}
|
||||||
|
loading={updateNote.isPending}
|
||||||
|
disabled={noteText === (selectedIdea.adminNote || '')}
|
||||||
|
>
|
||||||
|
Save Note
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
IconCurrencyDollar, IconClipboardCheck, IconLogin, IconEye, IconBulb,
|
||||||
} 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,6 +211,16 @@ export function AdminPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toggleIdeation = useMutation({
|
||||||
|
mutationFn: async ({ orgId, enabled }: { orgId: string; enabled: boolean }) => {
|
||||||
|
await api.put(`/admin/organizations/${orgId}/settings`, { ideationEnabled: enabled });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-tenant-detail', selectedOrgId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-orgs'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const impersonateUser = useMutation({
|
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}`);
|
||||||
@@ -782,6 +792,27 @@ 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">
|
||||||
|
|||||||
@@ -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.03.18</Badge>
|
<Badge variant="light">2026.4.2</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>
|
||||||
|
|||||||
141
load-tests/analysis/baseline.json
Normal file
141
load-tests/analysis/baseline.json
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"capturedAt": null,
|
||||||
|
"environment": "staging",
|
||||||
|
"k6Version": null,
|
||||||
|
"vus": null,
|
||||||
|
"duration": null,
|
||||||
|
"notes": "Empty baseline – populate after first stable load test run"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"POST /api/auth/login": {
|
||||||
|
"p50": null,
|
||||||
|
"p95": null,
|
||||||
|
"p99": null,
|
||||||
|
"errorRate": null,
|
||||||
|
"rps": null
|
||||||
|
},
|
||||||
|
"POST /api/auth/refresh": {
|
||||||
|
"p50": null,
|
||||||
|
"p95": null,
|
||||||
|
"p99": null,
|
||||||
|
"errorRate": null,
|
||||||
|
"rps": null
|
||||||
|
},
|
||||||
|
"POST /api/auth/logout": {
|
||||||
|
"p50": null,
|
||||||
|
"p95": null,
|
||||||
|
"p99": null,
|
||||||
|
"errorRate": null,
|
||||||
|
"rps": null
|
||||||
|
},
|
||||||
|
"GET /api/auth/profile": {
|
||||||
|
"p50": null,
|
||||||
|
"p95": null,
|
||||||
|
"p99": null,
|
||||||
|
"errorRate": null,
|
||||||
|
"rps": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"GET /api/reports/dashboard": {
|
||||||
|
"p50": null,
|
||||||
|
"p95": null,
|
||||||
|
"p99": null,
|
||||||
|
"errorRate": null,
|
||||||
|
"rps": null
|
||||||
|
},
|
||||||
|
"GET /api/reports/balance-sheet": {
|
||||||
|
"p50": null,
|
||||||
|
"p95": null,
|
||||||
|
"p99": null,
|
||||||
|
"errorRate": null,
|
||||||
|
"rps": null
|
||||||
|
},
|
||||||
|
"GET /api/reports/income-statement": {
|
||||||
|
"p50": null,
|
||||||
|
"p95": null,
|
||||||
|
"p99": null,
|
||||||
|
"errorRate": null,
|
||||||
|
"rps": null
|
||||||
|
},
|
||||||
|
"GET /api/reports/aging": {
|
||||||
|
"p50": null,
|
||||||
|
"p95": null,
|
||||||
|
"p99": null,
|
||||||
|
"errorRate": null,
|
||||||
|
"rps": null
|
||||||
|
},
|
||||||
|
"GET /api/health-scores/latest": {
|
||||||
|
"p50": null,
|
||||||
|
"p95": null,
|
||||||
|
"p99": null,
|
||||||
|
"errorRate": null,
|
||||||
|
"rps": null
|
||||||
|
},
|
||||||
|
"GET /api/reports/cash-flow": {
|
||||||
|
"p50": null,
|
||||||
|
"p95": null,
|
||||||
|
"p99": null,
|
||||||
|
"errorRate": null,
|
||||||
|
"rps": null
|
||||||
|
},
|
||||||
|
"GET /api/reports/cash-flow-forecast": {
|
||||||
|
"p50": null,
|
||||||
|
"p95": null,
|
||||||
|
"p99": null,
|
||||||
|
"errorRate": null,
|
||||||
|
"rps": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"crud": {
|
||||||
|
"units": {
|
||||||
|
"GET /api/units": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"POST /api/units": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"GET /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"PUT /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"DELETE /api/units/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||||
|
},
|
||||||
|
"vendors": {
|
||||||
|
"GET /api/vendors": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"POST /api/vendors": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"GET /api/vendors/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"PUT /api/vendors/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||||
|
},
|
||||||
|
"journalEntries": {
|
||||||
|
"GET /api/journal-entries": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"POST /api/journal-entries": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"GET /api/journal-entries/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"POST /api/journal-entries/:id/post": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"POST /api/journal-entries/:id/void": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||||
|
},
|
||||||
|
"payments": {
|
||||||
|
"GET /api/payments": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"POST /api/payments": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"GET /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"PUT /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"DELETE /api/payments/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||||
|
},
|
||||||
|
"accounts": {
|
||||||
|
"GET /api/accounts": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"GET /api/accounts/trial-balance": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"POST /api/accounts": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"PUT /api/accounts/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||||
|
},
|
||||||
|
"invoices": {
|
||||||
|
"GET /api/invoices": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"GET /api/invoices/:id": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"POST /api/invoices/generate-bulk": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"boardPlanning": {
|
||||||
|
"GET /api/board-planning/scenarios": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"POST /api/board-planning/scenarios": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"GET /api/board-planning/scenarios/:id/projection": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"GET /api/board-planning/budget-plans": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||||
|
},
|
||||||
|
"organizations": {
|
||||||
|
"GET /api/organizations": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null },
|
||||||
|
"GET /api/organizations/members": { "p50": null, "p95": null, "p99": null, "errorRate": null, "rps": null }
|
||||||
|
}
|
||||||
|
}
|
||||||
270
load-tests/analysis/nrql-queries.sql
Normal file
270
load-tests/analysis/nrql-queries.sql
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- HOA Financial Platform (HOALedgerIQ) – New Relic NRQL Query Library
|
||||||
|
-- App Name: HOALedgerIQ_App (see NEW_RELIC_APP_NAME env var)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 1. OVERVIEW & THROUGHPUT
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Overall throughput (requests/min) over past hour
|
||||||
|
SELECT rate(count(*), 1 minute) AS 'RPM'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES AUTO;
|
||||||
|
|
||||||
|
-- Throughput by HTTP method
|
||||||
|
SELECT rate(count(*), 1 minute) AS 'RPM'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET request.method
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES AUTO;
|
||||||
|
|
||||||
|
-- Apdex score over time
|
||||||
|
SELECT apdex(duration, t: 0.5)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES AUTO;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 2. AUTHENTICATION ENDPOINTS
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Login latency (p50, p95, p99)
|
||||||
|
SELECT percentile(duration, 50, 95, 99) AS 'Login Latency (s)'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri = '/api/auth/login'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES AUTO;
|
||||||
|
|
||||||
|
-- Login error rate
|
||||||
|
SELECT percentage(count(*), WHERE httpResponseCode >= 400) AS 'Login Error %'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri = '/api/auth/login'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES AUTO;
|
||||||
|
|
||||||
|
-- Token refresh latency
|
||||||
|
SELECT percentile(duration, 50, 95, 99) AS 'Refresh Latency (s)'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri = '/api/auth/refresh'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES AUTO;
|
||||||
|
|
||||||
|
-- Auth endpoints overview
|
||||||
|
SELECT count(*), average(duration), percentile(duration, 95)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri LIKE '/api/auth/%'
|
||||||
|
FACET request.uri
|
||||||
|
SINCE 1 hour ago;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 3. DASHBOARD & REPORTS
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Dashboard KPI latency
|
||||||
|
SELECT percentile(duration, 50, 95, 99) AS 'Dashboard Latency (s)'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri = '/api/reports/dashboard'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES AUTO;
|
||||||
|
|
||||||
|
-- All report endpoints performance
|
||||||
|
SELECT count(*), average(duration), percentile(duration, 95)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri LIKE '/api/reports/%'
|
||||||
|
FACET request.uri
|
||||||
|
SINCE 1 hour ago;
|
||||||
|
|
||||||
|
-- Slowest report queries (> 2s)
|
||||||
|
SELECT count(*)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri LIKE '/api/reports/%'
|
||||||
|
AND duration > 2
|
||||||
|
FACET request.uri
|
||||||
|
SINCE 1 hour ago;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 4. CRUD OPERATIONS
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Units endpoint latency by method
|
||||||
|
SELECT percentile(duration, 50, 95) AS 'Units Latency'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri LIKE '/api/units%'
|
||||||
|
FACET request.method
|
||||||
|
SINCE 1 hour ago;
|
||||||
|
|
||||||
|
-- Vendors endpoint latency by method
|
||||||
|
SELECT percentile(duration, 50, 95) AS 'Vendors Latency'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri LIKE '/api/vendors%'
|
||||||
|
FACET request.method
|
||||||
|
SINCE 1 hour ago;
|
||||||
|
|
||||||
|
-- Journal entries performance
|
||||||
|
SELECT percentile(duration, 50, 95) AS 'JE Latency'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri LIKE '/api/journal-entries%'
|
||||||
|
FACET request.method
|
||||||
|
SINCE 1 hour ago;
|
||||||
|
|
||||||
|
-- Payments performance
|
||||||
|
SELECT percentile(duration, 50, 95) AS 'Payments Latency'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri LIKE '/api/payments%'
|
||||||
|
FACET request.method
|
||||||
|
SINCE 1 hour ago;
|
||||||
|
|
||||||
|
-- Accounts performance
|
||||||
|
SELECT percentile(duration, 50, 95) AS 'Accounts Latency'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri LIKE '/api/accounts%'
|
||||||
|
FACET request.method
|
||||||
|
SINCE 1 hour ago;
|
||||||
|
|
||||||
|
-- Invoices performance
|
||||||
|
SELECT percentile(duration, 50, 95) AS 'Invoices Latency'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri LIKE '/api/invoices%'
|
||||||
|
FACET request.method
|
||||||
|
SINCE 1 hour ago;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 5. MULTI-TENANT / ORG OPERATIONS
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Organizations endpoint performance
|
||||||
|
SELECT percentile(duration, 50, 95)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri LIKE '/api/organizations%'
|
||||||
|
FACET request.method
|
||||||
|
SINCE 1 hour ago;
|
||||||
|
|
||||||
|
-- Board planning (complex module) latency
|
||||||
|
SELECT count(*), percentile(duration, 50, 95, 99)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND request.uri LIKE '/api/board-planning%'
|
||||||
|
FACET request.uri
|
||||||
|
SINCE 1 hour ago;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 6. ERROR ANALYSIS
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Error rate by endpoint (top 20 offenders)
|
||||||
|
SELECT percentage(count(*), WHERE httpResponseCode >= 400) AS 'Error %',
|
||||||
|
count(*) AS 'Total'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET request.uri
|
||||||
|
SINCE 1 hour ago
|
||||||
|
LIMIT 20;
|
||||||
|
|
||||||
|
-- 5xx errors specifically
|
||||||
|
SELECT count(*)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND httpResponseCode >= 500
|
||||||
|
FACET request.uri, httpResponseCode
|
||||||
|
SINCE 1 hour ago;
|
||||||
|
|
||||||
|
-- Error rate over time
|
||||||
|
SELECT percentage(count(*), WHERE httpResponseCode >= 500) AS 'Server Error %',
|
||||||
|
percentage(count(*), WHERE httpResponseCode >= 400 AND httpResponseCode < 500) AS 'Client Error %'
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES AUTO;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 7. DATABASE & EXTERNAL SERVICES
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Database call duration (TypeORM / Postgres)
|
||||||
|
SELECT average(databaseDuration), percentile(databaseDuration, 95)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES AUTO;
|
||||||
|
|
||||||
|
-- Slowest DB transactions
|
||||||
|
SELECT average(databaseDuration), count(*)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND databaseDuration > 1
|
||||||
|
FACET request.uri
|
||||||
|
SINCE 1 hour ago
|
||||||
|
LIMIT 20;
|
||||||
|
|
||||||
|
-- External service calls (Stripe, Resend, NVIDIA AI)
|
||||||
|
SELECT average(externalDuration), count(*)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
AND externalDuration > 0
|
||||||
|
FACET request.uri
|
||||||
|
SINCE 1 hour ago;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 8. LOAD TEST COMPARISON
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Compare metrics between two time windows (baseline vs test)
|
||||||
|
-- Adjust SINCE/UNTIL for your test windows
|
||||||
|
SELECT percentile(duration, 50, 95, 99), count(*), percentage(count(*), WHERE httpResponseCode >= 500)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
SINCE 2 hours ago
|
||||||
|
UNTIL 1 hour ago
|
||||||
|
COMPARE WITH 1 hour ago;
|
||||||
|
|
||||||
|
-- Per-endpoint comparison during load test window
|
||||||
|
SELECT average(duration), percentile(duration, 95), count(*)
|
||||||
|
FROM Transaction
|
||||||
|
WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
FACET request.uri
|
||||||
|
SINCE 1 hour ago
|
||||||
|
LIMIT 50;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 9. INFRASTRUCTURE (if NR Infrastructure agent is installed)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- CPU utilization
|
||||||
|
SELECT average(cpuPercent)
|
||||||
|
FROM SystemSample
|
||||||
|
WHERE hostname LIKE '%hoaledgeriq%'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES AUTO;
|
||||||
|
|
||||||
|
-- Memory utilization
|
||||||
|
SELECT average(memoryUsedPercent)
|
||||||
|
FROM SystemSample
|
||||||
|
WHERE hostname LIKE '%hoaledgeriq%'
|
||||||
|
SINCE 1 hour ago
|
||||||
|
TIMESERIES AUTO;
|
||||||
|
|
||||||
|
-- Connection pool saturation (custom metric – requires NR custom events)
|
||||||
|
-- SELECT average(custom.db.pool.active), average(custom.db.pool.idle)
|
||||||
|
-- FROM Metric
|
||||||
|
-- WHERE appName = 'HOALedgerIQ_App'
|
||||||
|
-- SINCE 1 hour ago
|
||||||
|
-- TIMESERIES AUTO;
|
||||||
53
load-tests/config/environments.json
Normal file
53
load-tests/config/environments.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"staging": {
|
||||||
|
"baseUrl": "https://staging.hoaledgeriq.com",
|
||||||
|
"stages": {
|
||||||
|
"rampUp": 10,
|
||||||
|
"steady": 25,
|
||||||
|
"rampDown": 0
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"http_req_duration_p95": 2000,
|
||||||
|
"login_p95": 1500,
|
||||||
|
"dashboard_p95": 3000,
|
||||||
|
"crud_write_p95": 2000,
|
||||||
|
"crud_read_p95": 1500,
|
||||||
|
"error_rate": 0.05
|
||||||
|
},
|
||||||
|
"notes": "Staging environment – smaller infra, relaxed thresholds"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"baseUrl": "https://app.hoaledgeriq.com",
|
||||||
|
"stages": {
|
||||||
|
"rampUp": 20,
|
||||||
|
"steady": 50,
|
||||||
|
"rampDown": 0
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"http_req_duration_p95": 1000,
|
||||||
|
"login_p95": 800,
|
||||||
|
"dashboard_p95": 1500,
|
||||||
|
"crud_write_p95": 1000,
|
||||||
|
"crud_read_p95": 800,
|
||||||
|
"error_rate": 0.01
|
||||||
|
},
|
||||||
|
"notes": "Production thresholds – strict SLA targets"
|
||||||
|
},
|
||||||
|
"local": {
|
||||||
|
"baseUrl": "http://localhost:3000",
|
||||||
|
"stages": {
|
||||||
|
"rampUp": 5,
|
||||||
|
"steady": 10,
|
||||||
|
"rampDown": 0
|
||||||
|
},
|
||||||
|
"thresholds": {
|
||||||
|
"http_req_duration_p95": 3000,
|
||||||
|
"login_p95": 2000,
|
||||||
|
"dashboard_p95": 5000,
|
||||||
|
"crud_write_p95": 3000,
|
||||||
|
"crud_read_p95": 2000,
|
||||||
|
"error_rate": 0.10
|
||||||
|
},
|
||||||
|
"notes": "Local dev – generous thresholds for single-machine testing"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
load-tests/config/user-pool.csv
Normal file
11
load-tests/config/user-pool.csv
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
email,password,orgId,role
|
||||||
|
loadtest-treasurer-01@hoaledgeriq.test,LoadTest!Pass01,org-uuid-placeholder-1,treasurer
|
||||||
|
loadtest-treasurer-02@hoaledgeriq.test,LoadTest!Pass02,org-uuid-placeholder-1,treasurer
|
||||||
|
loadtest-admin-01@hoaledgeriq.test,LoadTest!Pass03,org-uuid-placeholder-1,admin
|
||||||
|
loadtest-admin-02@hoaledgeriq.test,LoadTest!Pass04,org-uuid-placeholder-2,admin
|
||||||
|
loadtest-president-01@hoaledgeriq.test,LoadTest!Pass05,org-uuid-placeholder-2,president
|
||||||
|
loadtest-manager-01@hoaledgeriq.test,LoadTest!Pass06,org-uuid-placeholder-2,manager
|
||||||
|
loadtest-member-01@hoaledgeriq.test,LoadTest!Pass07,org-uuid-placeholder-1,member_at_large
|
||||||
|
loadtest-viewer-01@hoaledgeriq.test,LoadTest!Pass08,org-uuid-placeholder-1,viewer
|
||||||
|
loadtest-homeowner-01@hoaledgeriq.test,LoadTest!Pass09,org-uuid-placeholder-2,homeowner
|
||||||
|
loadtest-homeowner-02@hoaledgeriq.test,LoadTest!Pass10,org-uuid-placeholder-2,homeowner
|
||||||
|
189
load-tests/scenarios/auth-dashboard-flow.js
Normal file
189
load-tests/scenarios/auth-dashboard-flow.js
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import http from 'k6/http';
|
||||||
|
import { check, group, sleep } from 'k6';
|
||||||
|
import { SharedArray } from 'k6/data';
|
||||||
|
import { Counter, Rate, Trend } from 'k6/metrics';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom metrics
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const loginDuration = new Trend('login_duration', true);
|
||||||
|
const refreshDuration = new Trend('refresh_duration', true);
|
||||||
|
const dashboardDuration = new Trend('dashboard_duration', true);
|
||||||
|
const profileDuration = new Trend('profile_duration', true);
|
||||||
|
const loginFailures = new Counter('login_failures');
|
||||||
|
const authErrors = new Rate('auth_error_rate');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test user pool – parameterized from CSV
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const users = new SharedArray('users', function () {
|
||||||
|
const lines = open('../config/user-pool.csv').split('\n').slice(1); // skip header
|
||||||
|
return lines
|
||||||
|
.filter((l) => l.trim().length > 0)
|
||||||
|
.map((line) => {
|
||||||
|
const [email, password, orgId, role] = line.split(',');
|
||||||
|
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Environment config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const ENV = JSON.parse(open('../config/environments.json'));
|
||||||
|
const CONF = ENV[__ENV.TARGET_ENV || 'staging'];
|
||||||
|
const BASE = CONF.baseUrl;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// k6 options – ramp-up / steady / ramp-down
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
auth_dashboard: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
startVUs: 0,
|
||||||
|
stages: [
|
||||||
|
{ duration: '1m', target: CONF.stages.rampUp }, // ramp-up
|
||||||
|
{ duration: '5m', target: CONF.stages.steady }, // steady state
|
||||||
|
{ duration: '1m', target: 0 }, // ramp-down
|
||||||
|
],
|
||||||
|
gracefulStop: '30s',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
http_req_duration: [`p(95)<${CONF.thresholds.http_req_duration_p95}`],
|
||||||
|
login_duration: [`p(95)<${CONF.thresholds.login_p95}`],
|
||||||
|
dashboard_duration: [`p(95)<${CONF.thresholds.dashboard_p95}`],
|
||||||
|
auth_error_rate: [`rate<${CONF.thresholds.error_rate}`],
|
||||||
|
http_req_failed: [`rate<${CONF.thresholds.error_rate}`],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function authHeaders(accessToken) {
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonPost(url, body, params = {}) {
|
||||||
|
return http.post(url, JSON.stringify(body), {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...((params.headers) || {}) },
|
||||||
|
tags: params.tags || {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Default VU function – login → dashboard journey
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function () {
|
||||||
|
const user = users[__VU % users.length];
|
||||||
|
|
||||||
|
let accessToken = null;
|
||||||
|
|
||||||
|
// ── 1. Login ─────────────────────────────────────────────────────────
|
||||||
|
group('01_login', () => {
|
||||||
|
const res = jsonPost(`${BASE}/api/auth/login`, {
|
||||||
|
email: user.email,
|
||||||
|
password: user.password,
|
||||||
|
}, { tags: { name: 'POST /api/auth/login' } });
|
||||||
|
|
||||||
|
loginDuration.add(res.timings.duration);
|
||||||
|
|
||||||
|
const ok = check(res, {
|
||||||
|
'login status 200|201': (r) => r.status === 200 || r.status === 201,
|
||||||
|
'login returns accessToken': (r) => {
|
||||||
|
try { return !!JSON.parse(r.body).accessToken; } catch { return false; }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
loginFailures.add(1);
|
||||||
|
authErrors.add(1);
|
||||||
|
return; // abort journey – cannot continue without token
|
||||||
|
}
|
||||||
|
authErrors.add(0);
|
||||||
|
|
||||||
|
const body = JSON.parse(res.body);
|
||||||
|
accessToken = body.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!accessToken) return; // guard
|
||||||
|
|
||||||
|
sleep(0.5); // think-time between login and dashboard load
|
||||||
|
|
||||||
|
// ── 2. Fetch profile ────────────────────────────────────────────────
|
||||||
|
group('02_profile', () => {
|
||||||
|
const res = http.get(`${BASE}/api/auth/profile`, authHeaders(accessToken));
|
||||||
|
profileDuration.add(res.timings.duration);
|
||||||
|
check(res, {
|
||||||
|
'profile status 200': (r) => r.status === 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
// ── 3. Dashboard KPIs ───────────────────────────────────────────────
|
||||||
|
group('03_dashboard', () => {
|
||||||
|
const res = http.get(`${BASE}/api/reports/dashboard`, authHeaders(accessToken));
|
||||||
|
dashboardDuration.add(res.timings.duration);
|
||||||
|
check(res, {
|
||||||
|
'dashboard status 200': (r) => r.status === 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
// ── 4. Parallel dashboard widgets (batch) ───────────────────────────
|
||||||
|
group('04_dashboard_widgets', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const fromDate = `${year}-01-01`;
|
||||||
|
const toDate = now.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const responses = http.batch([
|
||||||
|
['GET', `${BASE}/api/accounts?fundType=operating`, null, authHeaders(accessToken)],
|
||||||
|
['GET', `${BASE}/api/reports/income-statement?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
|
||||||
|
['GET', `${BASE}/api/reports/balance-sheet?as_of=${toDate}`, null, authHeaders(accessToken)],
|
||||||
|
['GET', `${BASE}/api/reports/aging`, null, authHeaders(accessToken)],
|
||||||
|
['GET', `${BASE}/api/health-scores/latest`, null, authHeaders(accessToken)],
|
||||||
|
['GET', `${BASE}/api/onboarding/progress`, null, authHeaders(accessToken)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
responses.forEach((res, i) => {
|
||||||
|
check(res, {
|
||||||
|
[`widget_${i} status 200`]: (r) => r.status === 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(0.5);
|
||||||
|
|
||||||
|
// ── 5. Refresh token ───────────────────────────────────────────────
|
||||||
|
group('05_refresh_token', () => {
|
||||||
|
const res = http.post(`${BASE}/api/auth/refresh`, null, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
tags: { name: 'POST /api/auth/refresh' },
|
||||||
|
});
|
||||||
|
refreshDuration.add(res.timings.duration);
|
||||||
|
check(res, {
|
||||||
|
'refresh status 200|201': (r) => r.status === 200 || r.status === 201,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(0.5);
|
||||||
|
|
||||||
|
// ── 6. Logout ──────────────────────────────────────────────────────
|
||||||
|
group('06_logout', () => {
|
||||||
|
const res = http.post(`${BASE}/api/auth/logout`, null, authHeaders(accessToken));
|
||||||
|
check(res, {
|
||||||
|
'logout status 200|201': (r) => r.status === 200 || r.status === 201,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1); // pacing between iterations
|
||||||
|
}
|
||||||
377
load-tests/scenarios/crud-flow.js
Normal file
377
load-tests/scenarios/crud-flow.js
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import http from 'k6/http';
|
||||||
|
import { check, group, sleep } from 'k6';
|
||||||
|
import { SharedArray } from 'k6/data';
|
||||||
|
import { Counter, Rate, Trend } from 'k6/metrics';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom metrics
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const loginDuration = new Trend('crud_login_duration', true);
|
||||||
|
const createDuration = new Trend('crud_create_duration', true);
|
||||||
|
const readDuration = new Trend('crud_read_duration', true);
|
||||||
|
const updateDuration = new Trend('crud_update_duration', true);
|
||||||
|
const deleteDuration = new Trend('crud_delete_duration', true);
|
||||||
|
const listDuration = new Trend('crud_list_duration', true);
|
||||||
|
const crudErrors = new Rate('crud_error_rate');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test user pool
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const users = new SharedArray('users', function () {
|
||||||
|
const lines = open('../config/user-pool.csv').split('\n').slice(1);
|
||||||
|
return lines
|
||||||
|
.filter((l) => l.trim().length > 0)
|
||||||
|
.map((line) => {
|
||||||
|
const [email, password, orgId, role] = line.split(',');
|
||||||
|
return { email: email.trim(), password: password.trim(), orgId: orgId.trim(), role: role.trim() };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Environment config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const ENV = JSON.parse(open('../config/environments.json'));
|
||||||
|
const CONF = ENV[__ENV.TARGET_ENV || 'staging'];
|
||||||
|
const BASE = CONF.baseUrl;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// k6 options
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export const options = {
|
||||||
|
scenarios: {
|
||||||
|
crud_flow: {
|
||||||
|
executor: 'ramping-vus',
|
||||||
|
startVUs: 0,
|
||||||
|
stages: [
|
||||||
|
{ duration: '1m', target: CONF.stages.rampUp },
|
||||||
|
{ duration: '5m', target: CONF.stages.steady },
|
||||||
|
{ duration: '1m', target: 0 },
|
||||||
|
],
|
||||||
|
gracefulStop: '30s',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
thresholds: {
|
||||||
|
http_req_duration: [`p(95)<${CONF.thresholds.http_req_duration_p95}`],
|
||||||
|
crud_create_duration: [`p(95)<${CONF.thresholds.crud_write_p95}`],
|
||||||
|
crud_update_duration: [`p(95)<${CONF.thresholds.crud_write_p95}`],
|
||||||
|
crud_read_duration: [`p(95)<${CONF.thresholds.crud_read_p95}`],
|
||||||
|
crud_list_duration: [`p(95)<${CONF.thresholds.crud_read_p95}`],
|
||||||
|
crud_error_rate: [`rate<${CONF.thresholds.error_rate}`],
|
||||||
|
http_req_failed: [`rate<${CONF.thresholds.error_rate}`],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function authHeaders(token) {
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonPost(url, body, token, tags) {
|
||||||
|
return http.post(url, JSON.stringify(body), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: tags || {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonPut(url, body, token, tags) {
|
||||||
|
return http.put(url, JSON.stringify(body), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
tags: tags || {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function login(user) {
|
||||||
|
const res = http.post(
|
||||||
|
`${BASE}/api/auth/login`,
|
||||||
|
JSON.stringify({ email: user.email, password: user.password }),
|
||||||
|
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'POST /api/auth/login' } },
|
||||||
|
);
|
||||||
|
loginDuration.add(res.timings.duration);
|
||||||
|
if (res.status !== 200 && res.status !== 201) return null;
|
||||||
|
try { return JSON.parse(res.body).accessToken; } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VU function – CRUD journey across core entities
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function () {
|
||||||
|
const user = users[__VU % users.length];
|
||||||
|
|
||||||
|
// ── 1. Login ─────────────────────────────────────────────────────────
|
||||||
|
const accessToken = login(user);
|
||||||
|
if (!accessToken) {
|
||||||
|
crudErrors.add(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
crudErrors.add(0);
|
||||||
|
sleep(0.5);
|
||||||
|
|
||||||
|
// ── 2. Units CRUD ───────────────────────────────────────────────────
|
||||||
|
let unitId = null;
|
||||||
|
group('units_crud', () => {
|
||||||
|
// List
|
||||||
|
group('list_units', () => {
|
||||||
|
const res = http.get(`${BASE}/api/units`, authHeaders(accessToken));
|
||||||
|
listDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'list units 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
// Create
|
||||||
|
group('create_unit', () => {
|
||||||
|
const payload = {
|
||||||
|
unitNumber: `LT-${__VU}-${Date.now()}`,
|
||||||
|
address: `${__VU} Load Test Lane`,
|
||||||
|
ownerName: `Load Tester ${__VU}`,
|
||||||
|
ownerEmail: `lt-${__VU}@loadtest.local`,
|
||||||
|
squareFeet: 1200,
|
||||||
|
};
|
||||||
|
const res = jsonPost(`${BASE}/api/units`, payload, accessToken, { name: 'POST /api/units' });
|
||||||
|
createDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, {
|
||||||
|
'create unit 200|201': (r) => r.status === 200 || r.status === 201,
|
||||||
|
});
|
||||||
|
if (ok) {
|
||||||
|
try { unitId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
// Read
|
||||||
|
if (unitId) {
|
||||||
|
group('read_unit', () => {
|
||||||
|
const res = http.get(`${BASE}/api/units/${unitId}`, authHeaders(accessToken));
|
||||||
|
readDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'read unit 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
// Update
|
||||||
|
group('update_unit', () => {
|
||||||
|
const res = jsonPut(`${BASE}/api/units/${unitId}`, {
|
||||||
|
ownerName: `Updated Tester ${__VU}`,
|
||||||
|
}, accessToken, { name: 'PUT /api/units/:id' });
|
||||||
|
updateDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'update unit 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
group('delete_unit', () => {
|
||||||
|
const res = http.del(`${BASE}/api/units/${unitId}`, null, authHeaders(accessToken));
|
||||||
|
deleteDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'delete unit 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(0.5);
|
||||||
|
|
||||||
|
// ── 3. Vendors CRUD ─────────────────────────────────────────────────
|
||||||
|
let vendorId = null;
|
||||||
|
group('vendors_crud', () => {
|
||||||
|
group('list_vendors', () => {
|
||||||
|
const res = http.get(`${BASE}/api/vendors`, authHeaders(accessToken));
|
||||||
|
listDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'list vendors 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
group('create_vendor', () => {
|
||||||
|
const payload = {
|
||||||
|
name: `LT Vendor ${__VU}-${Date.now()}`,
|
||||||
|
email: `vendor-${__VU}@loadtest.local`,
|
||||||
|
phone: '555-0100',
|
||||||
|
category: 'maintenance',
|
||||||
|
};
|
||||||
|
const res = jsonPost(`${BASE}/api/vendors`, payload, accessToken, { name: 'POST /api/vendors' });
|
||||||
|
createDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, {
|
||||||
|
'create vendor 200|201': (r) => r.status === 200 || r.status === 201,
|
||||||
|
});
|
||||||
|
if (ok) {
|
||||||
|
try { vendorId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
if (vendorId) {
|
||||||
|
group('read_vendor', () => {
|
||||||
|
const res = http.get(`${BASE}/api/vendors/${vendorId}`, authHeaders(accessToken));
|
||||||
|
readDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'read vendor 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
group('update_vendor', () => {
|
||||||
|
const res = jsonPut(`${BASE}/api/vendors/${vendorId}`, {
|
||||||
|
name: `Updated Vendor ${__VU}`,
|
||||||
|
}, accessToken, { name: 'PUT /api/vendors/:id' });
|
||||||
|
updateDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'update vendor 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(0.5);
|
||||||
|
|
||||||
|
// ── 4. Journal Entries workflow ─────────────────────────────────────
|
||||||
|
let journalEntryId = null;
|
||||||
|
group('journal_entries', () => {
|
||||||
|
group('list_journal_entries', () => {
|
||||||
|
const res = http.get(`${BASE}/api/journal-entries`, authHeaders(accessToken));
|
||||||
|
listDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'list JE 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
// Fetch accounts first so we can build a valid entry
|
||||||
|
let accounts = [];
|
||||||
|
group('fetch_accounts_for_je', () => {
|
||||||
|
const res = http.get(`${BASE}/api/accounts`, authHeaders(accessToken));
|
||||||
|
check(res, { 'list accounts 200': (r) => r.status === 200 });
|
||||||
|
try { accounts = JSON.parse(res.body); } catch { /* noop */ }
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
if (Array.isArray(accounts) && accounts.length >= 2) {
|
||||||
|
group('create_journal_entry', () => {
|
||||||
|
const payload = {
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
memo: `Load test JE VU-${__VU}`,
|
||||||
|
type: 'standard',
|
||||||
|
lines: [
|
||||||
|
{ accountId: accounts[0].id, debit: 100, credit: 0, memo: 'debit leg' },
|
||||||
|
{ accountId: accounts[1].id, debit: 0, credit: 100, memo: 'credit leg' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const res = jsonPost(`${BASE}/api/journal-entries`, payload, accessToken, { name: 'POST /api/journal-entries' });
|
||||||
|
createDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, {
|
||||||
|
'create JE 200|201': (r) => r.status === 200 || r.status === 201,
|
||||||
|
});
|
||||||
|
if (ok) {
|
||||||
|
try { journalEntryId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
if (journalEntryId) {
|
||||||
|
group('read_journal_entry', () => {
|
||||||
|
const res = http.get(`${BASE}/api/journal-entries/${journalEntryId}`, authHeaders(accessToken));
|
||||||
|
readDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'read JE 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
// Post (finalize) the journal entry
|
||||||
|
group('post_journal_entry', () => {
|
||||||
|
const res = http.post(`${BASE}/api/journal-entries/${journalEntryId}/post`, null, authHeaders(accessToken));
|
||||||
|
updateDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'post JE 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
// Void the journal entry (cleanup)
|
||||||
|
group('void_journal_entry', () => {
|
||||||
|
const res = http.post(`${BASE}/api/journal-entries/${journalEntryId}/void`, null, authHeaders(accessToken));
|
||||||
|
deleteDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'void JE 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(0.5);
|
||||||
|
|
||||||
|
// ── 5. Payments CRUD ────────────────────────────────────────────────
|
||||||
|
let paymentId = null;
|
||||||
|
group('payments_crud', () => {
|
||||||
|
group('list_payments', () => {
|
||||||
|
const res = http.get(`${BASE}/api/payments`, authHeaders(accessToken));
|
||||||
|
listDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'list payments 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
group('create_payment', () => {
|
||||||
|
const payload = {
|
||||||
|
amount: 150.00,
|
||||||
|
date: new Date().toISOString().slice(0, 10),
|
||||||
|
method: 'check',
|
||||||
|
memo: `Load test payment VU-${__VU}`,
|
||||||
|
};
|
||||||
|
const res = jsonPost(`${BASE}/api/payments`, payload, accessToken, { name: 'POST /api/payments' });
|
||||||
|
createDuration.add(res.timings.duration);
|
||||||
|
const ok = check(res, {
|
||||||
|
'create payment 200|201': (r) => r.status === 200 || r.status === 201,
|
||||||
|
});
|
||||||
|
if (ok) {
|
||||||
|
try { paymentId = JSON.parse(res.body).id; } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
if (paymentId) {
|
||||||
|
group('read_payment', () => {
|
||||||
|
const res = http.get(`${BASE}/api/payments/${paymentId}`, authHeaders(accessToken));
|
||||||
|
readDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'read payment 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
group('update_payment', () => {
|
||||||
|
const res = jsonPut(`${BASE}/api/payments/${paymentId}`, {
|
||||||
|
memo: `Updated payment VU-${__VU}`,
|
||||||
|
}, accessToken, { name: 'PUT /api/payments/:id' });
|
||||||
|
updateDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'update payment 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
sleep(0.3);
|
||||||
|
|
||||||
|
group('delete_payment', () => {
|
||||||
|
const res = http.del(`${BASE}/api/payments/${paymentId}`, null, authHeaders(accessToken));
|
||||||
|
deleteDuration.add(res.timings.duration);
|
||||||
|
check(res, { 'delete payment 200': (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(0.5);
|
||||||
|
|
||||||
|
// ── 6. Reports (read-heavy) ─────────────────────────────────────────
|
||||||
|
group('reports_read', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const toDate = now.toISOString().slice(0, 10);
|
||||||
|
const fromDate = `${year}-01-01`;
|
||||||
|
|
||||||
|
const responses = http.batch([
|
||||||
|
['GET', `${BASE}/api/reports/balance-sheet?as_of=${toDate}`, null, authHeaders(accessToken)],
|
||||||
|
['GET', `${BASE}/api/reports/income-statement?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
|
||||||
|
['GET', `${BASE}/api/reports/aging`, null, authHeaders(accessToken)],
|
||||||
|
['GET', `${BASE}/api/reports/cash-flow?from=${fromDate}&to=${toDate}`, null, authHeaders(accessToken)],
|
||||||
|
['GET', `${BASE}/api/accounts/trial-balance?asOfDate=${toDate}`, null, authHeaders(accessToken)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
responses.forEach((res, i) => {
|
||||||
|
readDuration.add(res.timings.duration);
|
||||||
|
check(res, { [`report_${i} status 200`]: (r) => r.status === 200 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sleep(1); // pacing
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user