import { ORPHAN_COMMUNITY_NAME, ORPHAN_SPACE_NAME, } from '@app/common/constants/orphan-constant'; 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 { removeCircularReferences } from '@app/common/helper/removeCircularReferences'; import { SpaceProductAllocationEntity } from '@app/common/modules/space/entities/space-product-allocation.entity'; import { SpaceEntity } from '@app/common/modules/space/entities/space.entity'; import { SubspaceEntity } from '@app/common/modules/space/entities/subspace/subspace.entity'; import { InviteSpaceRepository, SpaceRepository, } from '@app/common/modules/space/repositories'; import { BadRequestException, HttpException, HttpStatus, Injectable, } from '@nestjs/common'; import { CommandBus } from '@nestjs/cqrs'; import { DeviceService } from 'src/device/services'; import { SpaceModelService } from 'src/space-model/services'; import { ProcessTagDto } from 'src/tags/dtos'; import { TagService } from 'src/tags/services/tags.service'; import { DataSource, In, Not, QueryRunner } from 'typeorm'; import { DisableSpaceCommand } from '../commands'; import { AddSpaceDto, CommunitySpaceParam, GetSpaceParam, UpdateSpaceDto, } from '../dtos'; import { GetSpaceDto } from '../dtos/get.space.dto'; import { SpaceWithParentsDto } from '../dtos/space.parents.dto'; import { SpaceLinkService } from './space-link'; import { SpaceProductAllocationService } from './space-product-allocation.service'; import { ValidationService } from './space-validation.service'; import { SubSpaceService } from './subspace'; @Injectable() export class SpaceService { constructor( private readonly dataSource: DataSource, private readonly spaceRepository: SpaceRepository, private readonly inviteSpaceRepository: InviteSpaceRepository, private readonly spaceLinkService: SpaceLinkService, private readonly subSpaceService: SubSpaceService, private readonly validationService: ValidationService, private readonly tagService: TagService, private readonly spaceModelService: SpaceModelService, private readonly deviceService: DeviceService, private commandBus: CommandBus, private readonly spaceProductAllocationService: SpaceProductAllocationService, ) {} async createSpace( addSpaceDto: AddSpaceDto, params: CommunitySpaceParam, ): Promise { const { parentUuid, direction, spaceModelUuid, subspaces, tags } = addSpaceDto; const { communityUuid, projectUuid } = params; const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); const { community } = await this.validationService.validateCommunityAndProject( communityUuid, projectUuid, ); this.validateSpaceCreationCriteria({ spaceModelUuid, subspaces, tags }); const parent = parentUuid ? await this.validationService.validateSpace(parentUuid) : null; const spaceModel = spaceModelUuid ? await this.validationService.validateSpaceModel(spaceModelUuid) : null; try { const space = queryRunner.manager.create(SpaceEntity, { ...addSpaceDto, spaceModel, parent: parentUuid ? parent : null, community, }); const newSpace = await queryRunner.manager.save(space); const subspaceTags = subspaces?.flatMap((subspace) => subspace.tags || []) || []; this.checkDuplicateTags([...tags, ...subspaceTags]); if (spaceModelUuid) { // no need to check for existing dependencies here as validateSpaceCreationCriteria // ensures no tags or subspaces are present along with spaceModelUuid await this.spaceModelService.linkToSpace( newSpace, spaceModel, queryRunner, ); } await Promise.all([ // todo: remove this logic as we are not using space links anymore direction && parent ? this.spaceLinkService.saveSpaceLink( parent.uuid, newSpace.uuid, direction, queryRunner, ) : Promise.resolve(), subspaces?.length ? this.subSpaceService.createSubspacesFromDto( subspaces, space, queryRunner, projectUuid, ) : Promise.resolve(), tags?.length ? this.createAllocations(tags, projectUuid, queryRunner, newSpace) : Promise.resolve(), ]); await queryRunner.commitTransaction(); return new SuccessResponseDto({ statusCode: HttpStatus.CREATED, data: JSON.parse(JSON.stringify(newSpace, removeCircularReferences())), message: 'Space created successfully', }); } catch (error) { await queryRunner.rollbackTransaction(); if (error instanceof HttpException) { throw error; } throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); } finally { await queryRunner.release(); } } private checkDuplicateTags(allTags: ProcessTagDto[]) { const tagUuidSet = new Set(); const tagNameProductSet = new Set(); for (const tag of allTags) { if (tag.uuid) { if (tagUuidSet.has(tag.uuid)) { throw new HttpException( `Duplicate tag UUID found: ${tag.uuid}`, HttpStatus.BAD_REQUEST, ); } tagUuidSet.add(tag.uuid); } else { const tagKey = `${tag.name}-${tag.productUuid}`; if (tagNameProductSet.has(tagKey)) { throw new HttpException( `Duplicate tag found with name "${tag.name}" and product "${tag.productUuid}".`, HttpStatus.BAD_REQUEST, ); } tagNameProductSet.add(tagKey); } } } async getSpacesHierarchyForCommunity( params: CommunitySpaceParam, getSpaceDto?: GetSpaceDto & { search?: string }, ): Promise { const { communityUuid, projectUuid } = params; const { onlyWithDevices, search } = getSpaceDto; await this.validationService.validateCommunityAndProject( communityUuid, projectUuid, ); try { const queryBuilder = this.spaceRepository .createQueryBuilder('space') .leftJoinAndSelect('space.parent', 'parent') .leftJoinAndSelect( 'space.children', 'children', 'children.disabled = :disabled', { disabled: false }, ) .leftJoinAndSelect( 'space.incomingConnections', 'incomingConnections', 'incomingConnections.disabled = :incomingConnectionDisabled', { incomingConnectionDisabled: false }, ) .leftJoinAndSelect('space.productAllocations', 'productAllocations') .leftJoinAndSelect('productAllocations.tag', 'tag') .leftJoinAndSelect('productAllocations.product', 'product') .leftJoinAndSelect( 'space.subspaces', 'subspaces', 'subspaces.disabled = :subspaceDisabled', { subspaceDisabled: false }, ) .leftJoinAndSelect( 'subspaces.productAllocations', 'subspaceProductAllocations', ) .leftJoinAndSelect('subspaceProductAllocations.tag', 'subspaceTag') .leftJoinAndSelect( 'subspaceProductAllocations.product', 'subspaceProduct', ) .leftJoinAndSelect('space.spaceModel', 'spaceModel') .where('space.community_id = :communityUuid', { communityUuid }) .andWhere('space.spaceName != :orphanSpaceName', { orphanSpaceName: ORPHAN_SPACE_NAME, }) .andWhere('space.disabled = :disabled', { disabled: false }); if (search) { queryBuilder.andWhere( '(space.spaceName ILIKE :search OR parent.spaceName ILIKE :search)', { search: `%${search}%` }, ); } if (onlyWithDevices) { queryBuilder.innerJoin('space.devices', 'devices'); } let spaces = await queryBuilder.getMany(); if (onlyWithDevices) { spaces = await Promise.all( spaces.map(async (space) => { const spaceHierarchy = await this.deviceService.getParentHierarchy(space); const parentHierarchy = spaceHierarchy .slice(0, 3) .map((space) => space.spaceName) .join(' - '); return { ...space, lastThreeParents: parentHierarchy, } as SpaceWithParentsDto; }), ); } const spaceHierarchy = this.buildSpaceHierarchy(spaces); return new SuccessResponseDto({ message: `Spaces in community ${communityUuid} successfully fetched in hierarchy`, data: onlyWithDevices ? spaces : spaceHierarchy, statusCode: HttpStatus.OK, }); } catch (error) { throw new HttpException( `An error occurred while fetching the spaces ${error}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } } async findOne(params: GetSpaceParam): Promise { const { communityUuid, spaceUuid, projectUuid } = params; try { await this.validationService.validateCommunityAndProject( communityUuid, projectUuid, ); const queryBuilder = this.spaceRepository .createQueryBuilder('space') .leftJoinAndSelect('space.productAllocations', 'productAllocations') .leftJoinAndSelect('productAllocations.tag', 'spaceTag') .leftJoinAndSelect('productAllocations.product', 'spaceProduct') .leftJoinAndSelect( 'space.subspaces', 'subspaces', 'subspaces.disabled = :subspaceDisabled', { subspaceDisabled: false }, ) .leftJoinAndSelect( 'subspaces.productAllocations', 'subspaceProductAllocations', ) .leftJoinAndSelect('subspaceProductAllocations.tag', 'subspaceTag') .leftJoinAndSelect( 'subspaceProductAllocations.product', 'subspaceProduct', ) .where('space.community_id = :communityUuid', { communityUuid }) .andWhere('space.spaceName != :orphanSpaceName', { orphanSpaceName: ORPHAN_SPACE_NAME, }) .andWhere('space.uuid = :spaceUuid', { spaceUuid }) .andWhere('space.disabled = :disabled', { disabled: false }); const space = await queryBuilder.getOne(); return new SuccessResponseDto({ message: `Space with ID ${spaceUuid} successfully fetched`, data: space, }); } catch (error) { if (error instanceof HttpException) { throw error; // If it's an HttpException, rethrow it } else { throw new HttpException( 'An error occurred while deleting the community', HttpStatus.INTERNAL_SERVER_ERROR, ); } } } async delete(params: GetSpaceParam): Promise { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { const { communityUuid, spaceUuid, projectUuid } = params; const { project } = await this.validationService.validateCommunityAndProject( communityUuid, projectUuid, ); const space = await this.validationService.validateSpace(spaceUuid); if (space.spaceName === ORPHAN_SPACE_NAME) { throw new HttpException( `space ${ORPHAN_SPACE_NAME} cannot be deleted`, HttpStatus.BAD_REQUEST, ); } const orphanSpace = await this.spaceRepository.findOne({ where: { community: { name: `${ORPHAN_COMMUNITY_NAME}-${project.name}`, }, spaceName: ORPHAN_SPACE_NAME, }, }); await this.spaceProductAllocationService.clearAllAllocations( spaceUuid, queryRunner, ); const subspaces = await queryRunner.manager.find(SubspaceEntity, { where: { space: { uuid: spaceUuid } }, }); const subspaceUuids = subspaces.map((subspace) => subspace.uuid); if (subspaceUuids.length > 0) { await this.subSpaceService.clearSubspaces(subspaceUuids, queryRunner); } await this.disableSpace(space, orphanSpace); await queryRunner.commitTransaction(); return new SuccessResponseDto({ message: `Space with ID ${spaceUuid} successfully deleted`, statusCode: HttpStatus.OK, }); } catch (error) { await queryRunner.rollbackTransaction(); console.error('Error deleting space:', error); if (error instanceof HttpException) { throw error; } throw new HttpException( `An error occurred while deleting the space: ${error.message}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } finally { await queryRunner.release(); } } async disableSpace(space: SpaceEntity, orphanSpace: SpaceEntity) { await this.commandBus.execute( new DisableSpaceCommand({ spaceUuid: space.uuid, orphanSpace }), ); } async updateSpace( params: GetSpaceParam, updateSpaceDto: UpdateSpaceDto, ): Promise { const { communityUuid, spaceUuid, projectUuid } = params; const queryRunner = this.dataSource.createQueryRunner(); const hasSubspace = updateSpaceDto.subspaces?.length > 0; const hasTags = updateSpaceDto.tags?.length > 0; try { await queryRunner.connect(); await queryRunner.startTransaction(); const project = await this.spaceModelService.validateProject( params.projectUuid, ); const space = await this.validationService.validateSpaceWithinCommunityAndProject( communityUuid, projectUuid, spaceUuid, ); if (space.spaceModel && !updateSpaceDto.spaceModelUuid) { await queryRunner.manager.update(SpaceEntity, space.uuid, { spaceModel: null, }); await this.unlinkSpaceFromModel(space, queryRunner); } await this.updateSpaceProperties(space, updateSpaceDto, queryRunner); if (hasSubspace || hasTags) { await queryRunner.manager.update(SpaceEntity, space.uuid, { spaceModel: null, }); } if (updateSpaceDto.spaceModelUuid) { const spaceModel = await this.validationService.validateSpaceModel( updateSpaceDto.spaceModelUuid, ); const hasDependencies = space.devices?.length > 0 || space.subspaces?.length > 0 || space.productAllocations?.length > 0; if (!hasDependencies && !space.spaceModel) { await this.spaceModelService.linkToSpace( space, spaceModel, queryRunner, ); } else if (hasDependencies) { // check for uuids that didn't change, // get their device ids and check if they has a tag in device entity, // if so move them ot the orphan space await this.spaceModelService.removeSpaceOldSubspacesAndAllocations( space, project, queryRunner, ); await this.spaceModelService.linkToSpace( space, spaceModel, queryRunner, ); } } if (hasSubspace) { await this.subSpaceService.unlinkModels(space.subspaces, queryRunner); } if (hasTags && space.productAllocations && space.spaceModel) { await this.spaceProductAllocationService.unlinkModels( space, queryRunner, ); } if (updateSpaceDto.subspaces) { await this.subSpaceService.updateSubspaceInSpace( updateSpaceDto.subspaces, queryRunner, space, projectUuid, ); } if (updateSpaceDto.tags) { await queryRunner.manager.delete(SpaceProductAllocationEntity, { space: { uuid: space.uuid }, tag: { uuid: Not( In( updateSpaceDto.tags .filter((tag) => tag.tagUuid) .map((tag) => tag.tagUuid), ), ), }, }); await this.createAllocations( updateSpaceDto.tags.map((tag) => ({ name: tag.name, uuid: tag.tagUuid, productUuid: tag.productUuid, })), projectUuid, queryRunner, space, ); } if (space.devices?.length) { await this.deviceService.addDevicesToOrphanSpace( space, project, queryRunner, ); } await queryRunner.commitTransaction(); return new SuccessResponseDto({ message: `Space with ID ${spaceUuid} successfully updated`, statusCode: HttpStatus.OK, }); } catch (error) { await queryRunner.rollbackTransaction(); if (error instanceof HttpException) { throw error; } throw new HttpException( `An error occurred while updating the space: error ${error}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } finally { await queryRunner.release(); } } async unlinkSpaceFromModel( space: SpaceEntity, queryRunner: QueryRunner, ): Promise { try { if (space.subspaces || space.productAllocations) { if (space.productAllocations) { await this.spaceProductAllocationService.unlinkModels( space, queryRunner, ); } if (space.subspaces) { await this.subSpaceService.unlinkModels(space.subspaces, queryRunner); } } } catch (error) { throw new HttpException( `Failed to unlink space model: ${error.message}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } } private async updateSpaceProperties( space: SpaceEntity, updateSpaceDto: UpdateSpaceDto, queryRunner: QueryRunner, ): Promise { const { spaceName, x, y, icon } = updateSpaceDto; const updateFields: Partial = {}; if (spaceName) updateFields.spaceName = spaceName; if (x !== undefined) updateFields.x = x; if (y !== undefined) updateFields.y = y; if (icon) updateFields.icon = icon; if (Object.keys(updateFields).length > 0) { await queryRunner.manager.update(SpaceEntity, space.uuid, updateFields); } } async getSpacesHierarchyForSpace( params: GetSpaceParam, ): Promise { const { spaceUuid, communityUuid, projectUuid } = params; await this.validationService.checkCommunityAndProjectSpaceExistence( communityUuid, projectUuid, spaceUuid, ); try { // Get all spaces that are children of the provided space, including the parent-child relations const spaces = await this.spaceRepository.find({ where: { parent: { uuid: spaceUuid }, disabled: false }, relations: ['parent', 'children'], }); // Organize spaces into a hierarchical structure const spaceHierarchy = this.buildSpaceHierarchy(spaces); return new SuccessResponseDto({ message: `Spaces under space ${spaceUuid} successfully fetched in hierarchy`, data: spaceHierarchy, statusCode: HttpStatus.OK, }); } catch (error) { throw new HttpException( `An error occurred while fetching the spaces under the space ${error}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } } async generateSpaceInvitationCode(params: GetSpaceParam): Promise { const { communityUuid, spaceUuid, projectUuid } = params; try { const invitationCode = generateRandomString(6); const space = await this.validationService.validateSpaceWithinCommunityAndProject( communityUuid, projectUuid, spaceUuid, ); await this.inviteSpaceRepository.save({ space: { uuid: spaceUuid }, invitationCode, }); return new SuccessResponseDto({ message: `Invitation code has been successfuly added to the space`, data: { invitationCode, spaceName: space.spaceName, spaceUuid: space.uuid, }, }); } catch (err) { if (err instanceof BadRequestException) { throw err; } else { throw new HttpException('Space not found', HttpStatus.NOT_FOUND); } } } private buildSpaceHierarchy(spaces: SpaceEntity[]): SpaceEntity[] { const map = new Map(); // Step 1: Create a map of spaces by UUID spaces.forEach((space: any) => { map.set(space.uuid, { ...space, children: [] }); // Ensure children are reset }); // Step 2: Organize the hierarchy const rootSpaces: SpaceEntity[] = []; spaces.forEach((space) => { if (space.parent && space.parent.uuid) { const parent = map.get(space.parent.uuid); if (parent) { const child = map.get(space.uuid); if (child && !parent.children.some((c) => c.uuid === child.uuid)) { parent.children.push(child); } } } else { rootSpaces.push(map.get(space.uuid)!); // Push only root spaces } }); return rootSpaces; } private validateSpaceCreationCriteria({ spaceModelUuid, tags, subspaces, }: Pick): void { const hasTagsOrSubspaces = (tags && tags.length > 0) || (subspaces && subspaces.length > 0); if (spaceModelUuid && hasTagsOrSubspaces) { throw new HttpException( 'For space creation choose either space model or products and subspace', HttpStatus.CONFLICT, ); } } private async createAllocations( tags: ProcessTagDto[], projectUuid: string, queryRunner: QueryRunner, space: SpaceEntity, ): Promise { const allocationsData = await this.tagService.processTags( tags, projectUuid, queryRunner, ); // Create a mapping of created tags by UUID and name for quick lookup const createdTagsByUUID = new Map(allocationsData.map((t) => [t.uuid, t])); const createdTagsByName = new Map(allocationsData.map((t) => [t.name, t])); // Create the product-tag mapping based on the processed tags const productTagMapping = tags.map(({ uuid, name, productUuid }) => { const inputTag = uuid ? createdTagsByUUID.get(uuid) : createdTagsByName.get(name); return { tag: inputTag?.uuid, product: productUuid, }; }); await this.spaceProductAllocationService.createProductAllocations( space, productTagMapping, queryRunner, ); } }