import { InviteSpaceRepository, SpaceRepository, } from '@app/common/modules/space/repositories'; import { BadRequestException, HttpException, HttpStatus, Injectable, } from '@nestjs/common'; import { AddSpaceDto, AddSubspaceDto, CommunitySpaceParam, GetSpaceParam, UpdateSpaceDto, } from '../dtos'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { generateRandomString } from '@app/common/helper/randomString'; import { SpaceLinkService } from './space-link'; import { SubSpaceService } from './subspace'; import { DataSource, QueryRunner } from 'typeorm'; import { ValidationService } from './space-validation.service'; import { ORPHAN_COMMUNITY_NAME, ORPHAN_SPACE_NAME, } from '@app/common/constants/orphan-constant'; import { CommandBus } from '@nestjs/cqrs'; import { TagService } from './tag'; import { TagService as NewTagService } from 'src/tags/services/tags.service'; import { SpaceModelService } from 'src/space-model/services'; import { DisableSpaceCommand } from '../commands'; import { GetSpaceDto } from '../dtos/get.space.dto'; import { removeCircularReferences } from '@app/common/helper/removeCircularReferences'; import { SpaceEntity } from '@app/common/modules/space/entities/space.entity'; import { ProcessTagDto } from 'src/tags/dtos'; import { SpaceProductAllocationService } from './space-product-allocation.service'; import { SubspaceProductAllocationService } from './subspace/subspace-product-allocation.service'; import { SubspaceEntity } from '@app/common/modules/space/entities/subspace/subspace.entity'; @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 newTagService: NewTagService, private readonly spaceModelService: SpaceModelService, private commandBus: CommandBus, private readonly spaceProductAllocationService: SpaceProductAllocationService, private readonly subspaceProductAllocationService: SubspaceProductAllocationService, ) {} async createSpace( addSpaceDto: AddSpaceDto, params: CommunitySpaceParam, ): Promise { const { parentUuid, direction, spaceModelUuid, subspaces, tags } = addSpaceDto; const { communityUuid, projectUuid } = params; if (addSpaceDto.spaceName === ORPHAN_SPACE_NAME) { throw new HttpException( `Name ${ORPHAN_SPACE_NAME} cannot be used`, HttpStatus.BAD_REQUEST, ); } const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); const { community } = await this.validationService.validateCommunityAndProject( communityUuid, projectUuid, ); this.validateSpaceCreation(addSpaceDto, spaceModelUuid); 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 = this.subSpaceService.extractTagsFromSubspace(subspaces); const allTags = [...tags, ...subspaceTags]; this.validateUniqueTags(allTags); if (spaceModelUuid) { const hasDependencies = subspaces?.length > 0 || tags?.length > 0; if (!hasDependencies && !newSpace.spaceModel) { await this.spaceModelService.linkToSpace( newSpace, spaceModel, queryRunner, ); } else if (hasDependencies) { throw new HttpException( `Space cannot be linked to a model because it has existing dependencies (subspaces or tags).`, HttpStatus.BAD_REQUEST, ); } } await Promise.all([ direction && parent ? this.spaceLinkService.saveSpaceLink( parent.uuid, newSpace.uuid, direction, queryRunner, ) : Promise.resolve(), subspaces?.length ? this.createSubspaces( subspaces, newSpace, queryRunner, tags, projectUuid, ) : Promise.resolve(), tags?.length ? this.createTags(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 validateUniqueTags(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 createFromModel( spaceModelUuid: string, queryRunner: QueryRunner, space: SpaceEntity, ) { try { const spaceModel = await this.spaceModelService.validateSpaceModel(spaceModelUuid); space.spaceModel = spaceModel; await queryRunner.manager.save(SpaceEntity, space); await this.subSpaceService.createSubSpaceFromModel( spaceModel.subspaceModels, space, queryRunner, ); await this.tagService.createTagsFromModel( queryRunner, spaceModel.tags, space, null, ); } catch (error) { if (error instanceof HttpException) { throw error; } throw new HttpException( 'An error occurred while creating the space from space model', HttpStatus.INTERNAL_SERVER_ERROR, ); } } async getSpacesHierarchyForCommunity( params: CommunitySpaceParam, getSpaceDto?: GetSpaceDto, ): Promise { const { communityUuid, projectUuid } = params; const { onlyWithDevices } = 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.tags', 'tags') .leftJoinAndSelect('tags.product', 'tagProduct') .leftJoinAndSelect( 'space.subspaces', 'subspaces', 'subspaces.disabled = :subspaceDisabled', { subspaceDisabled: false }, ) .leftJoinAndSelect( 'subspaces.productAllocations', 'subspaceProductAllocations', ) .leftJoinAndSelect('subspaceProductAllocations.tags', 'subspaceTags') .leftJoinAndSelect('subspaceTags.product', 'subspaceTagProduct') .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 (onlyWithDevices) { queryBuilder.innerJoin('space.devices', 'devices'); } const spaces = await queryBuilder.getMany(); 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.parent', 'parent') .leftJoinAndSelect( 'space.children', 'children', 'children.disabled = :disabled', { disabled: false }, ) .leftJoinAndSelect( 'space.incomingConnections', 'incomingConnections', 'incomingConnections.disabled = :incomingConnectionDisabled', { incomingConnectionDisabled: false }, ) .leftJoinAndSelect( 'space.tags', 'tags', 'tags.disabled = :tagDisabled', { tagDisabled: false }, ) .leftJoinAndSelect('tags.product', 'tagProduct') .leftJoinAndSelect( 'space.subspaces', 'subspaces', 'subspaces.disabled = :subspaceDisabled', { subspaceDisabled: false }, ) .leftJoinAndSelect( 'subspaces.tags', 'subspaceTags', 'subspaceTags.disabled = :subspaceTagsDisabled', { subspaceTagsDisabled: false }, ) .leftJoinAndSelect('subspaceTags.product', 'subspaceTagProduct') .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 { 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, }, }); const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { 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(); } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } return new SuccessResponseDto({ message: `Space with ID ${spaceUuid} successfully deleted`, statusCode: HttpStatus.OK, }); } catch (error) { if (error instanceof HttpException) { throw error; } throw new HttpException( `An error occurred while deleting the space ${error.message}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } } 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(); 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.spaceName === ORPHAN_SPACE_NAME) { throw new HttpException( `Space "${ORPHAN_SPACE_NAME}" cannot be updated`, HttpStatus.BAD_REQUEST, ); } this.updateSpaceProperties(space, updateSpaceDto); 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) { await this.spaceModelService.overwriteSpace( space, project, queryRunner, ); await this.spaceModelService.linkToSpace( space, spaceModel, queryRunner, ); } } const hasSubspace = updateSpaceDto.subspace?.length > 0; const hasTags = updateSpaceDto.tags?.length > 0; if (hasSubspace || hasTags) { space.spaceModel = null; await this.tagService.unlinkModels(space.tags, queryRunner); await this.subSpaceService.unlinkModels(space.subspaces, queryRunner); } await queryRunner.manager.save(space); if (hasSubspace) { const modifiedSubspaces = this.tagService.getModifiedSubspaces( updateSpaceDto.tags, updateSpaceDto.subspace, ); await this.subSpaceService.modifySubSpace( modifiedSubspaces, queryRunner, space, projectUuid, updateSpaceDto.tags, ); } if (hasTags) { const spaceTagsAfterMove = this.tagService.getSubspaceTagsToBeAdded( updateSpaceDto.tags, updateSpaceDto.subspace, ); await this.tagService.modifyTags( spaceTagsAfterMove, queryRunner, space, ); } if (updateSpaceDto.tags) { await this.spaceProductAllocationService.updateSpaceProductAllocations( updateSpaceDto.tags, projectUuid, space, queryRunner, updateSpaceDto.subspace, ); } 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', HttpStatus.INTERNAL_SERVER_ERROR, ); } finally { await queryRunner.release(); } } async unlinkSpaceFromModel( space: SpaceEntity, queryRunner: QueryRunner, ): Promise { try { await queryRunner.manager.update( this.spaceRepository.target, { uuid: space.uuid }, { spaceModel: null, }, ); // Unlink subspaces and tags if they exist if (space.subspaces || space.tags) { if (space.tags) { await this.tagService.unlinkModels(space.tags, 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 updateSpaceProperties( space: SpaceEntity, updateSpaceDto: UpdateSpaceDto, ): void { const { spaceName, x, y, icon } = updateSpaceDto; if (spaceName) space.spaceName = spaceName; if (x) space.x = x; if (y) space.y = y; if (icon) space.icon = icon; } 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'], // Include parent and children relations }); // 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) => { map.set( space.uuid, this.spaceRepository.create({ ...space, children: [], // Add children if needed }), ); }); // Step 2: Organize the hierarchy const rootSpaces: SpaceEntity[] = []; spaces.forEach((space) => { if (space.parent && space.parent.uuid) { const parent = map.get(space.parent.uuid); parent?.children?.push(map.get(space.uuid)); } else { rootSpaces.push(map.get(space.uuid)); } }); return rootSpaces; } private validateSpaceCreation( addSpaceDto: AddSpaceDto, spaceModelUuid?: string, ) { const hasTagsOrSubspaces = (addSpaceDto.tags && addSpaceDto.tags.length > 0) || (addSpaceDto.subspaces && addSpaceDto.subspaces.length > 0); if (spaceModelUuid && hasTagsOrSubspaces) { throw new HttpException( 'For space creation choose either space model or products and subspace', HttpStatus.CONFLICT, ); } } private async createSubspaces( subspaces: AddSubspaceDto[], space: SpaceEntity, queryRunner: QueryRunner, tags: ProcessTagDto[], projectUuid: string, ): Promise { space.subspaces = await this.subSpaceService.createSubspacesFromDto( subspaces, space, queryRunner, tags, projectUuid, ); } private async createTags( tags: ProcessTagDto[], projectUuid: string, queryRunner: QueryRunner, space: SpaceEntity, ): Promise { const processedTags = await this.newTagService.processTags( tags, projectUuid, queryRunner, ); await this.spaceProductAllocationService.createSpaceProductAllocations( space, processedTags, queryRunner, ); } }