refactor(nestjs): Implement users module

This commit is contained in:
Ahmed Bouhuolia
2025-05-20 17:55:58 +02:00
parent ce058b9416
commit 99fe5a6b0d
48 changed files with 1823 additions and 207 deletions

View File

@@ -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<typeof Account>,
) {}
// /**
// * 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 });
}
}

View File

@@ -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
);
};
}

View File

@@ -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
// );
// };
// }

View File

@@ -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: [

View File

@@ -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],

View File

@@ -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')

View File

@@ -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);
}
/**

View File

@@ -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<Currency> {
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;

View File

@@ -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;

View File

@@ -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',
];

View File

@@ -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();
}
}

View File

@@ -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 {}

View File

@@ -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,
};
});
}
}

View File

@@ -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' })

View File

@@ -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<MutateBaseCurrencyLockMeta | false>}
*/
private isModelMutateLocked = async (
private async isModelMutateLocked(
Model,
): Promise<MutateBaseCurrencyLockMeta | false> => {
): Promise<MutateBaseCurrencyLockMeta | false> {
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<MutateBaseCurrencyLockMeta[]>}
*/
public async baseCurrencyMutateLocks(
tenantId: number,
): Promise<MutateBaseCurrencyLockMeta[]> {
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<boolean>}
*/
public isBaseCurrencyMutateLocked = async (tenantId: number) => {
const locks = await this.baseCurrencyMutateLocks(tenantId);
public async isBaseCurrencyMutateLocked() {
const locks = await this.baseCurrencyMutateLocks();
return !isEmpty(locks);
};
}
}

View File

@@ -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<void>}
*/
public upgradeJob = async (tenantId: number): Promise<void> => {
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<void>}
*/
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);
}
}
}

View File

@@ -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);

View File

@@ -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';

View File

@@ -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.

View File

@@ -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<void>}
*/
async activateUser(userId: number): Promise<void> {
return this.activateUserService.activateUser(userId);
}
/**
* Inactivates a user.
* @param {number} tenantId - Tenant ID.
* @param {number} userId - User ID to inactivate.
* @param {ModelObject<TenantUser>} authorizedUser - The user performing the action.
* @returns {Promise<void>}
*/
async inactivateUser(userId: number): Promise<void> {
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<void>}
*/
async editUser(userId: number, editUserDTO: EditUserDto): Promise<void> {
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<void>}
*/
async deleteUser(userId: number): Promise<void> {
return this.deleteUserService.deleteUser(userId);
}
/**
* Gets a user by ID.
* @param {number} userId - User ID to retrieve.
* @returns {Promise<any>} User details.
*/
async getUser(userId: number): Promise<any> {
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<void>}
*/
async acceptInvite(
token: string,
inviteUserDTO: InviteUserDto,
): Promise<void> {
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);
}
}

View File

@@ -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';

View File

@@ -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.',
};
}
}

View File

@@ -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 {}

View File

@@ -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<TenantUser>;
oldTenantUser: ModelObject<TenantUser>;
}
export interface ITenantUserActivatedPayload {
userId: number;
tenantUser: ModelObject<TenantUser>;
}
export interface ITenantUserInactivatedPayload {
userId: number;
tenantUser: ModelObject<TenantUser>;
}
export interface ITenantUserDeletedPayload {
userId: number;
tenantUser: ModelObject<TenantUser>;
}
export interface IUserInvitedEventPayload {
inviteToken: string;
user: ModelObject<TenantUser>;
}
export interface IUserInviteTenantSyncedEventPayload {
invite: ModelObject<UserInvite>;
user: ModelObject<TenantUser>;
}
export interface IUserInviteResendEventPayload {
inviteToken: string;
user: ModelObject<TenantUser>;
}
export interface IAcceptInviteEventPayload {
inviteToken: ModelObject<UserInvite>;
user: ModelObject<SystemUser>;
inviteUserDTO: InviteUserDto;
}
export interface ICheckInviteEventPayload {
inviteToken: ModelObject<UserInvite>;
tenant: ModelObject<TenantModel>;
}
export interface IUserSendInviteDTO {
email: string;
roleId: number;
}
export interface SendInviteUserMailJobPayload extends TenantJobPayload {
fromUser: ModelObject<TenantUser>;
invite: ModelObject<UserInvite>;
}

