feat(nestjs): migrate to NestJS

This commit is contained in:
Ahmed Bouhuolia
2025-04-07 11:51:24 +02:00
parent f068218a16
commit 55fcc908ef
3779 changed files with 631 additions and 195332 deletions

View File

@@ -0,0 +1,334 @@
import { ItemAction } from "@/interfaces/Item";
import { ReportsAction } from "../FinancialStatements/types/Report.types";
import { InventoryAdjustmentAction } from "../InventoryAdjutments/types/InventoryAdjustments.types";
import { CashflowAction } from "../BankingTransactions/types/BankingTransactions.types";
import { ManualJournalAction } from "../ManualJournals/types/ManualJournals.types";
import { AccountAction } from "@/interfaces/Account";
import { VendorCreditAction } from "../VendorCredit/types/VendorCredit.types";
import { IPaymentMadeAction } from "../BillPayments/types/BillPayments.types";
import { ExpenseAction } from "../Expenses/Expenses.types";
import { CustomerAction, VendorAction } from "../Customers/types/Customers.types";
import { SaleEstimateAction } from "../SaleEstimates/types/SaleEstimates.types";
import { SaleInvoiceAction } from "../SaleInvoices/SaleInvoice.types";
import { CreditNoteAction } from "../CreditNotes/types/CreditNotes.types";
import { SaleReceiptAction } from "../SaleReceipts/types/SaleReceipts.types";
import { BillAction } from "../Bills/Bills.types";
import { AbilitySubject, ISubjectAbilitiesSchema, ISubjectAbilitySchema } from "./Roles.types";
import { PaymentReceiveAction } from "../PaymentReceived/types/PaymentReceived.types";
import { PreferencesAction } from "../Settings/Settings.types";
export const AbilitySchema: ISubjectAbilitiesSchema[] = [
{
subject: AbilitySubject.Account,
subjectLabel: 'ability.accounts',
abilities: [
{ key: AccountAction.VIEW, label: 'ability.view' },
{ key: AccountAction.CREATE, label: 'ability.create' },
{ key: AccountAction.EDIT, label: 'ability.edit' },
{ key: AccountAction.DELETE, label: 'ability.delete' },
],
extraAbilities: [
{
key: AccountAction.TransactionsLocking,
label: 'ability.transactions_locking',
},
],
},
{
subject: AbilitySubject.ManualJournal,
subjectLabel: 'ability.manual_journal',
abilities: [
{ key: ManualJournalAction.View, label: 'ability.view' },
{ key: ManualJournalAction.Create, label: 'ability.create' },
{ key: ManualJournalAction.Edit, label: 'ability.edit' },
{ key: ManualJournalAction.Delete, label: 'ability.delete' },
],
},
{
subject: AbilitySubject.Cashflow,
subjectLabel: 'ability.cashflow',
abilities: [
{ key: CashflowAction.View, label: 'ability.view' },
{ key: CashflowAction.Create, label: 'ability.create' },
{ key: CashflowAction.Delete, label: 'ability.delete' },
],
},
{
subject: AbilitySubject.Item,
subjectLabel: 'ability.items',
abilities: [
{ key: ItemAction.VIEW, label: 'ability.view', default: true },
{ key: ItemAction.CREATE, label: 'ability.create', default: true },
{ key: ItemAction.EDIT, label: 'ability.edit', default: true },
{ key: ItemAction.DELETE, label: 'ability.delete', default: true },
],
},
{
subject: AbilitySubject.InventoryAdjustment,
subjectLabel: 'ability.inventory_adjustment',
abilities: [
{
key: InventoryAdjustmentAction.VIEW,
label: 'ability.view',
default: true,
},
{
key: InventoryAdjustmentAction.CREATE,
label: 'ability.create',
default: true,
},
{
key: InventoryAdjustmentAction.EDIT,
label: 'ability.edit',
default: true,
},
{ key: InventoryAdjustmentAction.DELETE, label: 'ability.delete' },
],
},
{
subject: AbilitySubject.Customer,
subjectLabel: 'ability.customers',
// description: 'Description is here',
abilities: [
{ key: CustomerAction.View, label: 'ability.view', default: true },
{ key: CustomerAction.Create, label: 'ability.create', default: true },
{ key: CustomerAction.Edit, label: 'ability.edit', default: true },
{ key: CustomerAction.Delete, label: 'ability.delete', default: true },
],
},
{
subject: AbilitySubject.Vendor,
subjectLabel: 'ability.vendors',
abilities: [
{ key: VendorAction.View, label: 'ability.view', default: true },
{ key: VendorAction.Create, label: 'ability.create', default: true },
{ key: VendorAction.Edit, label: 'ability.edit', default: true },
{ key: VendorAction.Delete, label: 'ability.delete', default: true },
],
},
{
subject: AbilitySubject.SaleEstimate,
subjectLabel: 'ability.sale_estimates',
abilities: [
{ key: SaleEstimateAction.View, label: 'ability.view', default: true },
{
key: SaleEstimateAction.Create,
label: 'ability.create',
default: true,
},
{ key: SaleEstimateAction.Edit, label: 'ability.edit', default: true },
{
key: SaleEstimateAction.Delete,
label: 'ability.delete',
default: true,
},
],
},
{
subject: AbilitySubject.SaleInvoice,
subjectLabel: 'ability.sale_invoices',
abilities: [
{ key: SaleInvoiceAction.View, label: 'ability.view', default: true },
{ key: SaleInvoiceAction.Create, label: 'ability.create', default: true },
{ key: SaleInvoiceAction.Edit, label: 'ability.edit', default: true },
{ key: SaleInvoiceAction.Delete, label: 'ability.delete', default: true },
],
extraAbilities: [{ key: 'bad-debt', label: 'Due amount to bad debit' }],
},
{
subject: AbilitySubject.SaleReceipt,
subjectLabel: 'ability.sale_receipts',
abilities: [
{ key: SaleReceiptAction.View, label: 'ability.view', default: true },
{ key: SaleReceiptAction.Create, label: 'ability.create', default: true },
{ key: SaleReceiptAction.Edit, label: 'ability.edit', default: true },
{ key: SaleReceiptAction.Delete, label: 'ability.delete', default: true },
],
},
{
subject: AbilitySubject.CreditNote,
subjectLabel: 'ability.credit_note',
abilities: [
{ key: CreditNoteAction.View, label: 'ability.view', default: true },
{ key: CreditNoteAction.Create, label: 'ability.create', default: true },
{ key: CreditNoteAction.Edit, label: 'ability.edit', default: true },
{ key: CreditNoteAction.Delete, label: 'ability.delete', default: true },
{ key: CreditNoteAction.Refund, label: 'ability.refund', default: true },
],
},
{
subject: AbilitySubject.PaymentReceive,
subjectLabel: 'ability.payments_receive',
abilities: [
{ key: PaymentReceiveAction.View, label: 'ability.view', default: true },
{
key: PaymentReceiveAction.Create,
label: 'ability.create',
default: true,
},
{ key: PaymentReceiveAction.Edit, label: 'ability.edit', default: true },
{
key: PaymentReceiveAction.Delete,
label: 'ability.delete',
default: true,
},
],
},
{
subject: AbilitySubject.Bill,
subjectLabel: 'ability.purchase_invoices',
abilities: [
{ key: BillAction.View, label: 'ability.view', default: true },
{ key: BillAction.Create, label: 'ability.create', default: true },
{ key: BillAction.Edit, label: 'ability.edit', default: true },
{ key: BillAction.Delete, label: 'ability.delete', default: true },
],
},
{
subject: AbilitySubject.VendorCredit,
subjectLabel: 'ability.vendor_credit',
abilities: [
{ key: VendorCreditAction.View, label: 'ability.view', default: true },
{
key: VendorCreditAction.Create,
label: 'ability.create',
default: true,
},
{ key: VendorCreditAction.Edit, label: 'ability.edit', default: true },
{
key: VendorCreditAction.Delete,
label: 'ability.delete',
default: true,
},
{
key: VendorCreditAction.Refund,
label: 'ability.refund',
default: true,
},
],
},
{
subject: AbilitySubject.PaymentMade,
subjectLabel: 'ability.payments_made',
abilities: [
{ key: IPaymentMadeAction.View, label: 'ability.view', default: true },
{
key: IPaymentMadeAction.Create,
label: 'ability.create',
default: true,
},
{ key: IPaymentMadeAction.Edit, label: 'ability.edit', default: true },
{
key: IPaymentMadeAction.Delete,
label: 'ability.delete',
default: true,
},
],
},
{
subject: AbilitySubject.Expense,
subjectLabel: 'ability.expenses',
abilities: [
{ key: ExpenseAction.View, label: 'ability.view', default: true },
{ key: ExpenseAction.Create, label: 'ability.create', default: true },
{ key: ExpenseAction.Edit, label: 'ability.edit', default: true },
{ key: ExpenseAction.Delete, label: 'ability.delete', default: true },
],
},
{
subject: AbilitySubject.Report,
subjectLabel: 'ability.all_reports',
extraAbilities: [
{
key: ReportsAction.READ_BALANCE_SHEET,
label: 'ability.balance_sheet_report',
},
{
key: ReportsAction.READ_PROFIT_LOSS,
label: 'ability.profit_loss_sheet',
},
{ key: ReportsAction.READ_JOURNAL, label: 'ability.journal' },
{
key: ReportsAction.READ_GENERAL_LEDGET,
label: 'ability.general_ledger',
},
{ key: ReportsAction.READ_CASHFLOW, label: 'ability.cashflow_report' },
{
key: ReportsAction.READ_AR_AGING_SUMMARY,
label: 'ability.AR_aging_summary_report',
},
{
key: ReportsAction.READ_AP_AGING_SUMMARY,
label: 'ability.AP_aging_summary_report',
},
{
key: ReportsAction.READ_PURCHASES_BY_ITEMS,
label: 'ability.purchases_by_items',
},
{
key: ReportsAction.READ_SALES_BY_ITEMS,
label: 'ability.sales_by_items_report',
},
{
key: ReportsAction.READ_CUSTOMERS_TRANSACTIONS,
label: 'ability.customers_transactions_report',
},
{
key: ReportsAction.READ_VENDORS_TRANSACTIONS,
label: 'ability.vendors_transactions_report',
},
{
key: ReportsAction.READ_CUSTOMERS_SUMMARY_BALANCE,
label: 'ability.customers_summary_balance_report',
},
{
key: ReportsAction.READ_VENDORS_SUMMARY_BALANCE,
label: 'ability.vendors_summary_balance_report',
},
{
key: ReportsAction.READ_INVENTORY_VALUATION_SUMMARY,
label: 'ability.inventory_valuation_summary',
},
{
key: ReportsAction.READ_INVENTORY_ITEM_DETAILS,
label: 'ability.inventory_items_details',
},
],
},
{
subject: AbilitySubject.Preferences,
subjectLabel: 'ability.preferences',
extraAbilities: [
{
key: PreferencesAction.Mutate,
label: 'ability.mutate_system_preferences',
},
],
},
];
/**
* Retrieve the permissions subject.
* @param {string} key
* @returns {ISubjectAbilitiesSchema | null}
*/
export const getPermissionsSubject = (
key: string
): ISubjectAbilitiesSchema | null => {
return AbilitySchema.find((subject) => subject.subject === key);
};
/**
* Retrieve the permission subject ability.
* @param {String} subjectKey
* @param {string} abilityKey
* @returns
*/
export const getPermissionAbility = (
subjectKey: string,
abilityKey: string
): ISubjectAbilitySchema | null => {
const subject = getPermissionsSubject(subjectKey);
return subject?.abilities.find((ability) => ability.key === abilityKey);
};

