diff --git a/packages/server/src/modules/Accounts/MutateBaseCurrencyAccounts.ts b/packages/server/src/modules/Accounts/MutateBaseCurrencyAccounts.ts index 2a3d13d2d..c41ab1cb4 100644 --- a/packages/server/src/modules/Accounts/MutateBaseCurrencyAccounts.ts +++ b/packages/server/src/modules/Accounts/MutateBaseCurrencyAccounts.ts @@ -1,22 +1,21 @@ -// 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 + */ + mutateAllAccountsCurrency = async ( + currencyCode: string, + ) => { + await Account.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/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..4c5cc3be4 100644 --- a/packages/server/src/modules/Organization/Organization.controller.ts +++ b/packages/server/src/modules/Organization/Organization.controller.ts @@ -28,6 +28,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 +40,7 @@ export class OrganizationController { private readonly getCurrentOrgService: GetCurrentOrganizationService, private readonly updateOrganizationService: UpdateOrganizationService, private readonly getBuildOrganizationJobService: GetBuildOrganizationBuildJob, + private readonly orgBaseCurrencyLockingService: OrganizationBaseCurrencyLocking, ) {} @Post('build') @@ -81,6 +83,14 @@ export class OrganizationController { return { organization }; } + @Get('base-currency-mutate') + async baseCurrencyMutate() { + const abilities = + await this.orgBaseCurrencyLockingService.baseCurrencyMutateLocks(); + + return res.status(200).send({ 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..385c3d9e7 100644 --- a/packages/server/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.service.ts +++ b/packages/server/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.service.ts @@ -53,7 +53,6 @@ export class OrganizationBaseCurrencyLocking { * @returns {Promise} */ public async baseCurrencyMutateLocks( - tenantId: number, ): Promise { const PreventedModels = this.getModelsPreventsMutate(tenantId); 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..52987b2c1 --- /dev/null +++ b/packages/server/src/modules/UsersModule/Users.application.ts @@ -0,0 +1,123 @@ +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 } from './dtos/InviteUser.dto'; +import { GetUsersService } from './queries/GetUsers.service'; +import { InviteTenantUserService } from './commands/InviteUser.service'; +import { IUserSendInviteDTO } from './Users.types'; + +@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: IUserSendInviteDTO) { + 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..afe786e4f --- /dev/null +++ b/packages/server/src/modules/UsersModule/Users.constants.ts @@ -0,0 +1,17 @@ +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', +}; 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..640d2421a --- /dev/null +++ b/packages/server/src/modules/UsersModule/Users.controller.ts @@ -0,0 +1,161 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + Req, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { UsersApplication } from './Users.application'; +import { EditUserDto } from './dtos/EditUser.dto'; +import { InviteUserDto } from './dtos/InviteUser.dto'; +import { IUserSendInviteDTO } from './Users.types'; + +@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) { + const user = await this.usersApplication.getUser(userId); + + return { user }; + } + + /** + * Retrieve the list of users. + */ + @Get() + @ApiOperation({ summary: 'Retrieve the list of users.' }) + async listUsers( + @Query('page_size') pageSize?: number, + @Query('page') page?: number, + ) { + const users = await this.usersApplication.getUsers(); + + return { users }; + } + + /** + * 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.', + }; + } + + /** + * Accept a user invitation. + */ + @Post('invite/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('invite/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('invite') + @ApiOperation({ summary: 'Send an invitation to a new user.' }) + async sendInvite(@Body() sendInviteDTO: IUserSendInviteDTO) { + 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(':id/invite/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/Users.module.ts b/packages/server/src/modules/UsersModule/Users.module.ts new file mode 100644 index 000000000..932b3327d --- /dev/null +++ b/packages/server/src/modules/UsersModule/Users.module.ts @@ -0,0 +1,35 @@ +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 { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module'; +import { UserInvite } from './models/InviteUser.model'; + +const models = [RegisterTenancyModel(UserInvite)]; + +@Module({ + imports: [...models], + exports: [...models], + providers: [ + ActivateUserService, + DeleteUserService, + EditUserService, + InactivateUserService, + GetUserService, + PurgeUserAbilityCacheSubscriber, + SyncTenantUserDeleteSubscriber, + SyncTenantUserMutateSubscriber, + SyncSystemSendInviteSubscriber, + SyncTenantAcceptInviteSubscriber, + ], + controllers: [UsersController], +}) +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..f32627ce4 --- /dev/null +++ b/packages/server/src/modules/UsersModule/Users.types.ts @@ -0,0 +1,63 @@ +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"; + +export interface ITenantUserEditedPayload { + userId: number; + editUserDTO: EditUserDto; + tenantUser: ModelObject; + oldTenantUser: ModelObject; +} + +export interface ITenantUserActivatedPayload { + userId: number; + tenantUser: ModelObject; +} + +export interface ITenantUserInactivatedPayload { + tenantId: number; + userId: number; + authorizedUser: ModelObject; + tenantUser: ModelObject; +} + +export interface ITenantUserDeletedPayload { + userId: number; + tenantUser: ModelObject; +} + +export interface IUserInvitedEventPayload { + inviteToken: string; + user: ModelObject; +} +export interface IUserInviteTenantSyncedEventPayload { + invite: ModelObject; + authorizedUser: ModelObject; + tenantId: number; + 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; +} \ No newline at end of file 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..82daffbfd --- /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, + IInviteUserInput, + ICheckInviteEventPayload, + IUserInvite, +} 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'; + +@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: IInviteUserInput, + ): 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: IUserInvite; 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..9682369b6 --- /dev/null +++ b/packages/server/src/modules/UsersModule/commands/InactivateUser.service.ts @@ -0,0 +1,71 @@ +import { ServiceError } from '@/modules/Items/ServiceError'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { TenantUser } from '@/modules/Tenancy/TenancyModels/models/TenantUser.model'; +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { ModelObject } from 'objection'; +import { ERRORS } from '../Users.constants'; +import { ITenantUserInactivatedPayload } from '../Users.types'; +import { events } from '@/common/events/events'; + +@Injectable() +export class InactivateUserService { + constructor( + private readonly tenantUserModel: TenantModelProxy, + private readonly eventEmitter: EventEmitter2, + ) {} + /** + * Inactivate the given user id. + * @param {number} userId + * @return {Promise} + */ + public async inactivateUser(userId: number): Promise { + // 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.getTenantUserOrThrowError(tenantId, userId); + + // 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, { + tenantId, + userId, + authorizedUser, + 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..7b77d606e --- /dev/null +++ b/packages/server/src/modules/UsersModule/commands/InviteUser.service.ts @@ -0,0 +1,148 @@ +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'; + +@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: IUserSendInviteDTO): 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..831637b57 --- /dev/null +++ b/packages/server/src/modules/UsersModule/commands/SendInviteUsersMailMessage.service.ts @@ -0,0 +1,50 @@ +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 { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { ModelObject } from 'objection'; +import path from 'path'; + +@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 user + * @param invite + */ + async sendInviteMail(fromUser: ModelObject, invite: any) { + 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..43a20768f --- /dev/null +++ b/packages/server/src/modules/UsersModule/dtos/InviteUser.dto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class InviteUserDto { + @IsString() + @IsNotEmpty() + firstName: string; + + @IsString() + @IsNotEmpty() + lastName: string; + + @IsString() + @IsNotEmpty() + password: string; +} diff --git a/packages/server/src/modules/UsersModule/guards/Authorization.guard.ts b/packages/server/src/modules/UsersModule/guards/Authorization.guard.ts new file mode 100644 index 000000000..b71161e14 --- /dev/null +++ b/packages/server/src/modules/UsersModule/guards/Authorization.guard.ts @@ -0,0 +1,92 @@ +import { Request, Response, NextFunction } from 'express'; +import { Container } from 'typedi'; +import { Ability } from '@casl/ability'; +import LruCache from 'lru-cache'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { IRole, IRolePremission, ISystemUser } from '@/interfaces'; + +// store abilities of 1000 most active users +export const ABILITIES_CACHE = new LruCache(1000); + +/** + * Retrieve ability for the given role. + * @param {} role + * @returns + */ +function getAbilityForRole(role) { + const rules = getAbilitiesRolesConds(role); + return new Ability(rules); +} + +/** + * Retrieve abilities of the given role. + * @param {IRole} role + * @returns {} + */ +function getAbilitiesRolesConds(role: IRole) { + 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 {IRolePremission[]} permissions - + * @returns {} + */ +function getRulesFromRolePermissions(permissions: IRolePremission[]) { + return permissions + .filter((permission: IRolePremission) => permission.value) + .map((permission: IRolePremission) => { + return { + action: permission.ability, + subject: permission.subject, + }; + }); +} + +/** + * Retrieve ability for user. + * @param {ISystemUser} user + * @param {number} tenantId + * @returns {} + */ +async function getAbilityForUser(user: ISystemUser, tenantId: number) { + const tenancy = Container.get(HasTenancyService); + const { User } = tenancy.models(tenantId); + + const tenantUser = await User.query() + .findOne('systemUserId', user.id) + .withGraphFetched('role.permissions'); + + return getAbilityForRole(tenantUser.role); +} + +/** + * + * @param {Request} request - + * @param {Response} response - + * @param {NextFunction} next - + */ +export default async (req: Request, res: Response, next: NextFunction) => { + const { tenantId, user } = req; + + if (ABILITIES_CACHE.has(req.user.id)) { + req.ability = ABILITIES_CACHE.get(req.user.id); + } else { + req.ability = await getAbilityForUser(req.user, tenantId); + ABILITIES_CACHE.set(req.user.id, req.ability); + } + next(); +}; \ 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/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..158829b4f --- /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.__(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.__(user.role.description) + : user.role.description; + } + + /** + * Retrieves the role slug. + * @param user + * @returns {string} + */ + public roleSlug(user) { + return user.role.slug; + } +} \ No newline at end of file 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..c2aa1f7ee --- /dev/null +++ b/packages/server/src/modules/UsersModule/subscribers/InviteSendMailNotification.subscriber.ts @@ -0,0 +1,24 @@ +import { events } from '@/common/events/events'; +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { IUserInviteTenantSyncedEventPayload } from '../Users.types'; + +@Injectable() +export default class InviteSendMainNotificationSubscribe { + /** + * Sends mail notification. + * @param {IUserInvitedEventPayload} payload + */ + @OnEvent(events.inviteUser.sendInviteTenantSynced) + private sendMailNotification( + payload: IUserInviteTenantSyncedEventPayload + ) { + const { invite, authorizedUser, tenantId } = payload; + + this.agenda.now('user-invite-mail', { + invite, + authorizedUser, + tenantId, + }); + }; +} \ No newline at end of file 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..eadb1ca5e --- /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 { ABILITIES_CACHE } from '../../api/middleware/AuthorizationMiddleware'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Injectable } from '@nestjs/common'; +import { events } from '@/common/events/events'; + +@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..4eaa68280 --- /dev/null +++ b/packages/server/src/modules/UsersModule/subscribers/SyncSystemSendInvite.subscriber.ts @@ -0,0 +1,109 @@ +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'; + +@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, + ) {} + + /** + * Syncs send invite to system user. + * @param {IUserInvitedEventPayload} payload - + */ + @OnEvent(events.inviteUser.sendInvite) + async syncSendInviteSystem({ + inviteToken, + user, + tenantId, + authorizedUser, + }: IUserInvitedEventPayload) { + // 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, + tenantId, + user, + authorizedUser, + } as IUserInviteTenantSyncedEventPayload, + ); + } + + /** + * Syncs resend invite to system user. + * @param {IUserInviteResendEventPayload} payload - + */ + @OnEvent(events.inviteUser.resendInvite) + async syncResendInviteSystemUser({ + inviteToken, + authorizedUser, + tenantId, + user, + }: IUserInviteResendEventPayload) { + // Clear all invite tokens of the given user id. + await this.clearInviteTokensByUserId(user.systemUserId, tenantId); + + 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 ( + userId: number, + tenantId: 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: [], },