diff --git a/packages/server/src/common/decorators/LockMutateBaseCurrency.decorator.ts b/packages/server/src/common/decorators/LockMutateBaseCurrency.decorator.ts new file mode 100644 index 000000000..0645e45eb --- /dev/null +++ b/packages/server/src/common/decorators/LockMutateBaseCurrency.decorator.ts @@ -0,0 +1,57 @@ +/** + * Map to store all models that have been marked to prevent base currency mutation. + * Key is the model name, value is the model class. + */ +export const preventMutateBaseCurrencyModels = new Map(); + +/** + * Decorator that marks an ORM model to prevent base currency mutation. + * When applied to a model class, it adds a static property `preventMutateBaseCurrency` set to true + * and registers the model in the preventMutateBaseCurrencyModels map. + * + * @returns {ClassDecorator} A decorator function that can be applied to a class. + */ +export function PreventMutateBaseCurrency(): ClassDecorator { + return (target: any) => { + // Set the static property on the model class + target.preventMutateBaseCurrency = true; + + // Register the model in the map + const modelName = target.name; + preventMutateBaseCurrencyModels.set(modelName, target); + + // Return the modified class + return target; + }; +} + +/** + * Get all registered models that prevent base currency mutation. + * + * @returns {Map} Map of model names to model classes + */ +export function getPreventMutateBaseCurrencyModels(): Map { + return preventMutateBaseCurrencyModels; +} + +/** + * Check if a model is registered to prevent base currency mutation. + * + * @param {string} modelName - The name of the model to check + * @returns {boolean} True if the model is registered, false otherwise + */ +export function isModelPreventMutateBaseCurrency(modelName: string): boolean { + return preventMutateBaseCurrencyModels.has(modelName); +} + +/** + * Get a specific model by name that prevents base currency mutation. + * + * @param {string} modelName - The name of the model to retrieve + * @returns {any | undefined} The model class if found, undefined otherwise + */ +export function getPreventMutateBaseCurrencyModel( + modelName: string, +): any | undefined { + return preventMutateBaseCurrencyModels.get(modelName); +} diff --git a/packages/server/src/modules/Accounts/MutateBaseCurrencyAccounts.ts b/packages/server/src/modules/Accounts/MutateBaseCurrencyAccounts.ts index 2a3d13d2d..41a460a82 100644 --- a/packages/server/src/modules/Accounts/MutateBaseCurrencyAccounts.ts +++ b/packages/server/src/modules/Accounts/MutateBaseCurrencyAccounts.ts @@ -1,22 +1,19 @@ -// import { Inject, Service } from 'typedi'; -// import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { Inject, Injectable } from '@nestjs/common'; +import { Account } from './models/Account.model'; +import { TenantModelProxy } from '../System/models/TenantBaseModel'; -// @Service() -// export class MutateBaseCurrencyAccounts { -// @Inject() -// tenancy: HasTenancyService; +@Injectable() +export class MutateBaseCurrencyAccounts { + constructor( + @Inject(Account.name) + private readonly accountModel: TenantModelProxy, + ) {} -// /** -// * Mutates the all accounts or the organziation. -// * @param {number} tenantId -// * @param {string} currencyCode -// */ -// public mutateAllAccountsCurrency = async ( -// tenantId: number, -// currencyCode: string -// ) => { -// const { Account } = this.tenancy.models(tenantId); - -// await Account.query().update({ currencyCode }); -// }; -// } + /** + * Mutates the all accounts or the organziation. + * @param {string} currencyCode + */ + async mutateAllAccountsCurrency(currencyCode: string) { + await this.accountModel().query().update({ currencyCode }); + } +} diff --git a/packages/server/src/modules/Accounts/susbcribers/MutateBaseCurrencyAccounts.subscriber.ts b/packages/server/src/modules/Accounts/susbcribers/MutateBaseCurrencyAccounts.subscriber.ts new file mode 100644 index 000000000..f44efb079 --- /dev/null +++ b/packages/server/src/modules/Accounts/susbcribers/MutateBaseCurrencyAccounts.subscriber.ts @@ -0,0 +1,24 @@ +import { OnEvent } from '@nestjs/event-emitter'; +import { Injectable } from '@nestjs/common'; +import { events } from '@/common/events/events'; +import { MutateBaseCurrencyAccounts } from '../MutateBaseCurrencyAccounts'; + +@Injectable() +export class MutateBaseCurrencyAccountsSubscriber { + constructor( + public readonly mutateBaseCurrencyAccounts: MutateBaseCurrencyAccounts, + ) {} + + /** + * Updates the all accounts currency once the base currency + * of the organization is mutated. + */ + @OnEvent(events.organization.baseCurrencyUpdated) + async updateAccountsCurrencyOnBaseCurrencyMutated({ + organizationDTO, + }) { + await this.mutateBaseCurrencyAccounts.mutateAllAccountsCurrency( + organizationDTO.baseCurrency + ); + }; +} diff --git a/packages/server/src/modules/Accounts/susbcribers/MutateBaseCurrencyAccounts.ts b/packages/server/src/modules/Accounts/susbcribers/MutateBaseCurrencyAccounts.ts deleted file mode 100644 index 48903a36b..000000000 --- a/packages/server/src/modules/Accounts/susbcribers/MutateBaseCurrencyAccounts.ts +++ /dev/null @@ -1,34 +0,0 @@ -// import { Service, Inject } from 'typedi'; -// import events from '@/subscribers/events'; -// import { MutateBaseCurrencyAccounts } from '../MutateBaseCurrencyAccounts'; - -// @Service() -// export class MutateBaseCurrencyAccountsSubscriber { -// @Inject() -// public mutateBaseCurrencyAccounts: MutateBaseCurrencyAccounts; - -// /** -// * Attaches the events with handles. -// * @param bus -// */ -// attach(bus) { -// bus.subscribe( -// events.organization.baseCurrencyUpdated, -// this.updateAccountsCurrencyOnBaseCurrencyMutated -// ); -// } - -// /** -// * Updates the all accounts currency once the base currency -// * of the organization is mutated. -// */ -// private updateAccountsCurrencyOnBaseCurrencyMutated = async ({ -// tenantId, -// organizationDTO, -// }) => { -// await this.mutateBaseCurrencyAccounts.mutateAllAccountsCurrency( -// tenantId, -// organizationDTO.baseCurrency -// ); -// }; -// } diff --git a/packages/server/src/modules/App/App.module.ts b/packages/server/src/modules/App/App.module.ts index fa61da1e1..43b7da758 100644 --- a/packages/server/src/modules/App/App.module.ts +++ b/packages/server/src/modules/App/App.module.ts @@ -86,6 +86,8 @@ import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/Credit import { ResourceModule } from '../Resource/Resource.module'; import { ViewsModule } from '../Views/Views.module'; import { CurrenciesModule } from '../Currencies/Currencies.module'; +import { MiscellaneousModule } from '../Miscellaneous/Miscellaneous.module'; +import { UsersModule } from '../UsersModule/Users.module'; @Module({ imports: [ @@ -206,7 +208,9 @@ import { CurrenciesModule } from '../Currencies/Currencies.module'; ImportModule, ResourceModule, ViewsModule, - CurrenciesModule + CurrenciesModule, + MiscellaneousModule, + UsersModule ], controllers: [AppController], providers: [ diff --git a/packages/server/src/modules/BankingAccounts/BankAccounts.module.ts b/packages/server/src/modules/BankingAccounts/BankAccounts.module.ts index 32f9e4a8f..8aaae62f5 100644 --- a/packages/server/src/modules/BankingAccounts/BankAccounts.module.ts +++ b/packages/server/src/modules/BankingAccounts/BankAccounts.module.ts @@ -15,6 +15,8 @@ import { BankingTransactionsModule } from '../BankingTransactions/BankingTransac import { GetBankAccountsService } from './queries/GetBankAccounts'; import { DynamicListModule } from '../DynamicListing/DynamicList.module'; import { GetBankAccountSummary } from './queries/GetBankAccountSummary'; +import { MutateBaseCurrencyAccountsSubscriber } from '../Accounts/susbcribers/MutateBaseCurrencyAccounts.subscriber'; +import { MutateBaseCurrencyAccounts } from '../Accounts/MutateBaseCurrencyAccounts'; @Module({ imports: [ @@ -23,7 +25,7 @@ import { GetBankAccountSummary } from './queries/GetBankAccountSummary'; BankRulesModule, BankingTransactionsRegonizeModule, BankingTransactionsModule, - DynamicListModule + DynamicListModule, ], providers: [ DisconnectBankAccountService, @@ -34,7 +36,9 @@ import { GetBankAccountSummary } from './queries/GetBankAccountSummary'; DisconnectPlaidItemOnAccountDeleted, BankAccountsApplication, GetBankAccountsService, - GetBankAccountSummary + GetBankAccountSummary, + MutateBaseCurrencyAccounts, + MutateBaseCurrencyAccountsSubscriber, ], exports: [BankAccountsApplication], controllers: [BankAccountsController], diff --git a/packages/server/src/modules/Currencies/Currencies.controller.ts b/packages/server/src/modules/Currencies/Currencies.controller.ts index af304900b..5c4ae9127 100644 --- a/packages/server/src/modules/Currencies/Currencies.controller.ts +++ b/packages/server/src/modules/Currencies/Currencies.controller.ts @@ -37,15 +37,15 @@ export class CurrenciesController { return this.currenciesApp.createCurrency(dto); } - @Put(':id') + @Put(':code') @ApiOperation({ summary: 'Edit an existing currency' }) @ApiParam({ name: 'id', type: Number, description: 'Currency ID' }) @ApiBody({ type: EditCurrencyDto }) @ApiOkResponse({ description: 'The currency has been successfully updated.' }) @ApiNotFoundResponse({ description: 'Currency not found.' }) @ApiBadRequestResponse({ description: 'Invalid input data.' }) - edit(@Param('id') id: number, @Body() dto: EditCurrencyDto) { - return this.currenciesApp.editCurrency(Number(id), dto); + edit(@Param('code') code: string, @Body() dto: EditCurrencyDto) { + return this.currenciesApp.editCurrency(code, dto); } @Delete(':code') diff --git a/packages/server/src/modules/Currencies/CurrenciesApplication.service.ts b/packages/server/src/modules/Currencies/CurrenciesApplication.service.ts index cb40cad48..a981a2f7b 100644 --- a/packages/server/src/modules/Currencies/CurrenciesApplication.service.ts +++ b/packages/server/src/modules/Currencies/CurrenciesApplication.service.ts @@ -27,8 +27,8 @@ export class CurrenciesApplication { /** * Edits an existing currency. */ - public editCurrency(currencyId: number, currencyDTO: EditCurrencyDto) { - return this.editCurrencyService.editCurrency(currencyId, currencyDTO); + public editCurrency(currencyCode: string, currencyDTO: EditCurrencyDto) { + return this.editCurrencyService.editCurrency(currencyCode, currencyDTO); } /** diff --git a/packages/server/src/modules/Currencies/commands/EditCurrency.service.ts b/packages/server/src/modules/Currencies/commands/EditCurrency.service.ts index 2cb3fa96b..1c35b7d33 100644 --- a/packages/server/src/modules/Currencies/commands/EditCurrency.service.ts +++ b/packages/server/src/modules/Currencies/commands/EditCurrency.service.ts @@ -12,22 +12,21 @@ export class EditCurrencyService { /** * Edit details of the given currency. - * @param {number} tenantId - * @param {number} currencyId - * @param {ICurrencyDTO} currencyDTO + * @param {number} currencyCode - Currency code. + * @param {ICurrencyDTO} currencyDTO - Edit currency dto. */ public async editCurrency( - currencyId: number, + currencyCode: string, currencyDTO: EditCurrencyDto, ): Promise { const foundCurrency = await this.currencyModel() .query() - .findOne('id', currencyId) + .findOne('currencyCode', currencyCode) .throwIfNotFound(); const currency = await this.currencyModel() .query() - .patchAndFetchById(currencyId, { + .patchAndFetchById(foundCurrency.id, { ...currencyDTO, }); return currency; diff --git a/packages/server/src/modules/Items/models/Item.ts b/packages/server/src/modules/Items/models/Item.ts index c6c9be47e..20c85c451 100644 --- a/packages/server/src/modules/Items/models/Item.ts +++ b/packages/server/src/modules/Items/models/Item.ts @@ -5,10 +5,12 @@ import { ExportableModel } from '@/modules/Export/decorators/ExportableModel.dec import { InjectModelMeta } from '@/modules/Tenancy/TenancyModels/decorators/InjectModelMeta.decorator'; import { ItemMeta } from './Item.meta'; import { ImportableModel } from '@/modules/Import/decorators/Import.decorator'; +import { PreventMutateBaseCurrency } from '@/common/decorators/LockMutateBaseCurrency.decorator'; @ExportableModel() @ImportableModel() @InjectModelMeta(ItemMeta) +@PreventMutateBaseCurrency() export class Item extends TenantBaseModel { public readonly quantityOnHand: number; public readonly name: string; diff --git a/packages/server/src/modules/Miscellaneous/Miscellaneous.constants.ts b/packages/server/src/modules/Miscellaneous/Miscellaneous.constants.ts new file mode 100644 index 000000000..5db70ccbe --- /dev/null +++ b/packages/server/src/modules/Miscellaneous/Miscellaneous.constants.ts @@ -0,0 +1,11 @@ +export const DATE_FORMATS = [ + 'MM/DD/YY', + 'DD/MM/YY', + 'YY/MM/DD', + 'MM/DD/yyyy', + 'DD/MM/yyyy', + 'yyyy/MM/DD', + 'DD MMM YYYY', + 'DD MMMM YYYY', + 'MMMM DD, YYYY', +]; \ No newline at end of file diff --git a/packages/server/src/modules/Miscellaneous/Miscellaneous.controller.ts b/packages/server/src/modules/Miscellaneous/Miscellaneous.controller.ts new file mode 100644 index 000000000..d409e80a5 --- /dev/null +++ b/packages/server/src/modules/Miscellaneous/Miscellaneous.controller.ts @@ -0,0 +1,14 @@ +import { Controller, Get } from '@nestjs/common'; +import { GetDateFormatsService } from './queries/GetDateFormats.service'; +import { ApiTags } from '@nestjs/swagger'; + +@Controller('/') +@ApiTags('misc') +export class MiscellaneousController { + constructor(private readonly getDateFormatsSevice: GetDateFormatsService) {} + + @Get('/date-formats') + getDateFormats() { + return this.getDateFormatsSevice.getDateFormats(); + } +} diff --git a/packages/server/src/modules/Miscellaneous/Miscellaneous.module.ts b/packages/server/src/modules/Miscellaneous/Miscellaneous.module.ts new file mode 100644 index 000000000..d420a386e --- /dev/null +++ b/packages/server/src/modules/Miscellaneous/Miscellaneous.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { GetDateFormatsService } from './queries/GetDateFormats.service'; +import { MiscellaneousController } from './Miscellaneous.controller'; + +@Module({ + providers: [GetDateFormatsService], + exports: [GetDateFormatsService], + controllers: [MiscellaneousController], +}) +export class MiscellaneousModule {} diff --git a/packages/server/src/modules/Miscellaneous/queries/GetDateFormats.service.ts b/packages/server/src/modules/Miscellaneous/queries/GetDateFormats.service.ts new file mode 100644 index 000000000..7efc1595b --- /dev/null +++ b/packages/server/src/modules/Miscellaneous/queries/GetDateFormats.service.ts @@ -0,0 +1,15 @@ +import * as moment from 'moment'; +import { Injectable } from '@nestjs/common'; +import { DATE_FORMATS } from '../Miscellaneous.constants'; + +@Injectable() +export class GetDateFormatsService { + getDateFormats() { + return DATE_FORMATS.map((dateFormat) => { + return { + label: `${moment().format(dateFormat)} [${dateFormat}]`, + key: dateFormat, + }; + }); + } +} diff --git a/packages/server/src/modules/Organization/Organization.controller.ts b/packages/server/src/modules/Organization/Organization.controller.ts index b4863ba0e..14937d1a8 100644 --- a/packages/server/src/modules/Organization/Organization.controller.ts +++ b/packages/server/src/modules/Organization/Organization.controller.ts @@ -11,13 +11,9 @@ import { Put, Get, Body, - Req, - Res, - Next, HttpCode, Param, } from '@nestjs/common'; -import { Request, Response, NextFunction } from 'express'; import { BuildOrganizationService } from './commands/BuildOrganization.service'; import { BuildOrganizationDto, @@ -28,6 +24,7 @@ import { UpdateOrganizationService } from './commands/UpdateOrganization.service import { IgnoreTenantInitializedRoute } from '../Tenancy/EnsureTenantIsInitialized.guard'; import { IgnoreTenantSeededRoute } from '../Tenancy/EnsureTenantIsSeeded.guards'; import { GetBuildOrganizationBuildJob } from './commands/GetBuildOrganizationJob.service'; +import { OrganizationBaseCurrencyLocking } from './Organization/OrganizationBaseCurrencyLocking.service'; @ApiTags('Organization') @Controller('organization') @@ -39,6 +36,7 @@ export class OrganizationController { private readonly getCurrentOrgService: GetCurrentOrganizationService, private readonly updateOrganizationService: UpdateOrganizationService, private readonly getBuildOrganizationJobService: GetBuildOrganizationBuildJob, + private readonly orgBaseCurrencyLockingService: OrganizationBaseCurrencyLocking, ) {} @Post('build') @@ -81,6 +79,14 @@ export class OrganizationController { return { organization }; } + @Get('base-currency-mutate') + async baseCurrencyMutate() { + const abilities = + await this.orgBaseCurrencyLockingService.baseCurrencyMutateLocks(); + + return { abilities }; + } + @Put() @HttpCode(200) @ApiOperation({ summary: 'Update organization information' }) diff --git a/packages/server/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.service.ts b/packages/server/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.service.ts index 7504dac02..dfb4fb2a6 100644 --- a/packages/server/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.service.ts +++ b/packages/server/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.service.ts @@ -1,6 +1,7 @@ -// @ts-nocheck -import { Injectable } from '@nestjs/common'; import { isEmpty } from 'lodash'; +import { Injectable } from '@nestjs/common'; +import { getPreventMutateBaseCurrencyModels } from '@/common/decorators/LockMutateBaseCurrency.decorator'; +import { ModuleRef } from '@nestjs/core'; interface MutateBaseCurrencyLockMeta { modelName: string; @@ -9,26 +10,27 @@ interface MutateBaseCurrencyLockMeta { @Injectable() export class OrganizationBaseCurrencyLocking { + constructor(private readonly moduleRef: ModuleRef) {} + /** * Retrieves the tenant models that have prevented mutation base currency. */ - private getModelsPreventsMutate = (tenantId: number) => { - const Models = this.tenancy.models(tenantId); + private getModelsPreventsMutate() { + const lockedModels = getPreventMutateBaseCurrencyModels(); - const filteredEntries = Object.entries(Models).filter( + const filteredEntries = Array.from(lockedModels).filter( ([key, Model]) => !!Model.preventMutateBaseCurrency, ); return Object.fromEntries(filteredEntries); - }; + } /** * Detarmines the mutation base currency model is locked. - * @param {Model} Model * @returns {Promise} */ - private isModelMutateLocked = async ( + private async isModelMutateLocked( Model, - ): Promise => { + ): Promise { const validateQuery = Model.query(); if (typeof Model?.modifiers?.preventMutateBaseCurrency !== 'undefined') { @@ -45,21 +47,24 @@ export class OrganizationBaseCurrencyLocking { pluralName: Model.pluralName, } : false; - }; + } /** * Retrieves the base currency mutation locks of the tenant models. - * @param {number} tenantId * @returns {Promise} */ - public async baseCurrencyMutateLocks( - tenantId: number, - ): Promise { - const PreventedModels = this.getModelsPreventsMutate(tenantId); + public async baseCurrencyMutateLocks(): Promise< + MutateBaseCurrencyLockMeta[] + > { + const PreventedModels = this.getModelsPreventsMutate(); + const opers = Object.entries(PreventedModels).map(([ModelName, Model]) => { + const InjectedModelProxy = this.moduleRef.get(ModelName, { + strict: false, + }); + const InjectedModel = InjectedModelProxy(); - const opers = Object.entries(PreventedModels).map(([ModelName, Model]) => - this.isModelMutateLocked(Model), - ); + return this.isModelMutateLocked(InjectedModel); + }); const results = await Promise.all(opers); return results.filter( @@ -69,12 +74,11 @@ export class OrganizationBaseCurrencyLocking { /** * Detarmines the base currency mutation locked. - * @param {number} tenantId * @returns {Promise} */ - public isBaseCurrencyMutateLocked = async (tenantId: number) => { - const locks = await this.baseCurrencyMutateLocks(tenantId); + public async isBaseCurrencyMutateLocked() { + const locks = await this.baseCurrencyMutateLocks(); return !isEmpty(locks); - }; + } } diff --git a/packages/server/src/modules/Organization/Organization/OrganizationUpgrade.ts b/packages/server/src/modules/Organization/Organization/OrganizationUpgrade.ts deleted file mode 100644 index 9155539aa..000000000 --- a/packages/server/src/modules/Organization/Organization/OrganizationUpgrade.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { Inject, Service } from 'typedi'; -import { ObjectId } from 'mongodb'; -import HasTenancyService from '@/services/Tenancy/TenancyService'; -import { SeedMigration } from '@/lib/Seeder/SeedMigration'; -import { Tenant } from '@/system/models'; -import { ServiceError } from '@/exceptions'; -import TenantDBManager from '@/services/Tenancy/TenantDBManager'; -import config from '../../config'; -import { ERRORS } from './constants'; -import OrganizationService from './OrganizationService'; -import TenantsManagerService from '@/services/Tenancy/TenantsManager'; - -@Service() -export default class OrganizationUpgrade { - @Inject() - private organizationService: OrganizationService; - - @Inject() - private tenantsManager: TenantsManagerService; - - @Inject('agenda') - private agenda: any; - - /** - * Upgrades the given organization database. - * @param {number} tenantId - Tenant id. - * @returns {Promise} - */ - public upgradeJob = async (tenantId: number): Promise => { - const tenant = await Tenant.query() - .findById(tenantId) - .withGraphFetched('metadata'); - - // Validate tenant version. - this.validateTenantVersion(tenant); - - // Initialize the tenant. - const seedContext = this.tenantsManager.getSeedMigrationContext(tenant); - - // Database manager. - const dbManager = new TenantDBManager(); - - // Migrate the organization database schema. - await dbManager.migrate(tenant); - - // Seeds the organization database data. - await new SeedMigration(seedContext.knex, seedContext).latest(); - - // Update the organization database version. - await this.organizationService.flagTenantDBBatch(tenantId); - - // Remove the tenant job id. - await Tenant.markAsUpgraded(tenantId); - }; - - /** - * Running organization upgrade job. - * @param {number} tenantId - Tenant id. - * @return {Promise} - */ - public upgrade = async (tenantId: number): Promise<{ jobId: string }> => { - const tenant = await Tenant.query().findById(tenantId); - - // Validate tenant version. - this.validateTenantVersion(tenant); - - // Validate tenant upgrade is not running. - this.validateTenantUpgradeNotRunning(tenant); - - // Send welcome mail to the user. - const jobMeta = await this.agenda.now('organization-upgrade', { - tenantId, - }); - // Transformes the mangodb id to string. - const jobId = new ObjectId(jobMeta.attrs._id).toString(); - - // Markes the tenant as currently building. - await Tenant.markAsUpgrading(tenantId, jobId); - - return { jobId }; - }; - - /** - * Validates the given tenant version. - * @param {ITenant} tenant - */ - private validateTenantVersion(tenant) { - if (tenant.databaseBatch >= config.databaseBatch) { - throw new ServiceError(ERRORS.TENANT_DATABASE_UPGRADED); - } - } - - /** - * Validates the given tenant upgrade is not running. - * @param tenant - */ - private validateTenantUpgradeNotRunning(tenant) { - if (tenant.isUpgradeRunning) { - throw new ServiceError(ERRORS.TENANT_UPGRADE_IS_RUNNING); - } - } -} diff --git a/packages/server/src/modules/Organization/commands/CommandOrganizationValidators.service.ts b/packages/server/src/modules/Organization/commands/CommandOrganizationValidators.service.ts index de45d7c87..14edb3c31 100644 --- a/packages/server/src/modules/Organization/commands/CommandOrganizationValidators.service.ts +++ b/packages/server/src/modules/Organization/commands/CommandOrganizationValidators.service.ts @@ -9,7 +9,7 @@ export class CommandOrganizationValidators { constructor( private readonly baseCurrencyMutateLocking: OrganizationBaseCurrencyLocking, ) {} - + /** * Validate mutate base currency ability. * @param {Tenant} tenant - @@ -23,9 +23,7 @@ export class CommandOrganizationValidators { ) { if (tenant.isReady && newBaseCurrency !== oldBaseCurrency) { const isLocked = - await this.baseCurrencyMutateLocking.isBaseCurrencyMutateLocked( - tenant.id, - ); + await this.baseCurrencyMutateLocking.isBaseCurrencyMutateLocked(); if (isLocked) { throw new ServiceError(ERRORS.BASE_CURRENCY_MUTATE_LOCKED); diff --git a/packages/server/src/modules/Roles/TenantAbilities.ts b/packages/server/src/modules/Roles/TenantAbilities.ts index 2617f7dea..bf27d4da7 100644 --- a/packages/server/src/modules/Roles/TenantAbilities.ts +++ b/packages/server/src/modules/Roles/TenantAbilities.ts @@ -1,5 +1,5 @@ import { Ability } from '@casl/ability'; -import LruCache from 'lru-cache'; +import * as LruCache from 'lru-cache'; import { Role } from './models/Role.model'; import { RolePermission } from './models/RolePermission.model'; diff --git a/packages/server/src/modules/Tenancy/TenancyModels/models/TenantUser.model.ts b/packages/server/src/modules/Tenancy/TenancyModels/models/TenantUser.model.ts index ec7b54b3f..3cc272fc4 100644 --- a/packages/server/src/modules/Tenancy/TenancyModels/models/TenantUser.model.ts +++ b/packages/server/src/modules/Tenancy/TenancyModels/models/TenantUser.model.ts @@ -6,9 +6,12 @@ export class TenantUser extends TenantBaseModel { firstName!: string; lastName!: string; inviteAcceptedAt!: Date; + invitedAt!: Date; roleId!: number; - + active!: boolean; role!: Role; + email!: string; + systemUserId!: number; /** * Table name. diff --git a/packages/server/src/modules/UsersModule/Users.application.ts b/packages/server/src/modules/UsersModule/Users.application.ts new file mode 100644 index 000000000..e0bf73d77 --- /dev/null +++ b/packages/server/src/modules/UsersModule/Users.application.ts @@ -0,0 +1,122 @@ +import { Injectable } from '@nestjs/common'; +import { ActivateUserService } from './commands/ActivateUser.service'; +import { DeleteUserService } from './commands/DeleteUser.service'; +import { EditUserService } from './commands/EditUser.service'; +import { InactivateUserService } from './commands/InactivateUser.service'; +import { GetUserService } from './queries/GetUser.service'; +import { AcceptInviteUserService } from './commands/AcceptInviteUser.service'; +import { EditUserDto } from './dtos/EditUser.dto'; +import { InviteUserDto, SendInviteUserDto } from './dtos/InviteUser.dto'; +import { GetUsersService } from './queries/GetUsers.service'; +import { InviteTenantUserService } from './commands/InviteUser.service'; + +@Injectable() +export class UsersApplication { + constructor( + private readonly activateUserService: ActivateUserService, + private readonly deleteUserService: DeleteUserService, + private readonly editUserService: EditUserService, + private readonly inactivateUserService: InactivateUserService, + private readonly getUserService: GetUserService, + private readonly getUsersService: GetUsersService, + private readonly acceptInviteUserService: AcceptInviteUserService, + private readonly inviteservice: InviteTenantUserService, + ) {} + + /** + * Activates a user. + * @param {number} userId - User ID to activate. + * @returns {Promise} + */ + async activateUser(userId: number): Promise { + return this.activateUserService.activateUser(userId); + } + + /** + * Inactivates a user. + * @param {number} tenantId - Tenant ID. + * @param {number} userId - User ID to inactivate. + * @param {ModelObject} authorizedUser - The user performing the action. + * @returns {Promise} + */ + async inactivateUser(userId: number): Promise { + return this.inactivateUserService.inactivateUser(userId); + } + + /** + * Edits a user's details. + * @param {number} userId - User ID to edit. + * @param {IEditUserDTO} editUserDTO - User data to update. + * @returns {Promise} + */ + async editUser(userId: number, editUserDTO: EditUserDto): Promise { + return this.editUserService.editUser(userId, editUserDTO); + } + + /** + * Deletes a user (soft delete). + * @param {number} tenantId - Tenant ID. + * @param {number} userId - User ID to delete. + * @returns {Promise} + */ + async deleteUser(userId: number): Promise { + return this.deleteUserService.deleteUser(userId); + } + + /** + * Gets a user by ID. + * @param {number} userId - User ID to retrieve. + * @returns {Promise} User details. + */ + async getUser(userId: number): Promise { + return this.getUserService.getUser(userId); + } + + /** + * Gets users list based on the given filter. + */ + async getUsers() { + return this.getUsersService.getUsers(); + } + + /** + * Accepts a user invitation. + * @param {string} token - Invitation token. + * @param {IInviteUserInput} inviteUserDTO - User data for accepting the invitation. + * @returns {Promise} + */ + async acceptInvite( + token: string, + inviteUserDTO: InviteUserDto, + ): Promise { + return this.acceptInviteUserService.acceptInvite(token, inviteUserDTO); + } + + /** + * Checks if an invitation token is valid. + * @param {string} token - Invitation token to validate. + * @returns {Promise<{ inviteToken: any; orgName: string }>} Invitation details. + */ + async checkInvite( + token: string, + ): Promise<{ inviteToken: any; orgName: string }> { + return this.acceptInviteUserService.checkInvite(token); + } + /** + * Sends an invitation to a new user. + * @param {IUserSendInviteDTO} sendInviteDTO - User invitation data. + * @returns {Promise<{ invitedUser: ITenantUser }>} The invited user details. + */ + async sendInvite(sendInviteDTO: SendInviteUserDto) { + return this.inviteservice.sendInvite(sendInviteDTO); + } + + /** + * Resends an invitation to an existing user. + * @param {number} userId - ID of the user to resend invitation to. + * @returns {Promise<{ user: ITenantUser }>} The user details. + */ + async resendInvite(userId: number) { + return this.inviteservice.resendInvite(userId); + } +} diff --git a/packages/server/src/modules/UsersModule/Users.constants.ts b/packages/server/src/modules/UsersModule/Users.constants.ts new file mode 100644 index 000000000..334d96640 --- /dev/null +++ b/packages/server/src/modules/UsersModule/Users.constants.ts @@ -0,0 +1,20 @@ +export const ERRORS = { + CANNOT_DELETE_LAST_USER: 'CANNOT_DELETE_LAST_USER', + USER_ALREADY_ACTIVE: 'USER_ALREADY_ACTIVE', + USER_ALREADY_INACTIVE: 'USER_ALREADY_INACTIVE', + EMAIL_ALREADY_EXISTS: 'EMAIL_ALREADY_EXISTS', + PHONE_NUMBER_ALREADY_EXIST: 'PHONE_NUMBER_ALREADY_EXIST', + USER_NOT_FOUND: 'USER_NOT_FOUND', + USER_SAME_THE_AUTHORIZED_USER: 'USER_SAME_THE_AUTHORIZED_USER', + CANNOT_AUTHORIZED_USER_MUTATE_ROLE: 'CANNOT_AUTHORIZED_USER_MUTATE_ROLE', + + EMAIL_ALREADY_INVITED: 'EMAIL_ALREADY_INVITED', + INVITE_TOKEN_INVALID: 'INVITE_TOKEN_INVALID', + PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_EXISTS', + EMAIL_EXISTS: 'EMAIL_EXISTS', + EMAIL_NOT_EXISTS: 'EMAIL_NOT_EXISTS', + USER_RECENTLY_INVITED: 'USER_RECENTLY_INVITED', +}; + +export const SendInviteUserMailQueue = 'SendInviteUserMailQueue'; +export const SendInviteUserMailJob = 'SendInviteUserMailJob'; diff --git a/packages/server/src/modules/UsersModule/Users.controller.ts b/packages/server/src/modules/UsersModule/Users.controller.ts new file mode 100644 index 000000000..07196ed5a --- /dev/null +++ b/packages/server/src/modules/UsersModule/Users.controller.ts @@ -0,0 +1,100 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { UsersApplication } from './Users.application'; +import { EditUserDto } from './dtos/EditUser.dto'; + +@Controller('users') +@ApiTags('users') +export class UsersController { + constructor(private readonly usersApplication: UsersApplication) {} + + /** + * Edit details of the given user. + */ + @Post(':id') + @ApiOperation({ summary: 'Edit details of the given user.' }) + async editUser( + @Param('id') userId: number, + @Body() editUserDTO: EditUserDto, + ) { + await this.usersApplication.editUser(userId, editUserDTO); + + return { + id: userId, + message: 'The user has been edited successfully.', + }; + } + + /** + * Soft deleting the given user. + */ + @Delete(':id') + @ApiOperation({ summary: 'Soft deleting the given user.' }) + async deleteUser(@Param('id') userId: number) { + await this.usersApplication.deleteUser(userId); + + return { + id: userId, + message: 'The user has been deleted successfully.', + }; + } + + /** + * Retrieve user details of the given user id. + */ + @Get(':id') + @ApiOperation({ summary: 'Retrieve user details of the given user id.' }) + async getUser(@Param('id') userId: number) { + return this.usersApplication.getUser(userId); + } + + /** + * Retrieve the list of users. + */ + @Get() + @ApiOperation({ summary: 'Retrieve the list of users.' }) + async listUsers( + @Query('page_size') pageSize?: number, + @Query('page') page?: number, + ) { + return this.usersApplication.getUsers(); + } + + /** + * Activate the given user. + */ + @Put(':id/activate') + @ApiOperation({ summary: 'Activate the given user.' }) + async activateUser(@Param('id') userId: number) { + await this.usersApplication.activateUser(userId); + + return { + id: userId, + message: 'The user has been activated successfully.', + }; + } + + /** + * Inactivate the given user. + */ + @Put(':id/inactivate') + @ApiOperation({ summary: 'Inactivate the given user.' }) + async inactivateUser(@Param('id') userId: number) { + await this.usersApplication.inactivateUser(userId); + + return { + id: userId, + message: 'The user has been inactivated successfully.', + }; + } + +} diff --git a/packages/server/src/modules/UsersModule/Users.module.ts b/packages/server/src/modules/UsersModule/Users.module.ts new file mode 100644 index 000000000..7b83e493b --- /dev/null +++ b/packages/server/src/modules/UsersModule/Users.module.ts @@ -0,0 +1,46 @@ +import { Module } from '@nestjs/common'; +import { ActivateUserService } from './commands/ActivateUser.service'; +import { DeleteUserService } from './commands/DeleteUser.service'; +import { EditUserService } from './commands/EditUser.service'; +import { InactivateUserService } from './commands/InactivateUser.service'; +import { GetUserService } from './queries/GetUser.service'; +import { PurgeUserAbilityCacheSubscriber } from './subscribers/PurgeUserAbilityCache.subscriber'; +import { SyncTenantUserDeleteSubscriber } from './subscribers/SyncTenantUserDeleted.subscriber'; +import { SyncTenantUserMutateSubscriber } from './subscribers/SyncTenantUserSaved.subscriber'; +import { SyncSystemSendInviteSubscriber } from './subscribers/SyncSystemSendInvite.subscriber'; +import { SyncTenantAcceptInviteSubscriber } from './subscribers/SyncTenantAcceptInvite.subscriber'; +import { UsersController } from './Users.controller'; +import { UserInvite } from './models/InviteUser.model'; +import { TenancyModule } from '../Tenancy/Tenancy.module'; +import { UsersApplication } from './Users.application'; +import { GetUsersService } from './queries/GetUsers.service'; +import { AcceptInviteUserService } from './commands/AcceptInviteUser.service'; +import { InviteTenantUserService } from './commands/InviteUser.service'; +import { UsersInviteController } from './UsersInvite.controller'; +import { InjectSystemModel } from '../System/SystemModels/SystemModels.module'; + +const models = [InjectSystemModel(UserInvite)]; + +@Module({ + imports: [TenancyModule], + exports: [...models], + providers: [ + ...models, + ActivateUserService, + DeleteUserService, + EditUserService, + InactivateUserService, + GetUserService, + GetUsersService, + AcceptInviteUserService, + InviteTenantUserService, + PurgeUserAbilityCacheSubscriber, + SyncTenantUserDeleteSubscriber, + SyncTenantUserMutateSubscriber, + SyncSystemSendInviteSubscriber, + SyncTenantAcceptInviteSubscriber, + UsersApplication + ], + controllers: [UsersController, UsersInviteController], +}) +export class UsersModule {} diff --git a/packages/server/src/modules/UsersModule/Users.types.ts b/packages/server/src/modules/UsersModule/Users.types.ts new file mode 100644 index 000000000..5d19cbf37 --- /dev/null +++ b/packages/server/src/modules/UsersModule/Users.types.ts @@ -0,0 +1,65 @@ +import { ModelObject } from 'objection'; +import { TenantUser } from '../Tenancy/TenancyModels/models/TenantUser.model'; +import { EditUserDto } from './dtos/EditUser.dto'; +import { UserInvite } from './models/InviteUser.model'; +import { SystemUser } from '../System/models/SystemUser'; +import { InviteUserDto } from './dtos/InviteUser.dto'; +import { TenantModel } from '../System/models/TenantModel'; +import { TenantJobPayload } from '@/interfaces/Tenant'; + +export interface ITenantUserEditedPayload { + userId: number; + editUserDTO: EditUserDto; + tenantUser: ModelObject; + oldTenantUser: ModelObject; +} + +export interface ITenantUserActivatedPayload { + userId: number; + tenantUser: ModelObject; +} + +export interface ITenantUserInactivatedPayload { + userId: number; + tenantUser: ModelObject; +} + +export interface ITenantUserDeletedPayload { + userId: number; + tenantUser: ModelObject; +} + +export interface IUserInvitedEventPayload { + inviteToken: string; + user: ModelObject; +} +export interface IUserInviteTenantSyncedEventPayload { + invite: ModelObject; + user: ModelObject; +} + +export interface IUserInviteResendEventPayload { + inviteToken: string; + user: ModelObject; +} + +export interface IAcceptInviteEventPayload { + inviteToken: ModelObject; + user: ModelObject; + inviteUserDTO: InviteUserDto; +} + +export interface ICheckInviteEventPayload { + inviteToken: ModelObject; + tenant: ModelObject; +} + +export interface IUserSendInviteDTO { + email: string; + roleId: number; +} + +export interface SendInviteUserMailJobPayload extends TenantJobPayload { + fromUser: ModelObject; + invite: ModelObject; +} diff --git a/packages/server/src/modules/UsersModule/UsersInvite.controller.ts b/packages/server/src/modules/UsersModule/UsersInvite.controller.ts new file mode 100644 index 000000000..206869de0 --- /dev/null +++ b/packages/server/src/modules/UsersModule/UsersInvite.controller.ts @@ -0,0 +1,65 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { UsersApplication } from './Users.application'; +import { InviteUserDto, SendInviteUserDto } from './dtos/InviteUser.dto'; + +@Controller('invite') +@ApiTags('users') +export class UsersInviteController { + constructor(private readonly usersApplication: UsersApplication) {} + + /** + * Accept a user invitation. + */ + @Post('accept/:token') + @ApiOperation({ summary: 'Accept a user invitation.' }) + async acceptInvite( + @Param('token') token: string, + @Body() inviteUserDTO: InviteUserDto, + ) { + await this.usersApplication.acceptInvite(token, inviteUserDTO); + + return { + message: 'The invitation has been accepted successfully.', + }; + } + + /** + * Check if an invitation token is valid. + */ + @Get('check/:token') + @ApiOperation({ summary: 'Check if an invitation token is valid.' }) + async checkInvite(@Param('token') token: string) { + const inviteDetails = await this.usersApplication.checkInvite(token); + + return inviteDetails; + } + + /** + * Send an invitation to a new user. + */ + @Post() + @ApiOperation({ summary: 'Send an invitation to a new user.' }) + async sendInvite(@Body() sendInviteDTO: SendInviteUserDto) { + const result = await this.usersApplication.sendInvite(sendInviteDTO); + + return { + invitedUser: result.invitedUser, + message: 'The invitation has been sent successfully.', + }; + } + + /** + * Resend an invitation to an existing user. + */ + @Post('users/:id/resend') + @ApiOperation({ summary: 'Resend an invitation to an existing user.' }) + async resendInvite(@Param('id') userId: number) { + const result = await this.usersApplication.resendInvite(userId); + + return { + user: result.user, + message: 'The invitation has been resent successfully.', + }; + } +} diff --git a/packages/server/src/modules/UsersModule/commands/AcceptInviteUser.service.ts b/packages/server/src/modules/UsersModule/commands/AcceptInviteUser.service.ts new file mode 100644 index 000000000..5e948b1a0 --- /dev/null +++ b/packages/server/src/modules/UsersModule/commands/AcceptInviteUser.service.ts @@ -0,0 +1,139 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as moment from 'moment'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + IAcceptInviteEventPayload, + ICheckInviteEventPayload, +} from '../Users.types'; +import { SystemUser } from '@/modules/System/models/SystemUser'; +import { events } from '@/common/events/events'; +import { hashPassword } from '@/modules/Auth/Auth.utils'; +import { TenantModel } from '@/modules/System/models/TenantModel'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { ERRORS } from '../Users.constants'; +import { UserInvite } from '../models/InviteUser.model'; +import { ModelObject } from 'objection'; +import { InviteUserDto } from '../dtos/InviteUser.dto'; + +@Injectable() +export class AcceptInviteUserService { + constructor( + @Inject(SystemUser.name) + private readonly systemUserModel: typeof SystemUser, + + @Inject(TenantModel.name) + private readonly tenantModel: typeof TenantModel, + + @Inject(UserInvite.name) + private readonly userInviteModel: typeof UserInvite, + private readonly eventEmitter: EventEmitter2, + ) {} + + /** + * Accept the received invite. + * @param {string} token + * @param {IInviteUserInput} inviteUserInput + * @throws {ServiceErrors} + * @returns {Promise} + */ + public async acceptInvite( + token: string, + inviteUserDTO: InviteUserDto, + ): Promise { + // Retrieve the invite token or throw not found error. + const inviteToken = await this.getInviteTokenOrThrowError(token); + + // Hash the given password. + const hashedPassword = await hashPassword(inviteUserDTO.password); + + // Retrieve the system user. + const user = await this.systemUserModel + .query() + .findOne('email', inviteToken.email); + + // Sets the invited user details after invite accepting. + const systemUser = await this.systemUserModel + .query() + .updateAndFetchById(inviteToken.userId, { + ...inviteUserDTO, + inviteAcceptedAt: moment().format('YYYY-MM-DD'), + password: hashedPassword, + }); + // Clear invite token by the given user id. + await this.clearInviteTokensByUserId(inviteToken.userId); + + // Triggers `onUserAcceptInvite` event. + await this.eventEmitter.emitAsync(events.inviteUser.acceptInvite, { + inviteToken, + user: systemUser, + inviteUserDTO, + } as IAcceptInviteEventPayload); + } + + /** + * Validate the given invite token. + * @param {string} token - the given token string. + * @throws {ServiceError} + */ + public async checkInvite( + token: string, + ): Promise<{ inviteToken: ModelObject; orgName: string }> { + const inviteToken = await this.getInviteTokenOrThrowError(token); + + // Find the tenant that associated to the given token. + const tenant = await this.tenantModel + .query() + .findById(inviteToken.tenantId) + .withGraphFetched('metadata'); + + // Triggers `onUserCheckInvite` event. + await this.eventEmitter.emitAsync(events.inviteUser.checkInvite, { + inviteToken, + tenant, + } as ICheckInviteEventPayload); + + return { inviteToken, orgName: tenant.metadata.name }; + } + + /** + * Retrieve invite model from the given token or throw error. + * @param {string} token - Then given token string. + * @throws {ServiceError} + * @returns {Invite} + */ + private getInviteTokenOrThrowError = async ( + token: string, + ): Promise> => { + const inviteToken = await this.userInviteModel + .query() + .modify('notExpired') + .findOne('token', token); + + if (!inviteToken) { + throw new ServiceError(ERRORS.INVITE_TOKEN_INVALID); + } + return inviteToken; + }; + + /** + * Validate the given user email and phone number uniquine. + * @param {IInviteUserInput} inviteUserInput + */ + private validateUserPhoneNumberNotExists = async ( + phoneNumber: string, + ): Promise => { + const foundUser = await SystemUser.query().findOne({ phoneNumber }); + + if (foundUser) { + throw new ServiceError(ERRORS.PHONE_NUMBER_EXISTS); + } + }; + + /** + * Clear invite tokens of the given user id. + * @param {number} userId - User id. + */ + private clearInviteTokensByUserId = async (userId: number) => { + await this.userInviteModel.query().where('user_id', userId).delete(); + }; +} diff --git a/packages/server/src/modules/UsersModule/commands/ActivateUser.service.ts b/packages/server/src/modules/UsersModule/commands/ActivateUser.service.ts new file mode 100644 index 000000000..7543d49c8 --- /dev/null +++ b/packages/server/src/modules/UsersModule/commands/ActivateUser.service.ts @@ -0,0 +1,76 @@ +import { events } from '@/common/events/events'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; +import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model'; +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { ERRORS } from '../Users.constants'; +import { ModelObject } from 'objection'; +import { SystemUser } from '@/modules/System/models/SystemUser'; +import { ITenantUserActivatedPayload } from '../Users.types'; + +@Injectable() +export class ActivateUserService { + constructor( + @Inject(TenantUser.name) + private readonly tenantUserModel: TenantModelProxy, + private readonly tenancyContext: TenancyContext, + private readonly eventEmitter: EventEmitter2, + ) {} + + /** + * Activate the given user id. + * @param {number} userId - User id. + * @return {Promise} + */ + public async activateUser(userId: number): Promise { + const authorizedUser = await this.tenancyContext.getSystemUser(); + + // Throw service error if the given user is equals the authorized user. + this.throwErrorIfUserSameAuthorizedUser(userId, authorizedUser); + + // Retrieve the user or throw not found service error. + const tenantUser = await this.tenantUserModel().query().findById(userId); + + // Throw serivce error if the user is already activated. + this.throwErrorIfUserActive(tenantUser); + + // Marks the tenant user as active. + await this.tenantUserModel() + .query() + .findById(userId) + .update({ active: true }); + + // Triggers `onTenantUserActivated` event. + await this.eventEmitter.emitAsync(events.tenantUser.onActivated, { + userId, + tenantUser, + } as ITenantUserActivatedPayload); + } + + /** + * Throws service error in case the user was already active. + * @param {ISystemUser} user + * @throws {ServiceError} + */ + private throwErrorIfUserActive(user: ModelObject) { + if (user.active) { + throw new ServiceError(ERRORS.USER_ALREADY_ACTIVE); + } + } + + /** + * Throw service error in case the given user same the authorized user. + * @param {number} userId + * @param {ModelObject} authorizedUser + */ + private throwErrorIfUserSameAuthorizedUser( + userId: number, + authorizedUser: ModelObject, + ) { + if (userId === authorizedUser.id) { + throw new ServiceError(ERRORS.USER_SAME_THE_AUTHORIZED_USER); + } + } +} diff --git a/packages/server/src/modules/UsersModule/commands/DeleteUser.service.ts b/packages/server/src/modules/UsersModule/commands/DeleteUser.service.ts new file mode 100644 index 000000000..7678663d8 --- /dev/null +++ b/packages/server/src/modules/UsersModule/commands/DeleteUser.service.ts @@ -0,0 +1,54 @@ +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Inject, Injectable } from '@nestjs/common'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model'; +import { ITenantUserDeletedPayload } from '../Users.types'; +import { events } from '@/common/events/events'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { ERRORS } from '../Users.constants'; + +@Injectable() +export class DeleteUserService { + constructor( + @Inject(TenantUser.name) + private readonly tenantUserModel: TenantModelProxy, + private readonly eventEmitter: EventEmitter2, + ) {} + + /** + * Deletes the given user id. + * @param {number} userId - User id. + */ + async deleteUser(userId: number): Promise { + // Retrieve user details or throw not found service error. + const tenantUser = await this.tenantUserModel().query().findById(userId); + + // Validate the delete user should not be the last active user. + if (tenantUser.isInviteAccepted) { + await this.validateNotLastUserDelete(); + } + // Delete user from the storage. + await this.tenantUserModel().query().findById(userId).delete(); + + // Triggers `onTenantUserDeleted` event. + await this.eventEmitter.emitAsync(events.tenantUser.onDeleted, { + userId, + tenantUser, + } as ITenantUserDeletedPayload); + } + + /** + * Validate the delete user should not be the last user. + * @param {number} tenantId + */ + private async validateNotLastUserDelete() { + const inviteAcceptedUsers = await this.tenantUserModel() + .query() + .select(['id']) + .whereNotNull('invite_accepted_at'); + + if (inviteAcceptedUsers.length === 1) { + throw new ServiceError(ERRORS.CANNOT_DELETE_LAST_USER); + } + } +} diff --git a/packages/server/src/modules/UsersModule/commands/EditUser.service.ts b/packages/server/src/modules/UsersModule/commands/EditUser.service.ts new file mode 100644 index 000000000..1b5f66ec7 --- /dev/null +++ b/packages/server/src/modules/UsersModule/commands/EditUser.service.ts @@ -0,0 +1,102 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model'; +import { ITenantUserEditedPayload } from '../Users.types'; +import { EditUserDto } from '../dtos/EditUser.dto'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { ERRORS } from '../Users.constants'; +import { ModelObject } from 'objection'; +import { SystemUser } from '@/modules/System/models/SystemUser'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; + +@Injectable() +export class EditUserService { + constructor( + @Inject(TenantUser.name) + private readonly tenantUserModel: TenantModelProxy, + private readonly eventEmitter: EventEmitter2, + private readonly tenancyContext: TenancyContext, + ) {} + + /** + * Creates a new user. + * @param {number} userId - User id. + * @param {IUserDTO} editUserDTO - Edit user DTO. + * @return {Promise} + */ + public async editUser( + userId: number, + editUserDTO: EditUserDto, + ): Promise { + const { email } = editUserDTO; + const authorizedUser = await this.tenancyContext.getSystemUser(); + + // Retrieve the tenant user or throw not found service error. + const oldTenantUser = await this.tenantUserModel() + .query() + .findById(userId) + .throwIfNotFound(); + + // Validate cannot mutate the authorized user. + this.validateMutateRoleNotAuthorizedUser( + oldTenantUser, + editUserDTO, + authorizedUser, + ); + // Validate user email should be unique. + await this.validateUserEmailUniquiness(email, userId); + + // Updates the tenant user. + const tenantUser = await this.tenantUserModel() + .query() + .updateAndFetchById(userId, { + ...editUserDTO, + }); + // Triggers `onTenantUserEdited` event. + await this.eventEmitter.emitAsync(events.tenantUser.onEdited, { + userId, + editUserDTO, + tenantUser, + oldTenantUser, + } as ITenantUserEditedPayload); + + return tenantUser; + } + + /** + * Validate the given user email should be unique in the storage. + * @param {string} email - User email. + * @param {number} userId - User id. + */ + async validateUserEmailUniquiness(email: string, userId: number) { + const userByEmail = await this.tenantUserModel() + .query() + .findOne('email', email) + .whereNot('id', userId); + + if (userByEmail) { + throw new ServiceError(ERRORS.EMAIL_ALREADY_EXISTS); + } + } + + /** + * Validate the authorized user cannot mutate its role. + * @param {ITenantUser} oldTenantUser - Old tenant user. + * @param {IEditUserDTO} editUserDTO - Edit user dto. + * @param {ISystemUser} authorizedUser - Authorized user. + */ + validateMutateRoleNotAuthorizedUser( + oldTenantUser: ModelObject, + editUserDTO: EditUserDto, + authorizedUser: ModelObject, + ) { + if ( + authorizedUser.id === oldTenantUser.systemUserId && + editUserDTO.roleId !== oldTenantUser.roleId + ) { + throw new ServiceError(ERRORS.CANNOT_AUTHORIZED_USER_MUTATE_ROLE); + } + } +} diff --git a/packages/server/src/modules/UsersModule/commands/InactivateUser.service.ts b/packages/server/src/modules/UsersModule/commands/InactivateUser.service.ts new file mode 100644 index 000000000..173f0e822 --- /dev/null +++ b/packages/server/src/modules/UsersModule/commands/InactivateUser.service.ts @@ -0,0 +1,83 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { ModelObject } from 'objection'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model'; +import { ERRORS } from '../Users.constants'; +import { ITenantUserInactivatedPayload } from '../Users.types'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; +import { events } from '@/common/events/events'; + +@Injectable() +export class InactivateUserService { + constructor( + @Inject(TenantUser.name) + private readonly tenantUserModel: TenantModelProxy, + + private readonly eventEmitter: EventEmitter2, + private readonly tenancyContext: TenancyContext, + ) {} + + /** + * Inactivate the given user id. + * @param {number} userId + * @return {Promise} + */ + public async inactivateUser(userId: number): Promise { + const authorizedUser = await this.tenancyContext.getSystemUser(); + const authorizedTenantUser = await this.tenantUserModel() + .query() + .findOne({ systemUserId: authorizedUser.id }) + .throwIfNotFound(); + + // Throw service error if the given user is equals the authorized user. + this.throwErrorIfUserSameAuthorizedUser(userId, authorizedTenantUser); + + // Retrieve the user or throw not found service error. + const tenantUser = await this.tenantUserModel() + .query() + .findById(userId) + .throwIfNotFound(); + + // Throw serivce error if the user is already inactivated. + this.throwErrorIfUserInactive(tenantUser); + + // Marks the tenant user as active. + await this.tenantUserModel() + .query() + .findById(userId) + .update({ active: true }); + + // Triggers `onTenantUserActivated` event. + await this.eventEmitter.emitAsync(events.tenantUser.onInactivated, { + userId, + tenantUser, + } as ITenantUserInactivatedPayload); + } + + /** + * Throw service error in case the given user same the authorized user. + * @param {number} userId + * @param {ModelObject} authorizedUser + */ + private throwErrorIfUserSameAuthorizedUser( + userId: number, + authorizedUser: ModelObject, + ) { + if (userId === authorizedUser.id) { + throw new ServiceError(ERRORS.USER_SAME_THE_AUTHORIZED_USER); + } + } + + /** + * Throws service error in case the user was already inactive. + * @param {ModelObject} user + * @throws {ServiceError} + */ + private throwErrorIfUserInactive(user: ModelObject) { + if (!user.active) { + throw new ServiceError(ERRORS.USER_ALREADY_INACTIVE); + } + } +} diff --git a/packages/server/src/modules/UsersModule/commands/InviteUser.service.ts b/packages/server/src/modules/UsersModule/commands/InviteUser.service.ts new file mode 100644 index 000000000..e78d78eb6 --- /dev/null +++ b/packages/server/src/modules/UsersModule/commands/InviteUser.service.ts @@ -0,0 +1,149 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as uniqid from 'uniqid'; +import * as moment from 'moment'; +import { + IUserSendInviteDTO, + IUserInvitedEventPayload, + IUserInviteResendEventPayload, +} from '../Users.types'; +import { ERRORS } from '../Users.constants'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { Role } from '@/modules/Roles/models/Role.model'; +import { ModelObject } from 'objection'; +import { SendInviteUserDto } from '../dtos/InviteUser.dto'; + +@Injectable() +export class InviteTenantUserService { + constructor( + private readonly eventEmitter: EventEmitter2, + + @Inject(TenantUser.name) + private readonly tenantUserModel: TenantModelProxy, + + @Inject(Role.name) + private readonly roleModel: TenantModelProxy, + ) {} + + /** + * Sends invite mail to the given email from the given tenant and user. + * @param {string} email - + * @param {IUser} authorizedUser - + * @return {Promise} + */ + public async sendInvite(sendInviteDTO: SendInviteUserDto): Promise<{ + invitedUser: TenantUser; + }> { + // Get the given role or throw not found service error. + const role = await this.roleModel().query().findById(sendInviteDTO.roleId); + + // Validates the given email not exists on the storage. + await this.validateUserEmailNotExists(sendInviteDTO.email); + + // Generates a new invite token. + const inviteToken = uniqid(); + + // Creates and fetches a tenant user. + const user = await this.tenantUserModel().query().insertAndFetch({ + email: sendInviteDTO.email, + roleId: sendInviteDTO.roleId, + active: true, + invitedAt: new Date(), + }); + // Triggers `onUserSendInvite` event. + await this.eventEmitter.emitAsync(events.inviteUser.sendInvite, { + inviteToken, + user, + } as IUserInvitedEventPayload); + + return { invitedUser: user }; + } + + /** + * Re-send user invite. + * @param {number} tenantId - + * @param {string} email - + * @return {Promise<{ invite: IUserInvite }>} + */ + public async resendInvite(userId: number): Promise<{ user: ModelObject }> { + // Retrieve the user by id or throw not found service error. + const user = await this.getUserByIdOrThrowError(userId); + + // Validate the user is not invited recently. + this.validateUserInviteThrottle(user); + + // Validate the given user is not accepted yet. + this.validateInviteUserNotAccept(user); + + // Generates a new invite token. + const inviteToken = uniqid(); + + // Triggers `onUserSendInvite` event. + await this.eventEmitter.emitAsync(events.inviteUser.resendInvite, { + user, + inviteToken, + } as IUserInviteResendEventPayload); + + return { user }; + } + + /** + * Validate the given user has no active invite token. + * @param {number} tenantId + * @param {number} userId - User id. + */ + private validateInviteUserNotAccept = (user: ModelObject) => { + // Throw the error if the one invite tokens is still active. + if (user.inviteAcceptedAt) { + throw new ServiceError(ERRORS.USER_RECENTLY_INVITED); + } + }; + + /** + * Validates user invite is not invited recently before specific time point. + * @param {ITenantUser} user + */ + private validateUserInviteThrottle = (user: ModelObject) => { + const PARSE_FORMAT = 'M/D/YYYY, H:mm:ss A'; + const beforeTime = moment().subtract(5, 'minutes'); + + if (moment(user.invitedAt, PARSE_FORMAT).isAfter(beforeTime)) { + throw new ServiceError(ERRORS.USER_RECENTLY_INVITED); + } + }; + + /** + * Retrieve the given user by id or throw not found service error. + * @param {number} userId - User id. + */ + private getUserByIdOrThrowError = async ( + userId: number, + ): Promise => { + // Retrieve the tenant user. + const user = await this.tenantUserModel().query().findById(userId); + + // Throw if the user not found. + if (!user) { + throw new ServiceError(ERRORS.USER_NOT_FOUND); + } + return user; + }; + + /** + * Throws error in case the given user email not exists on the storage. + * @param {string} email + * @throws {ServiceError} + */ + private async validateUserEmailNotExists(email: string): Promise { + const foundUser = await this.tenantUserModel() + .query() + .findOne('email', email); + + if (foundUser) { + throw new ServiceError(ERRORS.EMAIL_EXISTS); + } + } +} diff --git a/packages/server/src/modules/UsersModule/commands/SendInviteUsersMailMessage.service.ts b/packages/server/src/modules/UsersModule/commands/SendInviteUsersMailMessage.service.ts new file mode 100644 index 000000000..4f0644c5e --- /dev/null +++ b/packages/server/src/modules/UsersModule/commands/SendInviteUsersMailMessage.service.ts @@ -0,0 +1,55 @@ +import * as path from 'path'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ModelObject } from 'objection'; +import { Mail } from '@/modules/Mail/Mail'; +import { MailTransporter } from '@/modules/Mail/MailTransporter.service'; +import { SystemUser } from '@/modules/System/models/SystemUser'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; +import { UserInvite } from '../models/InviteUser.model'; +import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model'; + +@Injectable() +export class SendInviteUsersMailMessage { + constructor( + private readonly mailTransporter: MailTransporter, + private readonly tenancyContext: TenancyContext, + private readonly configService: ConfigService, + ) {} + + /** + * Sends invite mail to the given email. + * @param {ModelObject} user + * @param {ModelObject} invite + */ + async sendInviteMail( + fromUser: ModelObject, + invite: ModelObject, + ) { + const tenant = await this.tenancyContext.getTenant(true); + const root = path.join(global.__views_dir, '/images/bigcapital.png'); + const baseURL = this.configService.get('baseURL'); + + const mail = new Mail() + .setSubject(`${fromUser.firstName} has invited you to join a Bigcapital`) + .setView('mail/UserInvite.html') + .setTo(invite.email) + .setAttachments([ + { + filename: 'bigcapital.png', + path: root, + cid: 'bigcapital_logo', + }, + ]) + .setData({ + root, + acceptUrl: `${baseURL}/auth/invite/${invite.token}/accept`, + fullName: `${fromUser.firstName} ${fromUser.lastName}`, + firstName: fromUser.firstName, + lastName: fromUser.lastName, + email: fromUser.email, + organizationName: tenant.metadata.name, + }); + this.mailTransporter.send(mail); + } +} diff --git a/packages/server/src/modules/UsersModule/dtos/EditUser.dto.ts b/packages/server/src/modules/UsersModule/dtos/EditUser.dto.ts new file mode 100644 index 000000000..cd67b8acb --- /dev/null +++ b/packages/server/src/modules/UsersModule/dtos/EditUser.dto.ts @@ -0,0 +1,16 @@ +import { IsEmail, IsNotEmpty } from 'class-validator'; + +export class EditUserDto { + @IsNotEmpty() + firstName: string; + + @IsNotEmpty() + lastName: string; + + @IsEmail() + @IsNotEmpty() + email: string; + + @IsNotEmpty() + roleId: number; +} diff --git a/packages/server/src/modules/UsersModule/dtos/InviteUser.dto.ts b/packages/server/src/modules/UsersModule/dtos/InviteUser.dto.ts new file mode 100644 index 000000000..e3e9dffa7 --- /dev/null +++ b/packages/server/src/modules/UsersModule/dtos/InviteUser.dto.ts @@ -0,0 +1,26 @@ +import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; + +export class InviteUserDto { + @IsString() + @IsNotEmpty() + firstName: string; + + @IsString() + @IsNotEmpty() + lastName: string; + + @IsString() + @IsNotEmpty() + password: string; +} + + +export class SendInviteUserDto { + @IsString() + @IsNotEmpty() + email: string; + + @IsNumber() + @IsNotEmpty() + roleId: number; +} \ No newline at end of file diff --git a/packages/server/src/modules/UsersModule/models/InviteUser.model.ts b/packages/server/src/modules/UsersModule/models/InviteUser.model.ts new file mode 100644 index 000000000..0ce076d9d --- /dev/null +++ b/packages/server/src/modules/UsersModule/models/InviteUser.model.ts @@ -0,0 +1,35 @@ +import moment from 'moment'; +import { BaseModel } from '@/models/Model'; + +export class UserInvite extends BaseModel { + token!: string; + userId!: number; + tenantId!: number; + email!: string; + + /** + * Table name. + */ + static get tableName() { + return 'user_invites'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt']; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + notExpired(query) { + const comp = moment().subtract(24, 'hours').toMySqlDateTime(); + query.where('created_at', '>=', comp); + }, + }; + } +} diff --git a/packages/server/src/modules/UsersModule/processors/SendInviteUserMail.processor.ts b/packages/server/src/modules/UsersModule/processors/SendInviteUserMail.processor.ts new file mode 100644 index 000000000..57453ffda --- /dev/null +++ b/packages/server/src/modules/UsersModule/processors/SendInviteUserMail.processor.ts @@ -0,0 +1,39 @@ +import { JOB_REF, Process, Processor } from '@nestjs/bull'; +import { Job } from 'bull'; +import { Inject, Scope } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { ClsService, UseCls } from 'nestjs-cls'; +import { + SendInviteUserMailJob, + SendInviteUserMailQueue, +} from '../Users.constants'; +import { SendInviteUserMailJobPayload } from '../Users.types'; +import { SendInviteUsersMailMessage } from '../commands/SendInviteUsersMailMessage.service'; + +@Processor({ + name: SendInviteUserMailQueue, + scope: Scope.REQUEST, +}) +export class SendInviteUserMailProcessor { + constructor( + private readonly sendInviteUsersMailService: SendInviteUsersMailMessage, + @Inject(REQUEST) private readonly request: Request, + @Inject(JOB_REF) + private readonly jobRef: Job, + private readonly clsService: ClsService, + ) {} + + @Process(SendInviteUserMailJob) + async handleSendInviteMail() { + const { fromUser, invite, organizationId, userId } = this.jobRef.data; + + this.clsService.set('organizationId', organizationId); + this.clsService.set('userId', userId); + + try { + await this.sendInviteUsersMailService.sendInviteMail(fromUser, invite); + } catch (error) { + console.log(error); + } + } +} diff --git a/packages/server/src/modules/UsersModule/queries/GetUser.service.ts b/packages/server/src/modules/UsersModule/queries/GetUser.service.ts new file mode 100644 index 000000000..8c294f44d --- /dev/null +++ b/packages/server/src/modules/UsersModule/queries/GetUser.service.ts @@ -0,0 +1,24 @@ +import { SystemUser } from '@/modules/System/models/SystemUser'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class GetUserService { + constructor( + @Inject(TenantUser.name) + private readonly tenantUserModel: TenantModelProxy, + ) {} + + /** + * Retrieve the given user details. + * @param {number} tenantId - Tenant id. + * @param {number} userId - User id. + */ + public async getUser(userId: number) { + // Retrieve the system user. + const user = await this.tenantUserModel().query().findById(userId); + + return user; + } +} diff --git a/packages/server/src/modules/UsersModule/queries/GetUsers.service.ts b/packages/server/src/modules/UsersModule/queries/GetUsers.service.ts new file mode 100644 index 000000000..74c9484fb --- /dev/null +++ b/packages/server/src/modules/UsersModule/queries/GetUsers.service.ts @@ -0,0 +1,26 @@ +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { Inject } from '@nestjs/common'; +import { UserTransformer } from './User.transformer'; + +export class GetUsersService { + constructor( + @Inject(TenantUser.name) + private readonly tenantUserModel: TenantModelProxy, + private readonly transformerInjectable: TransformerInjectable, + ) {} + + /** + * Retrieve users list based on the given filter. + * @param {object} filter + */ + public async getUsers() { + const users = await this.tenantUserModel().query().withGraphFetched('role'); + + return this.transformerInjectable.transform( + users, + new UserTransformer(), + ); + } +} diff --git a/packages/server/src/modules/UsersModule/queries/User.transformer.ts b/packages/server/src/modules/UsersModule/queries/User.transformer.ts new file mode 100644 index 000000000..65ac94226 --- /dev/null +++ b/packages/server/src/modules/UsersModule/queries/User.transformer.ts @@ -0,0 +1,50 @@ +import { Transformer } from '@/modules/Transformer/Transformer'; + +export class UserTransformer extends Transformer { + /** + * Exclude these attributes from user object. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['role']; + }; + + /** + * Includeded attributes. + * @returns {string[]} + */ + public includeAttributes = (): string[] => { + return ['roleName', 'roleDescription', 'roleSlug']; + }; + + /** + * Retrieves the localized role name if is predefined or stored name. + * @param role + * @returns {string} + */ + public roleName(user) { + return user.role.predefined + ? this.context.i18n.t(user.role.name) + : user.role.name; + } + + /** + * Retrieves the localized role description if is predefined or stored description. + * @param user + * @returns {string} + */ + public roleDescription(user) { + return user.role.predefined + ? this.context.i18n.t(user.role.description) + : user.role.description; + } + + /** + * Retrieves the role slug. + * @param user + * @returns {string} + */ + public roleSlug(user) { + return user.role.slug; + } +} diff --git a/packages/server/src/modules/UsersModule/subscribers/InviteSendMailNotification.subscriber.ts b/packages/server/src/modules/UsersModule/subscribers/InviteSendMailNotification.subscriber.ts new file mode 100644 index 000000000..d670dd776 --- /dev/null +++ b/packages/server/src/modules/UsersModule/subscribers/InviteSendMailNotification.subscriber.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bull'; +import { Queue } from 'bull'; +import { events } from '@/common/events/events'; +import { OnEvent } from '@nestjs/event-emitter'; +import { + IUserInviteTenantSyncedEventPayload, + SendInviteUserMailJobPayload, +} from '../Users.types'; +import { + SendInviteUserMailJob, + SendInviteUserMailQueue, +} from '../Users.constants'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; + +@Injectable() +export default class InviteSendMainNotificationSubscribe { + constructor( + @InjectQueue(SendInviteUserMailQueue) + private readonly sendInviteMailQueue: Queue, + private readonly tenancyContext: TenancyContext, + ) {} + + /** + * Sends mail notification. + * @param {IUserInvitedEventPayload} payload + */ + @OnEvent(events.inviteUser.sendInviteTenantSynced) + async sendMailNotification({ + invite, + user, + }: IUserInviteTenantSyncedEventPayload) { + const tenant = await this.tenancyContext.getTenant(); + const authedUser = await this.tenancyContext.getSystemUser(); + + const organizationId = tenant.organizationId; + const userId = authedUser.id; + + this.sendInviteMailQueue.add(SendInviteUserMailJob, { + fromUser: user, + invite, + userId, + organizationId, + } as SendInviteUserMailJobPayload); + } +} diff --git a/packages/server/src/modules/UsersModule/subscribers/PurgeUserAbilityCache.subscriber.ts b/packages/server/src/modules/UsersModule/subscribers/PurgeUserAbilityCache.subscriber.ts new file mode 100644 index 000000000..372694845 --- /dev/null +++ b/packages/server/src/modules/UsersModule/subscribers/PurgeUserAbilityCache.subscriber.ts @@ -0,0 +1,29 @@ +import { + ITenantUserInactivatedPayload, + ITenantUserActivatedPayload, + ITenantUserDeletedPayload, + ITenantUserEditedPayload, +} from '../Users.types'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Injectable } from '@nestjs/common'; +import { events } from '@/common/events/events'; +import { ABILITIES_CACHE } from '@/modules/Roles/TenantAbilities'; + +@Injectable() +export class PurgeUserAbilityCacheSubscriber { + /** + * Purges authorized user ability once the user mutate. + */ + @OnEvent(events.tenantUser.onEdited) + @OnEvent(events.tenantUser.onActivated) + @OnEvent(events.tenantUser.onInactivated) + purgeAuthorizedUserAbility({ + tenantUser, + }: + | ITenantUserInactivatedPayload + | ITenantUserActivatedPayload + | ITenantUserDeletedPayload + | ITenantUserEditedPayload) { + ABILITIES_CACHE.del(tenantUser.systemUserId); + } +} \ No newline at end of file diff --git a/packages/server/src/modules/UsersModule/subscribers/SyncSystemSendInvite.subscriber.ts b/packages/server/src/modules/UsersModule/subscribers/SyncSystemSendInvite.subscriber.ts new file mode 100644 index 000000000..1de7c337b --- /dev/null +++ b/packages/server/src/modules/UsersModule/subscribers/SyncSystemSendInvite.subscriber.ts @@ -0,0 +1,106 @@ + +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; +import { Inject, Injectable } from '@nestjs/common'; +import { + IUserInvitedEventPayload, + IUserInviteResendEventPayload, + IUserInviteTenantSyncedEventPayload, +} from '../Users.types'; +import { events } from '@/common/events/events'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model'; +import { UserInvite } from '../models/InviteUser.model'; +import { SystemUser } from '@/modules/System/models/SystemUser'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; + +@Injectable() +export class SyncSystemSendInviteSubscriber { + constructor( + @Inject(TenantUser.name) + private readonly tenantUserModel: TenantModelProxy, + + @Inject(SystemUser.name) + private readonly systemUserModel: typeof SystemUser, + + @Inject(UserInvite.name) + private readonly inviteModel: typeof UserInvite, + private readonly eventEmitter: EventEmitter2, + private readonly tenancyContext: TenancyContext + ) {} + + /** + * Syncs send invite to system user. + * @param {IUserInvitedEventPayload} payload - + */ + @OnEvent(events.inviteUser.sendInvite) + async syncSendInviteSystem({ inviteToken, user }: IUserInvitedEventPayload) { + const authorizedUser = await this.tenancyContext.getSystemUser(); + const tenantId = authorizedUser.tenantId; + + // Creates a new system user. + const systemUser = await this.systemUserModel.query().insert({ + email: user.email, + active: user.active, + tenantId, + + // Email should be verified since the user got the invite token through email. + verified: true, + }); + // Creates a invite user token. + const invite = await this.inviteModel.query().insert({ + email: user.email, + tenantId, + userId: systemUser.id, + token: inviteToken, + }); + // Links the tenant user with created system user. + await this.tenantUserModel().query().findById(user.id).patch({ + systemUserId: systemUser.id, + }); + // Triggers `onUserSendInviteTenantSynced` event. + await this.eventEmitter.emitAsync( + events.inviteUser.sendInviteTenantSynced, + { + invite, + user, + } as IUserInviteTenantSyncedEventPayload, + ); + } + + /** + * Syncs resend invite to system user. + * @param {IUserInviteResendEventPayload} payload - + */ + @OnEvent(events.inviteUser.resendInvite) + async syncResendInviteSystemUser({ + inviteToken, + user, + }: IUserInviteResendEventPayload) { + const authorizedUser = await this.tenancyContext.getSystemUser(); + const tenantId = authorizedUser.tenantId; + + // Clear all invite tokens of the given user id. + await this.clearInviteTokensByUserId(tenantId, user.systemUserId); + + const invite = await this.inviteModel.query().insert({ + email: user.email, + tenantId, + userId: user.systemUserId, + token: inviteToken, + }); + } + + /** + * Clear invite tokens of the given user id. + * @param {number} userId - User id. + */ + private clearInviteTokensByUserId = async (tenantId: number, userId: number) => { + await this.inviteModel + .query() + .where({ + userId, + tenantId, + }) + .delete(); + }; +} diff --git a/packages/server/src/modules/UsersModule/subscribers/SyncTenantAcceptInvite.subscriber.ts b/packages/server/src/modules/UsersModule/subscribers/SyncTenantAcceptInvite.subscriber.ts new file mode 100644 index 000000000..90b5ba7ff --- /dev/null +++ b/packages/server/src/modules/UsersModule/subscribers/SyncTenantAcceptInvite.subscriber.ts @@ -0,0 +1,35 @@ +import { omit } from 'lodash'; +import * as moment from 'moment'; +import { Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model'; +import { events } from '@/common/events/events'; +import { IAcceptInviteEventPayload } from '../Users.types'; + +@Injectable() +export class SyncTenantAcceptInviteSubscriber { + constructor( + @Inject(TenantUser.name) + private readonly tenantUserModel: TenantModelProxy, + ) {} + + /** + * Syncs accept invite to tenant user. + * @param {IAcceptInviteEventPayload} payload - + */ + @OnEvent(events.inviteUser.acceptInvite) + async syncTenantAcceptInvite({ + inviteToken, + user, + inviteUserDTO, + }: IAcceptInviteEventPayload) { + await this.tenantUserModel() + .query() + .where('systemUserId', inviteToken.userId) + .update({ + ...omit(inviteUserDTO, ['password']), + inviteAcceptedAt: moment().format('YYYY-MM-DD'), + }); + } +} diff --git a/packages/server/src/modules/UsersModule/subscribers/SyncTenantUserDeleted.subscriber.ts b/packages/server/src/modules/UsersModule/subscribers/SyncTenantUserDeleted.subscriber.ts new file mode 100644 index 000000000..3c5e29758 --- /dev/null +++ b/packages/server/src/modules/UsersModule/subscribers/SyncTenantUserDeleted.subscriber.ts @@ -0,0 +1,27 @@ +import { events } from '@/common/events/events'; +import { SystemUser } from '@/modules/System/models/SystemUser'; +import { Inject, Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { ITenantUserDeletedPayload } from '../Users.types'; + +@Injectable() +export class SyncTenantUserDeleteSubscriber { + constructor( + @Inject(SystemUser.name) + private readonly systemUserModel: typeof SystemUser, + ) {} + + /** + * Deletes the system user once tenant user be deleted. + * @param {ITenantUserDeletedPayload} payload - + */ + @OnEvent(events.tenantUser.onDeleted) + async syncSystemUserOnceUserDeleted({ + tenantUser, + }: ITenantUserDeletedPayload) { + await this.systemUserModel + .query() + .where('id', tenantUser.systemUserId) + .delete(); + } +} diff --git a/packages/server/src/modules/UsersModule/subscribers/SyncTenantUserSaved.subscriber.ts b/packages/server/src/modules/UsersModule/subscribers/SyncTenantUserSaved.subscriber.ts new file mode 100644 index 000000000..e3a624403 --- /dev/null +++ b/packages/server/src/modules/UsersModule/subscribers/SyncTenantUserSaved.subscriber.ts @@ -0,0 +1,69 @@ +import { pick } from 'lodash'; +import { Inject, Injectable } from '@nestjs/common'; +import { + ITenantUserActivatedPayload, + ITenantUserEditedPayload, + ITenantUserInactivatedPayload, +} from '../Users.types' +import { OnEvent } from '@nestjs/event-emitter'; +import { SystemUser } from '@/modules/System/models/SystemUser'; +import { events } from '@/common/events/events'; + +@Injectable() +export class SyncTenantUserMutateSubscriber { + constructor( + @Inject(SystemUser.name) + private readonly systemUserModel: typeof SystemUser, + ) {} + + /** + * @param {ITenantUserEditedPayload} payload + */ + @OnEvent(events.tenantUser.onEdited) + async syncSystemUserOnceEdited({ tenantUser }: ITenantUserEditedPayload) { + await this.systemUserModel + .query() + .where('id', tenantUser.systemUserId) + .patch({ + ...pick(tenantUser, [ + 'firstName', + 'lastName', + 'email', + 'active', + 'phoneNumber', + ]), + }); + } + + /** + * Syncs activate system user. + * @param {ITenantUserInactivatedPayload} payload - + */ + @OnEvent(events.tenantUser.onActivated) + async syncSystemUserOnceActivated({ + tenantUser, + }: ITenantUserInactivatedPayload) { + await this.systemUserModel + .query() + .where('id', tenantUser.systemUserId) + .patch({ + active: true, + }); + } + + /** + * Syncs inactivate system user. + * @param {ITenantUserActivatedPayload} payload - + */ + @OnEvent(events.tenantUser.onInactivated) + async syncSystemUserOnceInactivated({ + tenantUser, + }: ITenantUserActivatedPayload) { + await this.systemUserModel + .query() + .where('id', tenantUser.systemUserId) + .patch({ + active: false, + }); + } +} diff --git a/packages/webapp/src/hooks/query/misc.tsx b/packages/webapp/src/hooks/query/misc.tsx index f94f68ace..7241e31be 100644 --- a/packages/webapp/src/hooks/query/misc.tsx +++ b/packages/webapp/src/hooks/query/misc.tsx @@ -7,9 +7,9 @@ import { useRequestQuery } from '../useQueryRequest'; export function useDateFormats(props = {}) { return useRequestQuery( ['DATE_FORMATS'], - { method: 'get', url: `/date_formats` }, + { method: 'get', url: `/date-formats` }, { - select: (res) => res.data.data, + select: (res) => res.data, defaultData: [], ...props, }, diff --git a/packages/webapp/src/hooks/query/settings.tsx b/packages/webapp/src/hooks/query/settings.tsx index ae1308ffe..206cc217a 100644 --- a/packages/webapp/src/hooks/query/settings.tsx +++ b/packages/webapp/src/hooks/query/settings.tsx @@ -28,7 +28,7 @@ function useSettingsQuery(key, query, props) { key, { method: 'get', url: 'settings', params: query }, { - select: (res) => res.data.settings, + select: (res) => res.data, defaultData: [], ...props, }, @@ -170,7 +170,7 @@ export function useSettingSMSNotifications(props) { [t.SETTING_SMS_NOTIFICATIONS], { method: 'get', url: `settings/sms-notifications` }, { - select: (res) => res.data.notifications, + select: (res) => res.data, defaultData: [], ...props, }, @@ -188,7 +188,7 @@ export function useSettingSMSNotification(key, props) { url: `settings/sms-notification/${key}`, }, { - select: (res) => res.data.notification, + select: (res) => res.data, defaultData: { smsNotification: [], },