View File

@@ -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.',
};
}
}

View File

@@ -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<void>}
*/
public async acceptInvite(
token: string,
inviteUserDTO: InviteUserDto,
): Promise<void> {
// 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<UserInvite>; 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<ModelObject<UserInvite>> => {
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<void> => {
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();
};
}

View File

@@ -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<typeof TenantUser>,
private readonly tenancyContext: TenancyContext,
private readonly eventEmitter: EventEmitter2,
) {}
/**
* Activate the given user id.
* @param {number} userId - User id.
* @return {Promise<void>}
*/
public async activateUser(userId: number): Promise<void> {
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<TenantUser>) {
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<TenantUser>} authorizedUser
*/
private throwErrorIfUserSameAuthorizedUser(
userId: number,
authorizedUser: ModelObject<SystemUser>,
) {
if (userId === authorizedUser.id) {
throw new ServiceError(ERRORS.USER_SAME_THE_AUTHORIZED_USER);
}
}
}

View File

@@ -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<typeof TenantUser>,
private readonly eventEmitter: EventEmitter2,
) {}
/**
* Deletes the given user id.
* @param {number} userId - User id.
*/
async deleteUser(userId: number): Promise<void> {
// 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);
}
}
}

View File

@@ -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<typeof TenantUser>,
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<ISystemUser>}
*/
public async editUser(
userId: number,
editUserDTO: EditUserDto,
): Promise<any> {
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<TenantUser>,
editUserDTO: EditUserDto,
authorizedUser: ModelObject<SystemUser>,
) {
if (
authorizedUser.id === oldTenantUser.systemUserId &&
editUserDTO.roleId !== oldTenantUser.roleId
) {
throw new ServiceError(ERRORS.CANNOT_AUTHORIZED_USER_MUTATE_ROLE);
}
}
}

View File

@@ -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<typeof TenantUser>,
private readonly eventEmitter: EventEmitter2,
private readonly tenancyContext: TenancyContext,
) {}
/**
* Inactivate the given user id.
* @param {number} userId
* @return {Promise<void>}
*/
public async inactivateUser(userId: number): Promise<void> {
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<TenantUser>} authorizedUser
*/
private throwErrorIfUserSameAuthorizedUser(
userId: number,
authorizedUser: ModelObject<TenantUser>,
) {
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<TenantUser>} user
* @throws {ServiceError}
*/
private throwErrorIfUserInactive(user: ModelObject<TenantUser>) {
if (!user.active) {
throw new ServiceError(ERRORS.USER_ALREADY_INACTIVE);
}
}
}

View File

@@ -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<typeof TenantUser>,
@Inject(Role.name)
private readonly roleModel: TenantModelProxy<typeof Role>,
) {}
/**
* Sends invite mail to the given email from the given tenant and user.
* @param {string} email -
* @param {IUser} authorizedUser -
* @return {Promise<IUserInvite>}
*/
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<TenantUser> }> {
// 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<TenantUser>) => {
// 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<TenantUser>) => {
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<TenantUser> => {
// 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<void> {
const foundUser = await this.tenantUserModel()
.query()
.findOne('email', email);
if (foundUser) {
throw new ServiceError(ERRORS.EMAIL_EXISTS);
}
}
}

View File

@@ -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<TenantUser>} user
* @param {ModelObject<UserInvite>} invite
*/
async sendInviteMail(
fromUser: ModelObject<TenantUser>,
invite: ModelObject<UserInvite>,
) {
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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
},
};
}
}

View File

@@ -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<SendInviteUserMailJobPayload>,
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);
}
}
}

View File

@@ -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<typeof TenantUser>,
) {}
/**
* 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;
}
}

View File

@@ -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<typeof TenantUser>,
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(),
);
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<typeof TenantUser>,
@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();
};
}

View File

@@ -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<typeof TenantUser>,
) {}
/**
* 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'),
});
}
}

View File

@@ -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();
}
}

View File

@@ -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,
});
}
}