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

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