View File

@@ -0,0 +1,56 @@
import {
Injectable,
CanActivate,
ExecutionContext,
Inject,
} from '@nestjs/common';
import { Request } from 'express';
import { Reflector } from '@nestjs/core';
import { ClsService } from 'nestjs-cls';
import { ABILITIES_CACHE, getAbilityForRole } from './TenantAbilities';
import { TenantModelProxy } from '../System/models/TenantBaseModel';
import { TenantUser } from '../Tenancy/TenancyModels/models/TenantUser.model';
/**
* Authorization guard for checking user abilities
*/
@Injectable()
export class AuthorizationGuard implements CanActivate {
constructor(
private reflector: Reflector,
private readonly clsService: ClsService,
@Inject(TenantUser.name)
private readonly tenantUserModel: TenantModelProxy<typeof TenantUser>,
) {}
/**
* Checks if the user has the required abilities to access the route
* @param context - The execution context
* @returns A boolean indicating if the user can access the route
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const { tenantId, user } = request as any;
if (ABILITIES_CACHE.has(user.id)) {
(request as any).ability = ABILITIES_CACHE.get(user.id);
} else {
const ability = await this.getAbilityForUser();
(request as any).ability = ability;
ABILITIES_CACHE.set(user.id, ability);
}
return true;
}
async getAbilityForUser() {
const userId = this.clsService.get('userId');
const tenantUser = await this.tenantUserModel()
.query()
.findOne('systemUserId', userId)
.withGraphFetched('role.permissions');
return getAbilityForRole(tenantUser.role);
}
}

View File

@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { CreateRoleService } from './commands/CreateRole.service';
import { DeleteRoleService } from './commands/DeleteRole.service';
import { EditRoleService } from './commands/EditRole.service';
import { GetRoleService } from './queries/GetRole.service';
import { GetRolesService } from './queries/GetRoles.service';
@Injectable()
export class RolesApplication {
constructor(
private readonly createRoleService: CreateRoleService,
private readonly editRoleService: EditRoleService,
private readonly deleteRoleService: DeleteRoleService,
private readonly getRoleService: GetRoleService,
private readonly getRolesService: GetRolesService,
) {}
/**
* Creates a new role.
* @param createRoleDto The data for creating a new role.
* @returns The created role.
*/
async createRole(createRoleDto: any) {
return this.createRoleService.createRole(createRoleDto);
}
/**
* Edits an existing role.
* @param roleId The ID of the role to edit.
* @param editRoleDto The data for editing the role.
* @returns The edited role.
*/
async editRole(roleId: number, editRoleDto: any) {
return this.editRoleService.editRole(roleId, editRoleDto);
}
/**
* Deletes a role.
* @param roleId The ID of the role to delete.
* @returns The result of the deletion operation.
*/
async deleteRole(roleId: number) {
return this.deleteRoleService.deleteRole(roleId);
}
/**
* Gets a specific role by ID.
* @param roleId The ID of the role to retrieve.
* @returns The requested role.
*/
async getRole(roleId: number) {
return this.getRoleService.getRole(roleId);
}
/**
* Lists all roles.
* @returns A list of all roles.
*/
async getRoles() {
return this.getRolesService.getRoles();
}
}

