feat: add flexible capability-based RBAC with per-tenant customization

Introduces a capability layer on top of existing roles that controls
feature visibility and access. Capabilities follow an area.feature.action
taxonomy (~35 capabilities) with sensible defaults per role. Tenant admins
can customize via grant/revoke overrides stored in org settings JSONB.

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 15:28:14 -04:00
parent 5fec296569
commit 43b10869f0
55 changed files with 1351 additions and 86 deletions

View File

@@ -7,6 +7,7 @@ import { AppController } from './app.controller';
import { DatabaseModule } from './database/database.module';
import { TenantMiddleware } from './database/tenant.middleware';
import { WriteAccessGuard } from './common/guards/write-access.guard';
import { CapabilityGuard } from './common/guards/capability.guard';
import { NoCacheInterceptor } from './common/interceptors/no-cache.interceptor';
import { AuthModule } from './modules/auth/auth.module';
import { OrganizationsModule } from './modules/organizations/organizations.module';
@@ -100,6 +101,10 @@ import { ScheduleModule } from '@nestjs/schedule';
provide: APP_GUARD,
useClass: WriteAccessGuard,
},
{
provide: APP_GUARD,
useClass: CapabilityGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: NoCacheInterceptor,

View File

@@ -0,0 +1,14 @@
import { SetMetadata } from '@nestjs/common';
export const CAPABILITIES_KEY = 'required_capabilities';
/**
* Decorator to require specific capabilities on an endpoint.
* User must have ALL listed capabilities to access the endpoint.
*
* Usage:
* @RequireCapability('financials.accounts.edit')
* @RequireCapability('financials.accounts.view', 'financials.accounts.edit')
*/
export const RequireCapability = (...capabilities: string[]) =>
SetMetadata(CAPABILITIES_KEY, capabilities);

View File

@@ -0,0 +1,83 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { DataSource } from 'typeorm';
import { CAPABILITIES_KEY } from '../decorators/capability.decorator';
import { resolveCapabilities } from '../permissions';
@Injectable()
export class CapabilityGuard implements CanActivate {
// Cache org settings (including permissionOverrides) per orgId
private settingsCache = new Map<string, { settings: Record<string, any>; cachedAt: number }>();
private static readonly CACHE_TTL = 60_000; // 60 seconds
constructor(
private reflector: Reflector,
private dataSource: DataSource,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredCapabilities = this.reflector.getAllAndOverride<string[]>(CAPABILITIES_KEY, [
context.getHandler(),
context.getClass(),
]);
// No capabilities required — pass through (backward compatible)
if (!requiredCapabilities || requiredCapabilities.length === 0) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
// No authenticated user — let other guards handle auth
if (!user) return true;
// Superadmins bypass all capability checks
if (user.isSuperadmin) return true;
const role = user.role;
const orgId = user.orgId;
if (!role || !orgId) return true;
// Get org settings (with caching)
const settings = await this.getOrgSettings(orgId);
const userCapabilities = resolveCapabilities(role, settings?.permissionOverrides);
// User must have ALL required capabilities
const hasAll = requiredCapabilities.every((cap) => userCapabilities.has(cap));
if (!hasAll) {
throw new ForbiddenException(
'You do not have the required permissions for this action.',
);
}
return true;
}
private async getOrgSettings(orgId: string): Promise<Record<string, any> | null> {
const cached = this.settingsCache.get(orgId);
if (cached && Date.now() - cached.cachedAt < CapabilityGuard.CACHE_TTL) {
return cached.settings;
}
try {
const result = await this.dataSource.query(
`SELECT settings FROM shared.organizations WHERE id = $1`,
[orgId],
);
if (result.length > 0) {
const settings = result[0].settings || {};
this.settingsCache.set(orgId, { settings, cachedAt: Date.now() });
return settings;
}
} catch {
// Non-critical — fall through to use defaults only
}
return null;
}
/** Clear cached settings for an org (call after settings update) */
clearCache(orgId: string) {
this.settingsCache.delete(orgId);
}
}

View File

@@ -0,0 +1,65 @@
/**
* Capability taxonomy for the HOA Financial Platform.
*
* Pattern: {area}.{feature}.{action}
* Actions: view, edit, approve, manage
*
* Add new capabilities here when new features are built.
* The default role matrix in ./default-role-capabilities.ts must also be updated.
*/
export const CAPABILITIES = {
// Dashboard
DASHBOARD_VIEW: 'dashboard.view',
// Financials
FINANCIALS_ACCOUNTS_VIEW: 'financials.accounts.view',
FINANCIALS_ACCOUNTS_EDIT: 'financials.accounts.edit',
FINANCIALS_CASHFLOW_VIEW: 'financials.cashflow.view',
FINANCIALS_CASHFLOW_EDIT: 'financials.cashflow.edit',
FINANCIALS_ACTUALS_VIEW: 'financials.actuals.view',
FINANCIALS_ACTUALS_EDIT: 'financials.actuals.edit',
FINANCIALS_BUDGETS_VIEW: 'financials.budgets.view',
FINANCIALS_BUDGETS_EDIT: 'financials.budgets.edit',
FINANCIALS_BUDGETS_APPROVE: 'financials.budgets.approve',
// Assessments
ASSESSMENTS_UNITS_VIEW: 'assessments.units.view',
ASSESSMENTS_UNITS_EDIT: 'assessments.units.edit',
ASSESSMENTS_GROUPS_VIEW: 'assessments.groups.view',
ASSESSMENTS_GROUPS_EDIT: 'assessments.groups.edit',
// Board Planning
PLANNING_BUDGETS_VIEW: 'planning.budgets.view',
PLANNING_BUDGETS_EDIT: 'planning.budgets.edit',
PLANNING_PROJECTS_VIEW: 'planning.projects.view',
PLANNING_PROJECTS_EDIT: 'planning.projects.edit',
PLANNING_SCENARIOS_VIEW: 'planning.scenarios.view',
PLANNING_SCENARIOS_EDIT: 'planning.scenarios.edit',
PLANNING_SCENARIOS_APPROVE: 'planning.scenarios.approve',
PLANNING_INVESTMENTS_VIEW: 'planning.investments.view',
PLANNING_INVESTMENTS_EDIT: 'planning.investments.edit',
// Board Reference
REFERENCE_VENDORS_VIEW: 'reference.vendors.view',
REFERENCE_VENDORS_EDIT: 'reference.vendors.edit',
// Transactions
TRANSACTIONS_VIEW: 'transactions.view',
TRANSACTIONS_EDIT: 'transactions.edit',
TRANSACTIONS_APPROVE: 'transactions.approve',
// Reports
REPORTS_VIEW: 'reports.view',
// Settings & Administration
SETTINGS_ORG_VIEW: 'settings.org.view',
SETTINGS_ORG_EDIT: 'settings.org.edit',
SETTINGS_MEMBERS_VIEW: 'settings.members.view',
SETTINGS_MEMBERS_MANAGE: 'settings.members.manage',
SETTINGS_PERMISSIONS_MANAGE: 'settings.permissions.manage',
} as const;
export type Capability = (typeof CAPABILITIES)[keyof typeof CAPABILITIES];
/** Set of all valid capability strings, for validation */
export const ALL_CAPABILITIES = new Set<string>(Object.values(CAPABILITIES));

View File

@@ -0,0 +1,157 @@
import { CAPABILITIES, Capability } from './capabilities';
const C = CAPABILITIES;
/**
* Default capability sets per role.
*
* These represent sensible defaults for a typical HOA. Tenant admins can
* customize per-role capabilities via permission overrides in org settings.
*
* Roles not listed here (e.g. unknown future roles) get zero capabilities.
*/
export const DEFAULT_ROLE_CAPABILITIES: Record<string, readonly Capability[]> = {
president: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
C.REPORTS_VIEW,
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
C.SETTINGS_PERMISSIONS_MANAGE,
],
admin: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT, C.FINANCIALS_BUDGETS_APPROVE,
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT, C.PLANNING_SCENARIOS_APPROVE,
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT, C.TRANSACTIONS_APPROVE,
C.REPORTS_VIEW,
C.SETTINGS_ORG_VIEW, C.SETTINGS_ORG_EDIT,
C.SETTINGS_MEMBERS_VIEW, C.SETTINGS_MEMBERS_MANAGE,
C.SETTINGS_PERMISSIONS_MANAGE,
],
vice_president: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW,
C.ASSESSMENTS_GROUPS_VIEW,
C.PLANNING_BUDGETS_VIEW,
C.PLANNING_PROJECTS_VIEW,
C.PLANNING_SCENARIOS_VIEW,
C.PLANNING_INVESTMENTS_VIEW,
C.REFERENCE_VENDORS_VIEW,
C.TRANSACTIONS_VIEW,
C.REPORTS_VIEW,
C.SETTINGS_ORG_VIEW,
C.SETTINGS_MEMBERS_VIEW,
],
treasurer: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW, C.FINANCIALS_ACCOUNTS_EDIT,
C.FINANCIALS_CASHFLOW_VIEW, C.FINANCIALS_CASHFLOW_EDIT,
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
C.FINANCIALS_BUDGETS_VIEW, C.FINANCIALS_BUDGETS_EDIT,
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
C.ASSESSMENTS_GROUPS_VIEW, C.ASSESSMENTS_GROUPS_EDIT,
C.PLANNING_BUDGETS_VIEW, C.PLANNING_BUDGETS_EDIT,
C.PLANNING_PROJECTS_VIEW, C.PLANNING_PROJECTS_EDIT,
C.PLANNING_SCENARIOS_VIEW, C.PLANNING_SCENARIOS_EDIT,
C.PLANNING_INVESTMENTS_VIEW, C.PLANNING_INVESTMENTS_EDIT,
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
C.REPORTS_VIEW,
C.SETTINGS_MEMBERS_VIEW,
],
secretary: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW,
C.ASSESSMENTS_GROUPS_VIEW,
C.PLANNING_BUDGETS_VIEW,
C.PLANNING_PROJECTS_VIEW,
C.PLANNING_SCENARIOS_VIEW,
C.PLANNING_INVESTMENTS_VIEW,
C.REFERENCE_VENDORS_VIEW,
C.REPORTS_VIEW,
],
member_at_large: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW,
C.ASSESSMENTS_GROUPS_VIEW,
C.PLANNING_BUDGETS_VIEW,
C.PLANNING_PROJECTS_VIEW,
C.PLANNING_SCENARIOS_VIEW,
C.PLANNING_INVESTMENTS_VIEW,
C.REFERENCE_VENDORS_VIEW,
C.REPORTS_VIEW,
],
manager: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW, C.FINANCIALS_ACTUALS_EDIT,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW, C.ASSESSMENTS_UNITS_EDIT,
C.ASSESSMENTS_GROUPS_VIEW,
C.REFERENCE_VENDORS_VIEW, C.REFERENCE_VENDORS_EDIT,
C.TRANSACTIONS_VIEW, C.TRANSACTIONS_EDIT,
C.REPORTS_VIEW,
],
homeowner: [
C.DASHBOARD_VIEW,
C.REPORTS_VIEW,
],
viewer: [
C.DASHBOARD_VIEW,
C.FINANCIALS_ACCOUNTS_VIEW,
C.FINANCIALS_CASHFLOW_VIEW,
C.FINANCIALS_ACTUALS_VIEW,
C.FINANCIALS_BUDGETS_VIEW,
C.ASSESSMENTS_UNITS_VIEW,
C.ASSESSMENTS_GROUPS_VIEW,
C.PLANNING_BUDGETS_VIEW,
C.PLANNING_PROJECTS_VIEW,
C.PLANNING_SCENARIOS_VIEW,
C.PLANNING_INVESTMENTS_VIEW,
C.REFERENCE_VENDORS_VIEW,
C.TRANSACTIONS_VIEW,
C.REPORTS_VIEW,
],
};

