Files
backend/src/invite-user/services/invite-user.service.ts
ZaydSkaff e5970c02c1 SP-1757, SP-1758, SP-1809, SP-1810: Feat/implement booking (#469)
* fix: commission device API

* task: add create booking API

* add get All api for dashboard & mobile

* add Find APIs for bookings

* implement sending email updates on update bookable space

* move email interfaces to separate files
2025-07-15 10:11:36 +03:00

905 lines
26 KiB
TypeScript

import { RoleType } from '@app/common/constants/role.type.enum';
import { UserStatusEnum } from '@app/common/constants/user-status.enum';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { generateRandomString } from '@app/common/helper/randomString';
import { InviteUserEntity } from '@app/common/modules/Invite-user/entities';
import {
InviteUserRepository,
InviteUserSpaceRepository,
} from '@app/common/modules/Invite-user/repositiories';
import { ProjectEntity } from '@app/common/modules/project/entities';
import { RoleTypeEntity } from '@app/common/modules/role-type/entities';
import { RoleTypeRepository } from '@app/common/modules/role-type/repositories';
import { SpaceRepository } from '@app/common/modules/space';
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
import { UserEntity } from '@app/common/modules/user/entities';
import { UserRepository } from '@app/common/modules/user/repositories';
import { EmailService } from '@app/common/util/email/email.service';
import {
BadRequestException,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { SpaceUserService } from 'src/space/services';
import { UserSpaceService } from 'src/users/services';
import {
DataSource,
EntityManager,
In,
IsNull,
Not,
QueryRunner,
} from 'typeorm';
import { AddUserInvitationDto } from '../dtos';
import { ActivateCodeDto } from '../dtos/active-code.dto';
import { CheckEmailDto } from '../dtos/check-email.dto';
import {
DisableUserInvitationDto,
UpdateUserInvitationDto,
} from '../dtos/update.invite-user.dto';
@Injectable()
export class InviteUserService {
constructor(
private readonly inviteUserRepository: InviteUserRepository,
private readonly userRepository: UserRepository,
private readonly emailService: EmailService,
private readonly inviteUserSpaceRepository: InviteUserSpaceRepository,
private readonly userSpaceService: UserSpaceService,
private readonly spaceUserService: SpaceUserService,
private readonly spaceRepository: SpaceRepository,
private readonly roleTypeRepository: RoleTypeRepository,
private readonly dataSource: DataSource,
) {}
async createUserInvitation(
dto: AddUserInvitationDto,
roleType: RoleType,
): Promise<BaseResponseDto> {
const {
firstName,
lastName,
email,
jobTitle,
companyName,
phoneNumber,
roleUuid,
spaceUuids,
projectUuid,
} = dto;
const invitationCode = generateRandomString(6);
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.startTransaction();
try {
const userRepo = queryRunner.manager.getRepository(UserEntity);
await this.checkEmailAndProject({ email });
const existingUser = await userRepo.findOne({
where: {
email,
project: Not(IsNull()),
},
});
if (existingUser) {
throw new HttpException(
'User already has a project',
HttpStatus.BAD_REQUEST,
);
}
await this.checkRole(roleUuid, queryRunner);
await this.checkProject(projectUuid, queryRunner);
// Validate spaces
const validSpaces = await this.validateSpaces(
spaceUuids,
queryRunner.manager,
);
// Create invitation
const inviteUser = this.inviteUserRepository.create({
firstName,
lastName,
email,
jobTitle,
companyName,
phoneNumber,
roleType: { uuid: roleUuid },
status: UserStatusEnum.INVITED,
invitationCode,
invitedBy: roleType,
project: { uuid: projectUuid },
});
const invitedUser = await queryRunner.manager.save(inviteUser);
const invitedRoleType = await this.getRoleTypeByUuid(roleUuid);
// Link user to spaces
const spacePromises = validSpaces.map(async (space) => {
const inviteUserSpace = this.inviteUserSpaceRepository.create({
inviteUser: { uuid: invitedUser.uuid },
space: { uuid: space.uuid },
});
return queryRunner.manager.save(inviteUserSpace);
});
await Promise.all(spacePromises);
// Send invitation email
const spaceNames = validSpaces.map((space) => space.spaceName).join(', ');
await this.emailService.sendEmailWithInvitationTemplate(email, {
name: firstName,
invitationCode,
role: invitedRoleType.replace(/_/g, ' '),
spacesList: spaceNames,
});
await queryRunner.commitTransaction();
return new SuccessResponseDto({
statusCode: HttpStatus.CREATED,
success: true,
data: {
invitationCode: invitedUser.invitationCode,
},
message: 'User invited successfully',
});
} catch (error) {
await queryRunner.rollbackTransaction();
if (error instanceof HttpException) {
throw error;
}
console.error('Error creating user invitation:', error);
throw new HttpException(
error.message || 'An unexpected error occurred while inviting the user',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
await queryRunner.release();
}
}
async updateUserInvitation(
dto: UpdateUserInvitationDto,
invitedUserUuid: string,
): Promise<BaseResponseDto> {
const { projectUuid, spaceUuids } = dto;
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.startTransaction();
try {
// Fetch the user's existing data in the project
const userOldData = await this.inviteUserRepository.findOne({
where: { uuid: invitedUserUuid, project: { uuid: projectUuid } },
relations: ['project', 'spaces.space', 'roleType'],
});
if (!userOldData) {
throw new HttpException('User not found', HttpStatus.NOT_FOUND);
}
await this.checkRole(dto.roleUuid, queryRunner);
await this.checkProject(projectUuid, queryRunner);
// Perform update actions if status is 'INVITED'
if (userOldData.status === UserStatusEnum.INVITED) {
await this.updateWhenUserIsInvite(queryRunner, dto, invitedUserUuid);
} else if (userOldData.status === UserStatusEnum.ACTIVE) {
await this.updateWhenUserIsInvite(queryRunner, dto, invitedUserUuid);
await this.updateWhenUserIsActive(queryRunner, dto, invitedUserUuid);
}
// Extract existing space UUIDs
const oldSpaceUuids = userOldData.spaces.map((space) => space.space.uuid);
// Compare spaces
const addedSpaces = spaceUuids.filter(
(uuid) => !oldSpaceUuids.includes(uuid),
);
const removedSpaces = oldSpaceUuids.filter(
(uuid) => !spaceUuids.includes(uuid),
);
// Fetch the space names for added and removed spaces
const spaceRepo = queryRunner.manager.getRepository(SpaceEntity);
const addedSpacesDetails = await spaceRepo.find({
where: {
uuid: In(addedSpaces),
},
});
const removedSpacesDetails = await spaceRepo.find({
where: {
uuid: In(removedSpaces),
},
});
// Extract the names of the added and removed spaces
const addedSpaceNames = addedSpacesDetails.map(
(space) => space.spaceName,
);
const removedSpaceNames = removedSpacesDetails.map(
(space) => space.spaceName,
);
// Check for role and name change
const oldRole = userOldData.roleType.type;
const newRole = await this.getRoleTypeByUuid(dto.roleUuid);
const oldFullName = `${userOldData.firstName} ${userOldData.lastName}`;
const newFullName = `${dto.firstName} ${dto.lastName}`;
// Generate email body
const emailMessage = this.emailService.generateUserChangesEmailBody(
addedSpaceNames,
removedSpaceNames,
oldRole,
newRole,
oldFullName,
newFullName,
);
await this.emailService.sendEditUserEmailWithTemplate(userOldData.email, {
name: dto.firstName || userOldData.firstName,
...emailMessage,
});
// Proceed with other updates (e.g., roles, names, etc.)
await queryRunner.commitTransaction();
return new SuccessResponseDto({
statusCode: HttpStatus.OK,
success: true,
message: 'User invitation updated successfully',
});
} catch (error) {
await queryRunner.rollbackTransaction();
// Throw an appropriate HTTP exception
throw error instanceof HttpException
? error
: new HttpException(
'An unexpected error occurred while updating the user',
HttpStatus.INTERNAL_SERVER_ERROR,
);
} finally {
await queryRunner.release();
}
}
async disableUserInvitation(
dto: DisableUserInvitationDto,
invitedUserUuid: string,
): Promise<BaseResponseDto> {
const { disable, projectUuid } = dto;
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.startTransaction();
try {
const userData = await this.inviteUserRepository.findOne({
where: { uuid: invitedUserUuid, project: { uuid: projectUuid } },
relations: ['roleType', 'spaces.space'],
});
if (!userData) {
throw new HttpException('User not found', HttpStatus.NOT_FOUND);
}
if (userData.status === UserStatusEnum.INVITED) {
await this.updateUserStatus(invitedUserUuid, projectUuid, !disable);
} else if (userData.status === UserStatusEnum.ACTIVE) {
const invitedUserData = await this.inviteUserRepository.findOne({
where: { uuid: invitedUserUuid },
relations: [
'user.userSpaces.space',
'user.userSpaces.space.community',
],
});
if (!invitedUserData.user) {
throw new HttpException(
'User account not found',
HttpStatus.NOT_FOUND,
);
}
if (disable) {
await this.disassociateUserFromSpaces(
invitedUserData.user,
projectUuid,
);
await this.updateUserStatus(invitedUserUuid, projectUuid, !disable);
} else if (!disable) {
await this.associateUserToSpaces(
invitedUserData.user,
userData,
projectUuid,
invitedUserUuid,
!disable,
);
}
} else {
throw new HttpException(
'Invalid user status or action',
HttpStatus.BAD_REQUEST,
);
}
await this.emailService.sendEmailWithTemplate({
email: userData.email,
name: userData.firstName,
isEnable: !disable,
isDelete: false,
});
await queryRunner.commitTransaction();
return new SuccessResponseDto({
statusCode: HttpStatus.OK,
success: true,
message: 'User invitation status updated successfully',
});
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
async deleteUserInvitation(
invitedUserUuid: string,
): Promise<BaseResponseDto> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.startTransaction();
try {
const userData = await this.inviteUserRepository.findOne({
where: { uuid: invitedUserUuid },
relations: ['roleType', 'spaces.space', 'project'],
});
if (!userData) {
throw new HttpException('User not found', HttpStatus.NOT_FOUND);
}
if (userData.status === UserStatusEnum.INVITED) {
await this.inviteUserRepository.update(
{ uuid: invitedUserUuid },
{ isActive: false },
);
} else if (userData.status === UserStatusEnum.ACTIVE) {
const invitedUserData = await this.inviteUserRepository.findOne({
where: { uuid: invitedUserUuid },
relations: [
'user.userSpaces.space',
'user.userSpaces.space.community',
],
});
if (!invitedUserData.user) {
throw new HttpException(
'User account not found',
HttpStatus.NOT_FOUND,
);
}
await this.disassociateUserFromSpaces(
invitedUserData.user,
userData.project.uuid,
);
await this.inviteUserRepository.update(
{ uuid: invitedUserUuid },
{ isActive: false },
);
await this.userRepository.update(
{ uuid: invitedUserData.user.uuid },
{ isActive: false },
);
}
await this.emailService.sendEmailWithTemplate({
email: userData.email,
name: userData.firstName,
isEnable: false,
isDelete: true,
});
await queryRunner.commitTransaction();
return new SuccessResponseDto({
statusCode: HttpStatus.OK,
success: true,
message: 'User invitation deleted successfully',
});
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
async activationCode(dto: ActivateCodeDto): Promise<BaseResponseDto> {
const { activationCode, userUuid } = dto;
try {
const user = await this.getUser(userUuid);
const invitedUser = await this.inviteUserRepository.findOne({
where: {
email: user.email,
status: UserStatusEnum.INVITED,
isActive: true,
},
relations: ['project', 'spaces.space.community', 'roleType'],
});
if (invitedUser) {
if (invitedUser.invitationCode !== activationCode) {
throw new HttpException(
'Invalid activation code',
HttpStatus.BAD_REQUEST,
);
}
// Handle invited user with valid activation code
await this.handleInvitedUser(user, invitedUser);
} else {
// Handle case for non-invited user
await this.handleNonInvitedUser(activationCode, userUuid);
}
return new SuccessResponseDto({
statusCode: HttpStatus.OK,
success: true,
message: 'The code has been successfully activated',
});
} catch (error) {
console.error('Error activating the code:', error);
throw new HttpException(
error.message ||
'An unexpected error occurred while activating the code',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async checkEmailAndProject(dto: CheckEmailDto): Promise<BaseResponseDto> {
const { email } = dto;
try {
const user = await this.userRepository.findOne({
where: { email },
relations: ['project'],
});
this.validateUserOrInvite(user, 'User');
const invitedUser = await this.inviteUserRepository.findOne({
where: { email },
relations: ['project'],
});
this.validateUserOrInvite(invitedUser, 'Invited User');
return new SuccessResponseDto({
statusCode: HttpStatus.OK,
success: true,
message: 'Valid email',
});
} catch (error) {
console.error('Error checking email and project:', error);
throw new HttpException(
error.message ||
'An unexpected error occurred while checking the email',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private async getSpaceByUuid(spaceUuid: string) {
try {
const space = await this.spaceRepository.findOne({
where: {
uuid: spaceUuid,
},
relations: ['community'],
});
if (!space) {
throw new BadRequestException(`Invalid space UUID ${spaceUuid}`);
}
return {
uuid: space.uuid,
createdAt: space.createdAt,
updatedAt: space.updatedAt,
name: space.spaceName,
spaceTuyaUuid: space.community.externalId,
communityUuid: space.community.uuid,
};
} catch (err) {
if (err instanceof BadRequestException) {
throw err; // Re-throw BadRequestException
} else {
throw new HttpException('Space not found', HttpStatus.NOT_FOUND);
}
}
}
private async validateSpaces(
spaceUuids: string[],
entityManager: EntityManager,
): Promise<SpaceEntity[]> {
const spaceRepo = entityManager.getRepository(SpaceEntity);
const validSpaces = await spaceRepo.find({
where: { uuid: In(spaceUuids) },
});
if (validSpaces.length !== spaceUuids.length) {
const validSpaceUuids = validSpaces.map((space) => space.uuid);
const invalidSpaceUuids = spaceUuids.filter(
(uuid) => !validSpaceUuids.includes(uuid),
);
throw new HttpException(
`Invalid space UUIDs: ${invalidSpaceUuids.join(', ')}`,
HttpStatus.BAD_REQUEST,
);
}
return validSpaces;
}
private async checkRole(
roleUuid: string,
queryRunner: QueryRunner,
): Promise<BaseResponseDto> {
try {
const role = await queryRunner.manager.findOne(RoleTypeEntity, {
where: { uuid: roleUuid },
});
if (!role) {
throw new HttpException('Role not found', HttpStatus.NOT_FOUND);
}
return new SuccessResponseDto({
statusCode: HttpStatus.OK,
success: true,
message: 'Valid role',
});
} catch (error) {
console.error('Error checking role:', error);
throw new HttpException(
error.message || 'An unexpected error occurred while checking the role',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private async checkProject(
projectUuid: string,
queryRunner: QueryRunner,
): Promise<BaseResponseDto> {
try {
const project = await queryRunner.manager.findOne(ProjectEntity, {
where: { uuid: projectUuid },
});
if (!project) {
throw new HttpException('Project not found', HttpStatus.NOT_FOUND);
}
return new SuccessResponseDto({
statusCode: HttpStatus.OK,
success: true,
message: 'Valid project',
});
} catch (error) {
console.error('Error checking project:', error);
throw new HttpException(
error.message ||
'An unexpected error occurred while checking the project',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private validateUserOrInvite(user: any, userType: string): void {
if (user) {
if (!user.isActive) {
throw new HttpException(
`${userType} is deleted`,
HttpStatus.BAD_REQUEST,
);
}
if (user.project) {
throw new HttpException(
`${userType} already has a project`,
HttpStatus.BAD_REQUEST,
);
}
}
}
private async getUser(userUuid: string): Promise<UserEntity> {
const user = await this.userRepository.findOne({
where: { uuid: userUuid, isActive: true, isUserVerified: true },
});
if (!user) {
throw new HttpException('User not found', HttpStatus.NOT_FOUND);
}
return user;
}
private async handleNonInvitedUser(
activationCode: string,
userUuid: string,
): Promise<void> {
await this.userSpaceService.verifyCodeAndAddUserSpace(
{ inviteCode: activationCode },
userUuid,
);
}
private async handleInvitedUser(
user: UserEntity,
invitedUser: InviteUserEntity,
): Promise<void> {
for (const invitedSpace of invitedUser.spaces) {
try {
const deviceUUIDs = await this.userSpaceService.getDeviceUUIDsForSpace(
invitedSpace.space.uuid,
);
await this.userSpaceService.addUserPermissionsToDevices(
user.uuid,
deviceUUIDs,
);
await this.spaceUserService.associateUserToSpace({
communityUuid: invitedSpace.space.community.uuid,
spaceUuid: invitedSpace.space.uuid,
userUuid: user.uuid,
projectUuid: invitedUser.project.uuid,
});
} catch (spaceError) {
console.error(
`Error processing space ${invitedSpace.space.uuid}:`,
spaceError,
);
continue; // Skip to the next space
}
}
// Update invited user and associated user data
await this.inviteUserRepository.update(
{ uuid: invitedUser.uuid },
{ status: UserStatusEnum.ACTIVE, user: { uuid: user.uuid } },
);
await this.userRepository.update(
{ uuid: user.uuid },
{
project: { uuid: invitedUser.project.uuid },
roleType: { uuid: invitedUser.roleType.uuid },
},
);
}
private async getRoleTypeByUuid(roleUuid: string) {
const role = await this.roleTypeRepository.findOne({
where: { uuid: roleUuid },
});
return role.type;
}
private async updateWhenUserIsInvite(
queryRunner: QueryRunner,
dto: UpdateUserInvitationDto,
invitedUserUuid: string,
): Promise<void> {
const {
firstName,
lastName,
jobTitle,
companyName,
phoneNumber,
roleUuid,
spaceUuids,
} = dto;
// Update user invitation details
await queryRunner.manager.update(
this.inviteUserRepository.target,
{ uuid: invitedUserUuid },
{
firstName,
lastName,
jobTitle,
companyName,
phoneNumber,
roleType: { uuid: roleUuid },
},
);
// Remove old space associations
await queryRunner.manager.delete(this.inviteUserSpaceRepository.target, {
inviteUser: { uuid: invitedUserUuid },
});
// Save new space associations
const spaceData = spaceUuids.map((spaceUuid) => ({
inviteUser: { uuid: invitedUserUuid },
space: { uuid: spaceUuid },
}));
await queryRunner.manager.save(
this.inviteUserSpaceRepository.target,
spaceData,
);
}
private async updateWhenUserIsActive(
queryRunner: QueryRunner,
dto: UpdateUserInvitationDto,
invitedUserUuid: string,
): Promise<void> {
const {
firstName,
lastName,
jobTitle,
companyName,
phoneNumber,
roleUuid,
spaceUuids,
projectUuid,
} = dto;
const userData = await this.inviteUserRepository.findOne({
where: { uuid: invitedUserUuid },
relations: ['user.userSpaces.space', 'user.userSpaces.space.community'],
});
if (!userData) {
throw new HttpException(
`User with invitedUserUuid ${invitedUserUuid} not found`,
HttpStatus.NOT_FOUND,
);
}
// Update user details
await queryRunner.manager.update(
this.inviteUserRepository.target,
{ uuid: invitedUserUuid },
{
firstName,
lastName,
jobTitle,
companyName,
phoneNumber,
roleType: { uuid: roleUuid },
},
);
await this.userRepository.update(
{ uuid: userData.user.uuid },
{
roleType: { uuid: roleUuid },
},
);
// Disassociate the user from all current spaces
const disassociatePromises = userData.user.userSpaces.map((userSpace) =>
this.spaceUserService
.disassociateUserFromSpace({
communityUuid: userSpace.space.community.uuid,
spaceUuid: userSpace.space.uuid,
userUuid: userData.user.uuid,
projectUuid,
})
.catch((error) => {
console.error(
`Failed to disassociate user from space ${userSpace.space.uuid}:`,
error,
);
throw error;
}),
);
await Promise.allSettled(disassociatePromises);
// Process new spaces
const associatePromises = spaceUuids.map(async (spaceUuid) => {
try {
// Fetch space details
const spaceDetails = await this.getSpaceByUuid(spaceUuid);
// Fetch device UUIDs for the space
const deviceUUIDs =
await this.userSpaceService.getDeviceUUIDsForSpace(spaceUuid);
// Grant permissions to the user for all devices in the space
await this.userSpaceService.addUserPermissionsToDevices(
userData.user.uuid,
deviceUUIDs,
);
// Associate the user with the new space
await this.spaceUserService.associateUserToSpace({
communityUuid: spaceDetails.communityUuid,
spaceUuid: spaceUuid,
userUuid: userData.user.uuid,
projectUuid,
});
} catch (error) {
console.error(`Failed to process space ${spaceUuid}:`, error);
throw error;
}
});
await Promise.all(associatePromises);
}
private async updateUserStatus(
invitedUserUuid: string,
projectUuid: string,
isEnabled: boolean,
) {
await this.inviteUserRepository.update(
{ uuid: invitedUserUuid, project: { uuid: projectUuid } },
{ isEnabled },
);
}
private async disassociateUserFromSpaces(user: any, projectUuid: string) {
const disassociatePromises = user.userSpaces.map((userSpace) =>
this.spaceUserService.disassociateUserFromSpace({
communityUuid: userSpace.space.community.uuid,
spaceUuid: userSpace.space.uuid,
userUuid: user.uuid,
projectUuid,
}),
);
const results = await Promise.allSettled(disassociatePromises);
results.forEach((result, index) => {
if (result.status === 'rejected') {
console.error(
`Failed to disassociate user from space ${user.userSpaces[index].space.uuid}:`,
result.reason,
);
}
});
}
private async associateUserToSpaces(
user: any,
userData: any,
projectUuid: string,
invitedUserUuid: string,
disable: boolean,
) {
const spaceUuids = userData.spaces.map((space) => space.space.uuid);
const associatePromises = spaceUuids.map(async (spaceUuid) => {
try {
const spaceDetails = await this.getSpaceByUuid(spaceUuid);
const deviceUUIDs =
await this.userSpaceService.getDeviceUUIDsForSpace(spaceUuid);
await this.userSpaceService.addUserPermissionsToDevices(
user.uuid,
deviceUUIDs,
);
await this.spaceUserService.associateUserToSpace({
communityUuid: spaceDetails.communityUuid,
spaceUuid,
userUuid: user.uuid,
projectUuid,
});
await this.updateUserStatus(invitedUserUuid, projectUuid, disable);
} catch (error) {
console.error(`Failed to associate user to space ${spaceUuid}:`, error);
}
});
await Promise.allSettled(associatePromises);
}
}