View File

@@ -0,0 +1,113 @@
import {
Controller,
Post,
Get,
Delete,
Param,
Body,
Req,
Res,
Next,
HttpStatus,
ParseIntPipe,
} from '@nestjs/common';
import { Response, NextFunction } from 'express';
import { CreateRoleDto, EditRoleDto } from './dtos/Role.dto';
import { RolesApplication } from './Roles.application';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBody,
} from '@nestjs/swagger';
@ApiTags('Roles')
@Controller('roles')
export class RolesController {
constructor(private readonly rolesApp: RolesApplication) {}
@Post()
@ApiOperation({ summary: 'Create a new role' })
@ApiBody({ type: CreateRoleDto })
@ApiResponse({
status: HttpStatus.OK,
description: 'Role created successfully',
})
async createRole(
@Res() res: Response,
@Next() next: NextFunction,
@Body() createRoleDto: CreateRoleDto,
) {
const role = await this.rolesApp.createRole(createRoleDto);
return res.status(HttpStatus.OK).send({
data: { roleId: role.id },
message: 'The role has been created successfully.',
});
}
@Post(':id')
@ApiOperation({ summary: 'Edit an existing role' })
@ApiParam({ name: 'id', description: 'Role ID' })
@ApiBody({ type: EditRoleDto })
@ApiResponse({
status: HttpStatus.OK,
description: 'Role updated successfully',
})
async editRole(
@Res() res: Response,
@Next() next: NextFunction,
@Param('id', ParseIntPipe) roleId: number,
@Body() editRoleDto: EditRoleDto,
) {
const role = await this.rolesApp.editRole(roleId, editRoleDto);
return res.status(HttpStatus.OK).send({
data: { roleId },
message: 'The given role has been updated successfully.',
});
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a role' })
@ApiParam({ name: 'id', description: 'Role ID' })
@ApiResponse({
status: HttpStatus.OK,
description: 'Role deleted successfully',
})
async deleteRole(
@Res() res: Response,
@Next() next: NextFunction,
@Param('id', ParseIntPipe) roleId: number,
) {
await this.rolesApp.deleteRole(roleId);
return res.status(HttpStatus.OK).send({
data: { roleId },
message: 'The given role has been deleted successfully.',
});
}
@Get()
@ApiOperation({ summary: 'Get all roles' })
@ApiResponse({ status: HttpStatus.OK, description: 'List of all roles' })
async getRoles(@Res() res: Response) {
const roles = await this.rolesApp.getRoles();
return res.status(HttpStatus.OK).send({ roles });
}
@Get(':id')
@ApiOperation({ summary: 'Get a specific role by ID' })
@ApiParam({ name: 'id', description: 'Role ID' })
@ApiResponse({ status: HttpStatus.OK, description: 'Role details' })
async getRole(
@Res() res: Response,
@Param('id', ParseIntPipe) roleId: number,
) {
const role = await this.rolesApp.getRole(roleId);
return res.status(HttpStatus.OK).send({ role });
}
}

View File

@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { CreateRoleService } from './commands/CreateRole.service';
import { EditRoleService } from './commands/EditRole.service';
import { DeleteRoleService } from './commands/DeleteRole.service';
import { GetRoleService } from './queries/GetRole.service';
import { GetRolesService } from './queries/GetRoles.service';
import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module';
import { Role } from './models/Role.model';
import { RolePermission } from './models/RolePermission.model';
import { RolesController } from './Roles.controller';
import { RolesApplication } from './Roles.application';
const models = [
RegisterTenancyModel(Role),
RegisterTenancyModel(RolePermission),
];
@Module({
imports: [...models],
providers: [
CreateRoleService,
EditRoleService,
DeleteRoleService,
GetRoleService,
GetRolesService,
RolesApplication,
],
controllers: [RolesController],
exports: [...models],
})
export class RolesModule {}

View File

@@ -0,0 +1,83 @@
import { Knex } from 'knex';
import { Ability, RawRuleOf, ForcedSubject } from '@casl/ability';
import { CreateRoleDto, EditRoleDto } from './dtos/Role.dto';
import { Role } from './models/Role.model';
export const actions = [
'manage',
'create',
'read',
'update',
'delete',
] as const;
export const subjects = ['Article', 'all'] as const;
export type Abilities = [
typeof actions[number],
(
| typeof subjects[number]
| ForcedSubject<Exclude<typeof subjects[number], 'all'>>
)
];
export type AppAbility = Ability<Abilities>;
export const createAbility = (rules: RawRuleOf<AppAbility>[]) =>
new Ability<Abilities>(rules);
export interface ISubjectAbilitySchema {
key: string;
label: string;
default?: boolean;
}
export interface ISubjectAbilitiesSchema {
subject: string;
subjectLabel: string;
description?: string;
abilities?: ISubjectAbilitySchema[];
extraAbilities?: ISubjectAbilitySchema[];
}
export enum AbilitySubject {
Item = 'Item',
InventoryAdjustment = 'InventoryAdjustment',
Report = 'Report',
Account = 'Account',
SaleInvoice = 'SaleInvoice',
SaleEstimate = 'SaleEstimate',
SaleReceipt = 'SaleReceipt',
PaymentReceive = 'PaymentReceive',
Bill = 'Bill',
PaymentMade = 'PaymentMade',
Expense = 'Expense',
Customer = 'Customer',
Vendor = 'Vendor',
Cashflow = 'Cashflow',
ManualJournal = 'ManualJournal',
Preferences = 'Preferences',
CreditNote = 'CreditNode',
VendorCredit = 'VendorCredit',
Project = 'Project',
TaxRate = 'TaxRate'
}
export interface IRoleCreatedPayload {
createRoleDTO: CreateRoleDto;
role: Role;
trx: Knex.Transaction;
}
export interface IRoleEditedPayload {
editRoleDTO: EditRoleDto;
oldRole: Role;
role: Role;
trx: Knex.Transaction;
}
export interface IRoleDeletedPayload {
oldRole: Role;
roleId: number;
trx: Knex.Transaction;
}

View File

@@ -0,0 +1,13 @@
import { ServiceError } from '../Items/ServiceError';
import { ERRORS } from './constants';
import { Role } from './models/Role.model';
/**
* Valdiates role is not predefined.
* @param {IRole} role - Role object.
*/
export const validateRoleNotPredefined = (role: Role) => {
if (role.predefined) {
throw new ServiceError(ERRORS.ROLE_PREFINED);
}
};

View File

@@ -0,0 +1,55 @@
import { Ability } from '@casl/ability';
import LruCache from 'lru-cache';
import { Role } from './models/Role.model';
import { RolePermission } from './models/RolePermission.model';
// store abilities of 1000 most active users
export const ABILITIES_CACHE = new LruCache(1000);
/**
* Retrieve ability for the given role.
* @param {} role
* @returns
*/
export function getAbilityForRole(role) {
const rules = getAbilitiesRolesConds(role);
return new Ability(rules);
}
/**
* Retrieve abilities of the given role.
* @param {IRole} role
* @returns {}
*/
function getAbilitiesRolesConds(role: Role) {
switch (role.slug) {
case 'admin': // predefined role.
return getSuperAdminRules();
default:
return getRulesFromRolePermissions(role.permissions || []);
}
}
/**
* Retrieve the super admin rules.
* @returns {}
*/
function getSuperAdminRules() {
return [{ action: 'manage', subject: 'all' }];
}
/**
* Retrieve CASL rules from role permissions.
* @param {RolePermission[]} permissions -
* @returns {}
*/
function getRulesFromRolePermissions(permissions: RolePermission[]) {
return permissions
.filter((permission: RolePermission) => permission.value)
.map((permission: RolePermission) => {
return {
action: permission.ability,
subject: permission.subject,
};
});
}

View File

@@ -0,0 +1,52 @@
import { Knex } from 'knex';
import { IRoleCreatedPayload } from '../Roles.types';
import { Role } from './../models/Role.model';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { UnitOfWork } from '../../Tenancy/TenancyDB/UnitOfWork.service';
import { events } from '@/common/events/events';
import { CreateRoleDto } from '../dtos/Role.dto';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Inject, Injectable } from '@nestjs/common';
import { validateInvalidPermissions } from '../utils';
@Injectable()
export class CreateRoleService {
constructor(
private readonly uow: UnitOfWork,
private readonly eventPublisher: EventEmitter2,
@Inject(Role.name)
private readonly roleModel: TenantModelProxy<typeof Role>,
) {}
/**
* Creates a new role and store it to the storage.
* @param {CreateRoleDto} createRoleDTO -
* @returns
*/
public async createRole(createRoleDTO: CreateRoleDto) {
// Validates the invalid permissions.
validateInvalidPermissions(createRoleDTO.permissions);
// Transformes the permissions DTO.
const permissions = createRoleDTO.permissions;
// Creates a new role with associated entries under unit-of-work.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Creates a new role to the storage.
const role = await this.roleModel().query(trx).upsertGraph({
name: createRoleDTO.roleName,
description: createRoleDTO.roleDescription,
permissions,
});
// Triggers `onRoleCreated` event.
await this.eventPublisher.emitAsync(events.roles.onCreated, {
createRoleDTO,
role,
trx,
} as IRoleCreatedPayload);
return role;
});
}
}