View File

@@ -0,0 +1,5 @@
export { CAPABILITIES, ALL_CAPABILITIES } from './capabilities';
export type { Capability } from './capabilities';
export { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
export { resolveCapabilities, resolveCapabilitiesArray } from './resolve-permissions';
export type { PermissionOverrides } from './resolve-permissions';

View File

@@ -0,0 +1,57 @@
import { ALL_CAPABILITIES } from './capabilities';
import { DEFAULT_ROLE_CAPABILITIES } from './default-role-capabilities';
export interface PermissionOverrides {
[role: string]: {
grant?: string[];
revoke?: string[];
};
}
/**
* Resolve effective capabilities for a role, applying tenant overrides.
*
* 1. Start with default capabilities for the role
* 2. Add any granted capabilities from overrides
* 3. Remove any revoked capabilities from overrides
*
* Unknown capabilities in grant/revoke are silently ignored (they may
* come from an older version of the overrides).
*/
export function resolveCapabilities(
role: string,
overrides?: PermissionOverrides | null,
): Set<string> {
const defaults = DEFAULT_ROLE_CAPABILITIES[role] || [];
const result = new Set<string>(defaults);
if (overrides && overrides[role]) {
const roleOverride = overrides[role];
if (roleOverride.grant) {
for (const cap of roleOverride.grant) {
if (ALL_CAPABILITIES.has(cap)) {
result.add(cap);
}
}
}
if (roleOverride.revoke) {
for (const cap of roleOverride.revoke) {
result.delete(cap);
}
}
}
return result;
}
/**
* Convenience: resolve to a sorted array (for API responses).
*/
export function resolveCapabilitiesArray(
role: string,
overrides?: PermissionOverrides | null,
): string[] {
return Array.from(resolveCapabilities(role, overrides)).sort();
}

View File

@@ -3,6 +3,7 @@ import {
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { AccountsService } from './accounts.service';
import { CreateAccountDto } from './dto/create-account.dto';
import { UpdateAccountDto } from './dto/update-account.dto';
@@ -16,24 +17,28 @@ export class AccountsController {
@Get()
@ApiOperation({ summary: 'List all accounts' })
@RequireCapability('financials.accounts.view')
findAll(@Query('fundType') fundType?: string, @Query('includeArchived') includeArchived?: string) {
return this.accountsService.findAll(fundType, includeArchived === 'true');
}
@Get('trial-balance')
@ApiOperation({ summary: 'Get trial balance' })
@RequireCapability('financials.accounts.view')
getTrialBalance(@Query('asOfDate') asOfDate?: string) {
return this.accountsService.getTrialBalance(asOfDate);
}
@Put(':id/set-primary')
@ApiOperation({ summary: 'Set account as primary for its fund type' })
@RequireCapability('financials.accounts.edit')
setPrimary(@Param('id') id: string) {
return this.accountsService.setPrimary(id);
}
@Post('bulk-opening-balances')
@ApiOperation({ summary: 'Set opening balances for multiple accounts' })
@RequireCapability('financials.accounts.edit')
bulkSetOpeningBalances(
@Body() dto: { asOfDate: string; entries: { accountId: string; targetBalance: number }[] },
) {
@@ -42,6 +47,7 @@ export class AccountsController {
@Post(':id/opening-balance')
@ApiOperation({ summary: 'Set opening balance for an account at a specific date' })
@RequireCapability('financials.accounts.edit')
setOpeningBalance(
@Param('id') id: string,
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
@@ -51,6 +57,7 @@ export class AccountsController {
@Post(':id/adjust-balance')
@ApiOperation({ summary: 'Adjust account balance to a target amount' })
@RequireCapability('financials.accounts.edit')
adjustBalance(
@Param('id') id: string,
@Body() dto: { targetBalance: number; asOfDate: string; memo?: string },
@@ -60,6 +67,7 @@ export class AccountsController {
@Post('transfer')
@ApiOperation({ summary: 'Transfer funds between asset accounts' })
@RequireCapability('financials.accounts.edit')
transferFunds(
@Body() dto: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo?: string },
) {
@@ -68,18 +76,21 @@ export class AccountsController {
@Get(':id')
@ApiOperation({ summary: 'Get account by ID' })
@RequireCapability('financials.accounts.view')
findOne(@Param('id') id: string) {
return this.accountsService.findOne(id);
}
@Post()
@ApiOperation({ summary: 'Create a new account' })
@RequireCapability('financials.accounts.edit')
create(@Body() dto: CreateAccountDto) {
return this.accountsService.create(dto);
}
@Put(':id')
@ApiOperation({ summary: 'Update an account' })
@RequireCapability('financials.accounts.edit')
update(@Param('id') id: string, @Body() dto: UpdateAccountDto) {
return this.accountsService.update(id, dto);
}

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { AssessmentGroupsService } from './assessment-groups.service';
@ApiTags('assessment-groups')
@@ -11,23 +12,30 @@ export class AssessmentGroupsController {
constructor(private service: AssessmentGroupsService) {}
@Get()
@RequireCapability('assessments.groups.view')
findAll() { return this.service.findAll(); }
@Get('summary')
@RequireCapability('assessments.groups.view')
getSummary() { return this.service.getSummary(); }
@Get('default')
@RequireCapability('assessments.groups.view')
getDefault() { return this.service.getDefault(); }
@Get(':id')
@RequireCapability('assessments.groups.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@RequireCapability('assessments.groups.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('assessments.groups.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
@Put(':id/set-default')
@RequireCapability('assessments.groups.edit')
setDefault(@Param('id') id: string) { return this.service.setDefault(id); }
}

View File

@@ -17,6 +17,7 @@ import { EmailService } from '../email/email.service';
import { RegisterDto } from './dto/register.dto';
import { User } from '../users/entities/user.entity';
import { RefreshTokenService } from './refresh-token.service';
import { resolveCapabilitiesArray } from '../../common/permissions';
@Injectable()
export class AuthService {
@@ -162,6 +163,12 @@ export class AuthService {
// Generate new refresh token for org switch
const refreshToken = await this.refreshTokenService.createRefreshToken(user.id);
const orgSettings = membership.organization.settings || {};
const capabilities = resolveCapabilitiesArray(
membership.role,
orgSettings.permissionOverrides,
);
return {
accessToken: this.jwtService.sign(payload),
refreshToken,
@@ -169,7 +176,8 @@ export class AuthService {
id: membership.organization.id,
name: membership.organization.name,
role: membership.role,
settings: membership.organization.settings || {},
settings: orgSettings,
capabilities,
},
};
}
@@ -468,12 +476,16 @@ export class AuthService {
hasSeenIntro: user.hasSeenIntro || false,
mfaEnabled: user.mfaEnabled || false,
},
organizations: orgs.map((uo) => ({
id: uo.organizationId,
name: uo.organization?.name,
status: uo.organization?.status,
role: uo.role,
})),
organizations: orgs.map((uo) => {
const settings = uo.organization?.settings || {};
return {
id: uo.organizationId,
name: uo.organization?.name,
status: uo.organization?.status,
role: uo.role,
capabilities: resolveCapabilitiesArray(uo.role, settings.permissionOverrides),
};
}),
};
}

View File

@@ -3,6 +3,7 @@ import { Response } from 'express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { BoardPlanningService } from './board-planning.service';
import { BoardPlanningProjectionService } from './board-planning-projection.service';
import { BudgetPlanningService } from './budget-planning.service';
@@ -22,27 +23,32 @@ export class BoardPlanningController {
@Get('scenarios')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listScenarios(@Query('type') type?: string) {
return this.service.listScenarios(type);
}
@Get('scenarios/:id')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getScenario(@Param('id') id: string) {
return this.service.getScenario(id);
}
@Post('scenarios')
@RequireCapability('planning.scenarios.edit')
createScenario(@Body() dto: any, @Req() req: any) {
return this.service.createScenario(dto, req.user.sub);
}
@Put('scenarios/:id')
@RequireCapability('planning.scenarios.edit')
updateScenario(@Param('id') id: string, @Body() dto: any) {
return this.service.updateScenario(id, dto);
}
@Delete('scenarios/:id')
@RequireCapability('planning.scenarios.edit')
deleteScenario(@Param('id') id: string) {
return this.service.deleteScenario(id);
}
@@ -51,26 +57,31 @@ export class BoardPlanningController {
@Get('scenarios/:scenarioId/investments')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listInvestments(@Param('scenarioId') scenarioId: string) {
return this.service.listInvestments(scenarioId);
}
@Post('scenarios/:scenarioId/investments')
@RequireCapability('planning.scenarios.edit')
addInvestment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addInvestment(scenarioId, dto);
}
@Post('scenarios/:scenarioId/investments/from-recommendation')
@RequireCapability('planning.scenarios.edit')
addFromRecommendation(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addInvestmentFromRecommendation(scenarioId, dto);
}
@Put('investments/:id')
@RequireCapability('planning.scenarios.edit')
updateInvestment(@Param('id') id: string, @Body() dto: any) {
return this.service.updateInvestment(id, dto);
}
@Delete('investments/:id')
@RequireCapability('planning.scenarios.edit')
removeInvestment(@Param('id') id: string) {
return this.service.removeInvestment(id);
}
@@ -79,21 +90,25 @@ export class BoardPlanningController {
@Get('scenarios/:scenarioId/assessments')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listAssessments(@Param('scenarioId') scenarioId: string) {
return this.service.listAssessments(scenarioId);
}
@Post('scenarios/:scenarioId/assessments')
@RequireCapability('planning.scenarios.edit')
addAssessment(@Param('scenarioId') scenarioId: string, @Body() dto: any) {
return this.service.addAssessment(scenarioId, dto);
}
@Put('assessments/:id')
@RequireCapability('planning.scenarios.edit')
updateAssessment(@Param('id') id: string, @Body() dto: any) {
return this.service.updateAssessment(id, dto);
}
@Delete('assessments/:id')
@RequireCapability('planning.scenarios.edit')
removeAssessment(@Param('id') id: string) {
return this.service.removeAssessment(id);
}
@@ -102,11 +117,13 @@ export class BoardPlanningController {
@Get('scenarios/:id/projection')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getProjection(@Param('id') id: string) {
return this.projection.getProjection(id);
}
@Post('scenarios/:id/projection/refresh')
@RequireCapability('planning.scenarios.edit')
refreshProjection(@Param('id') id: string) {
return this.projection.computeProjection(id);
}
@@ -115,6 +132,7 @@ export class BoardPlanningController {
@Get('compare')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
compareScenarios(@Query('ids') ids: string) {
const scenarioIds = ids.split(',').map((s) => s.trim()).filter(Boolean);
return this.projection.compareScenarios(scenarioIds);
@@ -123,6 +141,7 @@ export class BoardPlanningController {
// ── Execute Investment ──
@Post('investments/:id/execute')
@RequireCapability('planning.scenarios.edit')
executeInvestment(
@Param('id') id: string,
@Body() dto: { executionDate: string },
@@ -135,43 +154,51 @@ export class BoardPlanningController {
@Get('budget-plans')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
listBudgetPlans() {
return this.budgetPlanning.listPlans();
}
@Get('budget-plans/available-years')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getAvailableYears() {
return this.budgetPlanning.getAvailableYears();
}
@Get('budget-plans/:year')
@AllowViewer()
@RequireCapability('planning.scenarios.view')
getBudgetPlan(@Param('year') year: string) {
return this.budgetPlanning.getPlan(parseInt(year, 10));
}
@Post('budget-plans')
@RequireCapability('planning.scenarios.edit')
createBudgetPlan(@Body() dto: { fiscalYear: number; baseYear: number; inflationRate?: number }, @Req() req: any) {
return this.budgetPlanning.createPlan(dto.fiscalYear, dto.baseYear, dto.inflationRate ?? 2.5, req.user.sub);
}
@Put('budget-plans/:year/lines')
@RequireCapability('planning.scenarios.edit')
updateBudgetPlanLines(@Param('year') year: string, @Body() dto: { planId: string; lines: any[] }) {
return this.budgetPlanning.updateLines(dto.planId, dto.lines);
}
@Put('budget-plans/:year/inflation')
@RequireCapability('planning.scenarios.edit')
updateBudgetPlanInflation(@Param('year') year: string, @Body() dto: { inflationRate: number }) {
return this.budgetPlanning.updateInflation(parseInt(year, 10), dto.inflationRate);
}
@Put('budget-plans/:year/status')
@RequireCapability('planning.scenarios.edit')
advanceBudgetPlanStatus(@Param('year') year: string, @Body() dto: { status: string }, @Req() req: any) {
return this.budgetPlanning.advanceStatus(parseInt(year, 10), dto.status, req.user.sub);
}
@Post('budget-plans/:year/import')
@RequireCapability('planning.scenarios.edit')
importBudgetPlanLines(
@Param('year') year: string,
@Body() lines: any[],
@@ -181,6 +208,7 @@ export class BoardPlanningController {
}
@Get('budget-plans/:year/template')
@RequireCapability('planning.scenarios.view')
async getBudgetPlanTemplate(
@Param('year') year: string,
@Res() res: Response,
@@ -194,6 +222,7 @@ export class BoardPlanningController {
}
@Delete('budget-plans/:year')
@RequireCapability('planning.scenarios.edit')
deleteBudgetPlan(@Param('year') year: string) {
return this.budgetPlanning.deletePlan(parseInt(year, 10));
}

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Put, Post, Body, Param, Query, Res, UseGuards, ParseIn
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { BudgetsService } from './budgets.service';
import { UpsertBudgetDto } from './dto/upsert-budget.dto';
@@ -14,6 +15,7 @@ export class BudgetsController {
@Post(':year/import')
@ApiOperation({ summary: 'Import budget data from parsed CSV/XLSX lines' })
@RequireCapability('financials.budgets.edit')
importBudget(
@Param('year', ParseIntPipe) year: number,
@Body() lines: any[],
@@ -23,6 +25,7 @@ export class BudgetsController {
@Get(':year/template')
@ApiOperation({ summary: 'Download budget CSV template for a fiscal year' })
@RequireCapability('financials.budgets.view')
async getTemplate(
@Param('year', ParseIntPipe) year: number,
@Res() res: Response,
@@ -37,6 +40,7 @@ export class BudgetsController {
@Get(':year/vs-actual')
@ApiOperation({ summary: 'Budget vs actual comparison' })
@RequireCapability('financials.budgets.view')
budgetVsActual(
@Param('year', ParseIntPipe) year: number,
@Query('month') month?: string,
@@ -46,12 +50,14 @@ export class BudgetsController {
@Get(':year')
@ApiOperation({ summary: 'Get budgets for a fiscal year' })
@RequireCapability('financials.budgets.view')
findByYear(@Param('year', ParseIntPipe) year: number) {
return this.budgetsService.findByYear(year);
}
@Put(':year')
@ApiOperation({ summary: 'Upsert budgets for a fiscal year' })
@RequireCapability('financials.budgets.edit')
upsert(
@Param('year', ParseIntPipe) year: number,
@Body() budgets: UpsertBudgetDto[],

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { CapitalProjectsService } from './capital-projects.service';
@ApiTags('capital-projects')
@@ -11,14 +12,18 @@ export class CapitalProjectsController {
constructor(private service: CapitalProjectsService) {}
@Get()
@RequireCapability('planning.projects.view')
findAll() { return this.service.findAll(); }
@Get(':id')
@RequireCapability('planning.projects.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@RequireCapability('planning.projects.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('planning.projects.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
}

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { InvestmentPlanningService } from './investment-planning.service';
@ApiTags('investment-planning')
@@ -13,24 +14,28 @@ export class InvestmentPlanningController {
@Get('snapshot')
@ApiOperation({ summary: 'Get financial snapshot for investment planning' })
@RequireCapability('planning.investments.view')
getSnapshot() {
return this.service.getFinancialSnapshot();
}
@Get('cd-rates')
@ApiOperation({ summary: 'Get latest CD rates from market data (backward compat)' })
@RequireCapability('planning.investments.view')
getCdRates() {
return this.service.getCdRates();
}
@Get('market-rates')
@ApiOperation({ summary: 'Get all market rates grouped by type (CD, Money Market, High Yield Savings)' })
@RequireCapability('planning.investments.view')
getMarketRates() {
return this.service.getMarketRates();
}
@Get('saved-recommendation')
@ApiOperation({ summary: 'Get the latest saved AI recommendation for this tenant' })
@RequireCapability('planning.investments.view')
getSavedRecommendation() {
return this.service.getSavedRecommendation();
}
@@ -38,6 +43,7 @@ export class InvestmentPlanningController {
@Post('recommendations')
@ApiOperation({ summary: 'Trigger AI-powered investment recommendations (async — returns immediately)' })
@AllowViewer()
@RequireCapability('planning.investments.edit')
triggerRecommendations(@Req() req: any) {
return this.service.triggerAIRecommendations(req.user?.sub, req.user?.orgId);
}

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { InvestmentsService } from './investments.service';
@ApiTags('investments')
@@ -11,14 +12,18 @@ export class InvestmentsController {
constructor(private service: InvestmentsService) {}
@Get()
@RequireCapability('planning.investments.view')
findAll() { return this.service.findAll(); }
@Get(':id')
@RequireCapability('planning.investments.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@RequireCapability('planning.investments.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('planning.investments.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
}

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { InvoicesService } from './invoices.service';
@ApiTags('invoices')
@@ -11,22 +12,27 @@ export class InvoicesController {
constructor(private invoicesService: InvoicesService) {}
@Get()
@RequireCapability('transactions.view')
findAll() { return this.invoicesService.findAll(); }
@Get(':id')
@RequireCapability('transactions.view')
findOne(@Param('id') id: string) { return this.invoicesService.findOne(id); }
@Post('generate-preview')
@RequireCapability('transactions.edit')
generatePreview(@Body() dto: { month: number; year: number }) {
return this.invoicesService.generatePreview(dto);
}
@Post('generate-bulk')
@RequireCapability('transactions.edit')
generateBulk(@Body() dto: { month: number; year: number }, @Request() req: any) {
return this.invoicesService.generateBulk(dto, req.user.sub);
}
@Post('apply-late-fees')
@RequireCapability('transactions.edit')
applyLateFees(@Body() dto: { grace_period_days: number; late_fee_amount: number }, @Request() req: any) {
return this.invoicesService.applyLateFees(dto, req.user.sub);
}

View File

@@ -3,6 +3,7 @@ import {
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { JournalEntriesService } from './journal-entries.service';
import { CreateJournalEntryDto } from './dto/create-journal-entry.dto';
import { VoidJournalEntryDto } from './dto/void-journal-entry.dto';
@@ -16,6 +17,7 @@ export class JournalEntriesController {
@Get()
@ApiOperation({ summary: 'List journal entries' })
@RequireCapability('transactions.view')
findAll(
@Query('from') from?: string,
@Query('to') to?: string,
@@ -27,24 +29,28 @@ export class JournalEntriesController {
@Get(':id')
@ApiOperation({ summary: 'Get journal entry by ID' })
@RequireCapability('transactions.view')
findOne(@Param('id') id: string) {
return this.jeService.findOne(id);
}
@Post()
@ApiOperation({ summary: 'Create a journal entry' })
@RequireCapability('transactions.edit')
create(@Body() dto: CreateJournalEntryDto, @Request() req: any) {
return this.jeService.create(dto, req.user.sub);
}
@Post(':id/post')
@ApiOperation({ summary: 'Post (finalize) a journal entry' })
@RequireCapability('transactions.edit')
post(@Param('id') id: string, @Request() req: any) {
return this.jeService.post(id, req.user.sub);
}
@Post(':id/void')
@ApiOperation({ summary: 'Void a journal entry' })
@RequireCapability('transactions.edit')
void(@Param('id') id: string, @Body() dto: VoidJournalEntryDto, @Request() req: any) {
return this.jeService.void(id, req.user.sub, dto.reason);
}

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Param, Body, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { MonthlyActualsService } from './monthly-actuals.service';
@ApiTags('monthly-actuals')
@@ -12,12 +13,14 @@ export class MonthlyActualsController {
@Get(':year/:month')
@ApiOperation({ summary: 'Get monthly actuals grid for a specific month' })
@RequireCapability('financials.actuals.view')
async getGrid(@Param('year') year: string, @Param('month') month: string) {
return this.monthlyActualsService.getActualsGrid(parseInt(year), parseInt(month));
}
@Post(':year/:month')
@ApiOperation({ summary: 'Save monthly actuals (creates reconciled journal entry)' })
@RequireCapability('financials.actuals.edit')
async save(
@Param('year') year: string,
@Param('month') month: string,

View File

@@ -3,6 +3,8 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { OrganizationsService } from './organizations.service';
import { CreateOrganizationDto } from './dto/create-organization.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { resolveCapabilitiesArray, ALL_CAPABILITIES } from '../../common/permissions';
@ApiTags('organizations')
@Controller('organizations')
@@ -23,54 +25,87 @@ export class OrganizationsController {
return this.orgService.findByUser(req.user.sub);
}
@Get('my-capabilities')
@ApiOperation({ summary: 'Get resolved capabilities for current user in current org' })
async getMyCapabilities(@Request() req: any) {
const org = await this.orgService.findById(req.user.orgId);
const settings = org?.settings || {};
const capabilities = resolveCapabilitiesArray(req.user.role, settings.permissionOverrides);
return { role: req.user.role, capabilities };
}
@Patch('settings')
@ApiOperation({ summary: 'Update settings for the current organization' })
@RequireCapability('settings.org.edit')
async updateSettings(@Request() req: any, @Body() body: Record<string, any>) {
this.requireTenantAdmin(req);
// Validate permissionOverrides if present
if (body.permissionOverrides) {
this.validatePermissionOverrides(body.permissionOverrides);
}
return this.orgService.updateSettings(req.user.orgId, body);
}
// ── Org Member Management ──
private requireTenantAdmin(req: any) {
const role = req.user.role;
if (!['president', 'admin', 'treasurer'].includes(role) && !req.user.isSuperadmin) {
throw new ForbiddenException('Only organization administrators can manage members');
}
}
@Get('members')
@ApiOperation({ summary: 'List members of current organization' })
@RequireCapability('settings.members.view')
async getMembers(@Request() req: any) {
this.requireTenantAdmin(req);
return this.orgService.getMembers(req.user.orgId);
}
@Post('members')
@ApiOperation({ summary: 'Add a member to the current organization' })
@RequireCapability('settings.members.manage')
async addMember(
@Request() req: any,
@Body() body: { email: string; firstName: string; lastName: string; password: string; role: string },
) {
this.requireTenantAdmin(req);
return this.orgService.addMember(req.user.orgId, body);
}
@Put('members/:id/role')
@ApiOperation({ summary: 'Update a member role' })
@RequireCapability('settings.members.manage')
async updateMemberRole(
@Request() req: any,
@Param('id') id: string,
@Body() body: { role: string },
) {
this.requireTenantAdmin(req);
return this.orgService.updateMemberRole(req.user.orgId, id, body.role);
}
@Delete('members/:id')
@ApiOperation({ summary: 'Remove a member from the organization' })
@RequireCapability('settings.members.manage')
async removeMember(@Request() req: any, @Param('id') id: string) {
this.requireTenantAdmin(req);
return this.orgService.removeMember(req.user.orgId, id, req.user.sub);
}
private validatePermissionOverrides(overrides: any) {
if (typeof overrides !== 'object' || overrides === null) {
throw new ForbiddenException('permissionOverrides must be an object');
}
const validRoles = ['president', 'vice_president', 'treasurer', 'secretary', 'member_at_large', 'manager', 'homeowner', 'admin', 'viewer'];
for (const role of Object.keys(overrides)) {
if (!validRoles.includes(role)) {
throw new ForbiddenException(`Invalid role in permissionOverrides: ${role}`);
}
const entry = overrides[role];
if (entry.grant) {
for (const cap of entry.grant) {
if (!ALL_CAPABILITIES.has(cap)) {
throw new ForbiddenException(`Unknown capability in grant: ${cap}`);
}
}
}
if (entry.revoke) {
for (const cap of entry.revoke) {
if (!ALL_CAPABILITIES.has(cap)) {
throw new ForbiddenException(`Unknown capability in revoke: ${cap}`);
}
}
}
}
}
}

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { PaymentsService } from './payments.service';
@ApiTags('payments')
@@ -11,19 +12,24 @@ export class PaymentsController {
constructor(private paymentsService: PaymentsService) {}
@Get()
@RequireCapability('transactions.view')
findAll() { return this.paymentsService.findAll(); }
@Get(':id')
@RequireCapability('transactions.view')
findOne(@Param('id') id: string) { return this.paymentsService.findOne(id); }
@Post()
@RequireCapability('transactions.edit')
create(@Body() dto: any, @Request() req: any) { return this.paymentsService.create(dto, req.user.sub); }
@Put(':id')
@RequireCapability('transactions.edit')
update(@Param('id') id: string, @Body() dto: any, @Request() req: any) {
return this.paymentsService.update(id, dto, req.user.sub);
}
@Delete(':id')
@RequireCapability('transactions.edit')
delete(@Param('id') id: string) { return this.paymentsService.delete(id); }
}

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Body, Param, Res, UseGuards } from '@nestjs
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { ProjectsService } from './projects.service';
@ApiTags('projects')
@@ -12,9 +13,11 @@ export class ProjectsController {
constructor(private service: ProjectsService) {}
@Get()
@RequireCapability('planning.projects.view')
findAll() { return this.service.findAll(); }
@Get('export')
@RequireCapability('planning.projects.view')
async exportCSV(@Res() res: Response) {
const csv = await this.service.exportCSV();
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="projects.csv"' });
@@ -22,21 +25,27 @@ export class ProjectsController {
}
@Get('planning')
@RequireCapability('planning.projects.view')
findForPlanning() { return this.service.findForPlanning(); }
@Get(':id')
@RequireCapability('planning.projects.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post('import')
@RequireCapability('planning.projects.edit')
importCSV(@Body() rows: any[]) { return this.service.importCSV(rows); }
@Post()
@RequireCapability('planning.projects.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('planning.projects.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
@Put(':id/planned-date')
@RequireCapability('planning.projects.edit')
updatePlannedDate(@Param('id') id: string, @Body() dto: { planned_date: string }) {
return this.service.updatePlannedDate(id, dto.planned_date);
}

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { ReportsService } from './reports.service';
@ApiTags('reports')
@@ -11,11 +12,13 @@ export class ReportsController {
constructor(private reportsService: ReportsService) {}
@Get('balance-sheet')
@RequireCapability('reports.view')
getBalanceSheet(@Query('as_of') asOf?: string) {
return this.reportsService.getBalanceSheet(asOf || new Date().toISOString().split('T')[0]);
}
@Get('income-statement')
@RequireCapability('reports.view')
getIncomeStatement(@Query('from') from?: string, @Query('to') to?: string) {
const now = new Date();
const defaultFrom = `${now.getFullYear()}-01-01`;
@@ -24,6 +27,7 @@ export class ReportsController {
}
@Get('cash-flow-sankey')
@RequireCapability('reports.view')
getCashFlowSankey(
@Query('year') year?: string,
@Query('source') source?: string,
@@ -37,6 +41,7 @@ export class ReportsController {
}
@Get('cash-flow')
@RequireCapability('reports.view')
getCashFlowStatement(
@Query('from') from?: string,
@Query('to') to?: string,
@@ -51,26 +56,31 @@ export class ReportsController {
}
@Get('aging')
@RequireCapability('reports.view')
getAgingReport() {
return this.reportsService.getAgingReport();
}
@Get('year-end')
@RequireCapability('reports.view')
getYearEndSummary(@Query('year') year?: string) {
return this.reportsService.getYearEndSummary(parseInt(year || '') || new Date().getFullYear());
}
@Get('dashboard')
@RequireCapability('reports.view')
getDashboardKPIs() {
return this.reportsService.getDashboardKPIs();
}
@Get('upcoming-investment-activities')
@RequireCapability('reports.view')
getUpcomingInvestmentActivities() {
return this.reportsService.getUpcomingInvestmentActivities();
}
@Get('cash-flow-forecast')
@RequireCapability('reports.view')
getCashFlowForecast(
@Query('startYear') startYear?: string,
@Query('months') months?: string,
@@ -81,6 +91,7 @@ export class ReportsController {
}
@Get('capital-planning')
@RequireCapability('reports.view')
getCapitalPlanningReport(@Query('startYear') startYear?: string) {
return this.reportsService.getCapitalPlanningReport(
parseInt(startYear || '') || undefined,
@@ -88,6 +99,7 @@ export class ReportsController {
}
@Get('quarterly')
@RequireCapability('reports.view')
getQuarterlyFinancial(
@Query('year') year?: string,
@Query('quarter') quarter?: string,

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Put, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { ReserveComponentsService } from './reserve-components.service';
@ApiTags('reserve-components')
@@ -11,14 +12,18 @@ export class ReserveComponentsController {
constructor(private service: ReserveComponentsService) {}
@Get()
@RequireCapability('planning.projects.view')
findAll() { return this.service.findAll(); }
@Get(':id')
@RequireCapability('planning.projects.view')
findOne(@Param('id') id: string) { return this.service.findOne(id); }
@Post()
@RequireCapability('planning.projects.edit')
create(@Body() dto: any) { return this.service.create(dto); }
@Put(':id')
@RequireCapability('planning.projects.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.service.update(id, dto); }
}

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Res, UseGuards } from
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { UnitsService } from './units.service';
@ApiTags('units')
@@ -12,9 +13,11 @@ export class UnitsController {
constructor(private unitsService: UnitsService) {}
@Get()
@RequireCapability('assessments.units.view')
findAll() { return this.unitsService.findAll(); }
@Get('export')
@RequireCapability('assessments.units.view')
async exportCSV(@Res() res: Response) {
const csv = await this.unitsService.exportCSV();
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="units.csv"' });
@@ -22,17 +25,22 @@ export class UnitsController {
}
@Get(':id')
@RequireCapability('assessments.units.view')
findOne(@Param('id') id: string) { return this.unitsService.findOne(id); }
@Post('import')
@RequireCapability('assessments.units.edit')
importCSV(@Body() rows: any[]) { return this.unitsService.importCSV(rows); }
@Post()
@RequireCapability('assessments.units.edit')
create(@Body() dto: any) { return this.unitsService.create(dto); }
@Put(':id')
@RequireCapability('assessments.units.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.unitsService.update(id, dto); }
@Delete(':id')
@RequireCapability('assessments.units.edit')
delete(@Param('id') id: string) { return this.unitsService.delete(id); }
}

View File

@@ -2,6 +2,7 @@ import { Controller, Get, Post, Put, Body, Param, Query, Res, UseGuards } from '
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequireCapability } from '../../common/decorators/capability.decorator';
import { VendorsService } from './vendors.service';
@ApiTags('vendors')
@@ -12,9 +13,11 @@ export class VendorsController {
constructor(private vendorsService: VendorsService) {}
@Get()
@RequireCapability('reference.vendors.view')
findAll() { return this.vendorsService.findAll(); }
@Get('export')
@RequireCapability('reference.vendors.view')
async exportCSV(@Res() res: Response) {
const csv = await this.vendorsService.exportCSV();
res.set({ 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="vendors.csv"' });
@@ -22,19 +25,24 @@ export class VendorsController {
}
@Get('1099-data')
@RequireCapability('reference.vendors.view')
get1099Data(@Query('year') year: string) {
return this.vendorsService.get1099Data(parseInt(year) || new Date().getFullYear());
}
@Get(':id')
@RequireCapability('reference.vendors.view')
findOne(@Param('id') id: string) { return this.vendorsService.findOne(id); }
@Post('import')
@RequireCapability('reference.vendors.edit')
importCSV(@Body() rows: any[]) { return this.vendorsService.importCSV(rows); }
@Post()
@RequireCapability('reference.vendors.edit')
create(@Body() dto: any) { return this.vendorsService.create(dto); }
@Put(':id')
@RequireCapability('reference.vendors.edit')
update(@Param('id') id: string, @Body() dto: any) { return this.vendorsService.update(id, dto); }
}