View File

@@ -0,0 +1,83 @@
import { Knex } from 'knex';
import { IRoleDeletedPayload } from '../Roles.types';
import { TenantModelProxy } from '../../System/models/TenantBaseModel';
import { Role } from '../models/Role.model';
import { RolePermission } from '../models/RolePermission.model';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { Inject, Injectable } from '@nestjs/common';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { validateRoleNotPredefined } from '../Roles.utils';
import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model';
import { ServiceError } from '@/modules/Items/ServiceError';
import { ERRORS } from '../constants';
@Injectable()
export class DeleteRoleService {
constructor(
private readonly uow: UnitOfWork,
private readonly eventPublisher: EventEmitter2,
@Inject(Role.name)
private readonly roleModel: TenantModelProxy<typeof Role>,
@Inject(RolePermission.name)
private readonly rolePermissionModel: TenantModelProxy<
typeof RolePermission
>,
@Inject(TenantUser.name)
private readonly tenantUserModel: TenantModelProxy<typeof TenantUser>,
) {}
/**
* Deletes the given role from the storage.
* @param {number} roleId - Role id.
*/
public async deleteRole(roleId: number): Promise<void> {
// Retrieve the given role or throw not found service error.
const oldRole = await this.roleModel()
.query()
.findById(roleId)
.throwIfNotFound();
// Validate role is not predefined.
validateRoleNotPredefined(oldRole);
// Validates the given role is not associated to any user.
await this.validateRoleNotAssociatedToUser(roleId);
// Deletes the given role and associated models under unit-of-work envirement.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Deletes the role associated permissions from the storage.
await this.rolePermissionModel()
.query(trx)
.where('roleId', roleId)
.delete();
// Deletes the role object form the storage.
await Role.query(trx).findById(roleId).delete();
// Triggers `onRoleDeleted` event.
await this.eventPublisher.emitAsync(events.roles.onDeleted, {
oldRole,
roleId,
trx,
} as IRoleDeletedPayload);
});
}
/**
* Validates the given role is not associated to any tenant users.
* @param {number} roleId
*/
private validateRoleNotAssociatedToUser = async (roleId: number) => {
const userAssociatedRole = await this.tenantUserModel()
.query()
.where('roleId', roleId);
// Throw service error if the role has associated users.
if (userAssociatedRole.length > 0) {
throw new ServiceError(ERRORS.CANNT_DELETE_ROLE_ASSOCIATED_TO_USERS);
}
};
}

View File

@@ -0,0 +1,55 @@
import { Knex } from 'knex';
import { IRoleEditedPayload } from '../Roles.types';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { events } from '@/common/events/events';
import { EditRoleDto } from '../dtos/Role.dto';
import { Inject, Injectable } from '@nestjs/common';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { Role } from '../models/Role.model';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { validateInvalidPermissions } from '../utils';
@Injectable()
export class EditRoleService {
constructor(
private readonly uow: UnitOfWork,
private readonly eventPublisher: EventEmitter2,
@Inject(Role.name)
private readonly roleModel: TenantModelProxy<typeof Role>,
) {}
/**
* Edits details of the given role on the storage.
* @param {number} roleId - Role id.
* @param {IEditRoleDTO} editRoleDTO - Edit role DTO.
*/
public async editRole(roleId: number, editRoleDTO: EditRoleDto) {
// Validates the invalid permissions.
validateInvalidPermissions(editRoleDTO.permissions);
// Retrieve the given role or throw not found serice error.
const oldRole = await this.roleModel().query().findById(roleId);
const permissions = editRoleDTO.permissions;
// Updates the role on the storage.
return this.uow.withTransaction(async (trx: Knex.Transaction) => {
// Updates the given role to the storage.
const role = await this.roleModel().query(trx).upsertGraph({
id: roleId,
name: editRoleDTO.roleName,
description: editRoleDTO.roleDescription,
permissions,
});
// Triggers `onRoleEdited` event.
await this.eventPublisher.emitAsync(events.roles.onEdited, {
editRoleDTO,
oldRole,
role,
trx,
} as IRoleEditedPayload);
return role;
});
}
}

View File

@@ -0,0 +1,6 @@
export const ERRORS = {
ROLE_NOT_FOUND: 'ROLE_NOT_FOUND',
ROLE_PREFINED: 'ROLE_PREFINED',
INVALIDATE_PERMISSIONS: 'INVALIDATE_PERMISSIONS',
CANNT_DELETE_ROLE_ASSOCIATED_TO_USERS: 'CANNT_DELETE_ROLE_ASSOCIATED_TO_USERS'
};

View File

@@ -0,0 +1,92 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsNotEmpty,
IsNumber,
IsString,
Length,
MinLength,
ValidateNested,
} from 'class-validator';
export class CommandRolePermissionDto {
@IsString()
@IsNotEmpty()
@ApiProperty({
example: 'subject',
description: 'The subject of the permission',
})
subject: string;
@IsString()
@IsNotEmpty()
@ApiProperty({
example: 'read',
description: 'The action of the permission',
})
ability: string;
@IsBoolean()
@IsNotEmpty()
@ApiProperty({
example: true,
description: 'The value of the permission',
})
value: boolean;
}
export class CreateRolePermissionDto extends CommandRolePermissionDto {}
export class EditRolePermissionDto extends CommandRolePermissionDto {
@IsNumber()
@IsNotEmpty()
@ApiProperty({
example: 1,
description: 'The permission ID',
})
permissionId: number;
}
class CommandRoleDto {
@IsString()
@IsNotEmpty()
@ApiProperty({
example: 'admin',
description: 'The name of the role',
})
roleName: string;
@IsString()
@IsNotEmpty()
@ApiProperty({
example: 'Administrator',
description: 'The description of the role',
})
roleDescription: string;
}
export class CreateRoleDto extends CommandRoleDto {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CommandRolePermissionDto)
@ApiProperty({
type: [CommandRolePermissionDto],
description: 'The permissions of the role',
})
permissions: Array<CreateRolePermissionDto>;
}
export class EditRoleDto extends CommandRoleDto {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CommandRolePermissionDto)
@ApiProperty({
type: [CommandRolePermissionDto],
description: 'The permissions of the role',
})
permissions: Array<EditRolePermissionDto>;
}

View File

@@ -0,0 +1,39 @@
import { Model, mixin } from 'objection';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { RolePermission } from './RolePermission.model';
export class Role extends TenantBaseModel {
name: string;
description: string;
slug: string;
predefined: boolean;
permissions: Array<RolePermission>;
/**
* Table name
*/
static get tableName() {
return 'roles';
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const { RolePermission } = require('./RolePermission.model');
return {
/**
*
*/
permissions: {
relation: Model.HasManyRelation,
modelClass: RolePermission,
join: {
from: 'roles.id',
to: 'role_permissions.roleId',
},
},
};
}
}

View File

@@ -0,0 +1,36 @@
import { Model, mixin } from 'objection';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
export class RolePermission extends TenantBaseModel {
value: any;
ability: any;
subject: any;
/**
* Table name
*/
static get tableName() {
return 'role_permissions';
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const { Role } = require('./Role.model');
return {
/**
*
*/
role: {
relation: Model.BelongsToOneRelation,
modelClass: Role,
join: {
from: 'role_permissions.roleId',
to: 'roles.id',
},
},
};
}
}

View File

@@ -0,0 +1,51 @@
import { AbilitySchema } from '../AbilitySchema';
import { RoleTransformer } from './RoleTransformer';
import { Role } from '../models/Role.model';
import { TransformerInjectable } from '../../Transformer/TransformerInjectable.service';
import { ServiceError } from '../../Items/ServiceError';
import { Inject, Injectable } from '@nestjs/common';
import { CommandRolePermissionDto } from '../dtos/Role.dto';
import { ERRORS } from '../constants';
import { getInvalidPermissions } from '../utils';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class GetRoleService {
constructor(
private readonly transformer: TransformerInjectable,
@Inject(Role.name)
private readonly roleModel: TenantModelProxy<typeof Role>,
) {}
/**
* Retrieve the given role metadata.
* @param {number} roleId - Role id.
* @returns {Promise<IRole>}
*/
public async getRole(roleId: number): Promise<Role> {
const role = await this.roleModel()
.query()
.findById(roleId)
.withGraphFetched('permissions')
.throwIfNotFound();
return this.transformer.transform(role, new RoleTransformer());
}
/**
* Validates the invalid given permissions.
* @param {ICreateRolePermissionDTO[]} permissions -
*/
public validateInvalidPermissions = (
permissions: CommandRolePermissionDto[],
) => {
const invalidPerms = getInvalidPermissions(AbilitySchema, permissions);
if (invalidPerms.length > 0) {
throw new ServiceError(ERRORS.INVALIDATE_PERMISSIONS, null, {
invalidPermissions: invalidPerms,
});
}
};
}

View File

@@ -0,0 +1,27 @@
import { Inject, Injectable } from '@nestjs/common';
import { Role } from '../models/Role.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
import { RoleTransformer } from './RoleTransformer';
@Injectable()
export class GetRolesService {
constructor(
private readonly transformer: TransformerInjectable,
@Inject(Role.name)
private readonly roleModel: TenantModelProxy<typeof Role>,
) {}
/**
* Retrieve the roles list.
* @param {Promise<Role[]>}
*/
public getRoles = async (): Promise<Role[]> => {
const roles = await this.roleModel()
.query()
.withGraphFetched('permissions');
return this.transformer.transform(roles, new RoleTransformer());
};
}

View File

@@ -0,0 +1,12 @@
import { AbilitySchema } from '../AbilitySchema';
import { Injectable } from '@nestjs/common';
@Injectable()
export class RolePermissionsSchema {
/**
* Retrieve the role permissions schema.
*/
getRolePermissionsSchema() {
return AbilitySchema;
}
}

View File

@@ -0,0 +1,31 @@
import { Transformer } from '../../Transformer/Transformer';
export class RoleTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return ['name', 'description'];
};
/**
* Retrieves the localized role name if is predefined or stored name.
* @param role
* @returns {string}
*/
public name(role) {
return role.predefined ? this.context.i18n.t(role.name) : role.name;
}
/**
* Retrieves the localized role description if is predefined or stored description.
* @param role
* @returns {string}
*/
public description(role) {
return role.predefined
? this.context.i18n.t(role.description)
: role.description;
}
}

View File

@@ -0,0 +1,62 @@
import { keyBy } from 'lodash';
import { ISubjectAbilitiesSchema } from './Roles.types';
import { CommandRolePermissionDto } from './dtos/Role.dto';
import { AbilitySchema } from './AbilitySchema';
import { ServiceError } from '../Items/ServiceError';
import { ERRORS } from './constants';
/**
* Transformes ability schema to map.
*/
export function transformAbilitySchemaToMap(schema: ISubjectAbilitiesSchema[]) {
return keyBy(
schema.map((item) => ({
...item,
abilities: keyBy(item.abilities, 'key'),
extraAbilities: keyBy(item.extraAbilities, 'key'),
})),
'subject',
);
}
/**
* Retrieve the invalid permissions from the given defined schema.
* @param {ISubjectAbilitiesSchema[]} schema
* @param permissions
* @returns
*/
export function getInvalidPermissions(
schema: ISubjectAbilitiesSchema[],
permissions,
) {
const schemaMap = transformAbilitySchemaToMap(schema);
return permissions.filter((permission) => {
const { subject, ability } = permission;
if (
!schemaMap[subject] ||
(!schemaMap[subject].abilities[ability] &&
!schemaMap[subject].extraAbilities[ability])
) {
return true;
}
return false;
});
}
/**
* Validates the invalid given permissions.
* @param {ICreateRolePermissionDTO[]} permissions -
*/
export const validateInvalidPermissions = (
permissions: CommandRolePermissionDto[],
) => {
const invalidPerms = getInvalidPermissions(AbilitySchema, permissions);
if (invalidPerms.length > 0) {
throw new ServiceError(ERRORS.INVALIDATE_PERMISSIONS, null, {
invalidPermissions: invalidPerms,
});
}
};