Files
backend/src/space/services/space.service.ts
2025-07-10 14:41:50 +03:00

832 lines
25 KiB
TypeScript

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 { 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 { CreateProductAllocationDto } from '../dtos/create-product-allocation.dto';
import { GetSpaceDto } from '../dtos/get.space.dto';
import { OrderSpacesDto } from '../dtos/order.spaces.dto';
import { SpaceWithParentsDto } from '../dtos/space.parents.dto';
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 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,
queryRunner?: QueryRunner,
recursiveCallParentEntity?: SpaceEntity,
): Promise<BaseResponseDto> {
const isRecursiveCall = !!queryRunner;
const {
parentUuid,
spaceModelUuid,
subspaces,
productAllocations,
children,
} = addSpaceDto;
const { communityUuid, projectUuid } = params;
if (!queryRunner) {
queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
}
const { community } =
await this.validationService.validateCommunityAndProject(
communityUuid,
projectUuid,
queryRunner,
);
this.validateSpaceCreationCriteria({
spaceModelUuid,
subspaces,
productAllocations,
});
const parent =
parentUuid && !isRecursiveCall
? await this.validationService.validateSpace(parentUuid)
: null;
const spaceModel = spaceModelUuid
? await this.validationService.validateSpaceModel(spaceModelUuid)
: null;
try {
const space = queryRunner.manager.create(SpaceEntity, {
// todo: find a better way to handle this instead of naming every key
spaceName: addSpaceDto.spaceName,
icon: addSpaceDto.icon,
x: addSpaceDto.x,
y: addSpaceDto.y,
spaceModel,
parent: isRecursiveCall
? recursiveCallParentEntity
: parentUuid
? parent
: null,
community,
});
const newSpace = await queryRunner.manager.save(space);
this.checkDuplicateTags([
...(productAllocations || []),
...(subspaces?.flatMap(
(subspace) => subspace.productAllocations || [],
) || []),
]);
if (spaceModelUuid) {
await this.spaceModelService.linkToSpace(
newSpace,
spaceModel,
queryRunner,
);
}
await Promise.all([
subspaces?.length
? this.subSpaceService.createSubspacesFromDto(
subspaces,
space,
queryRunner,
projectUuid,
)
: Promise.resolve(),
productAllocations?.length
? this.createAllocations(
productAllocations,
projectUuid,
queryRunner,
newSpace,
)
: Promise.resolve(),
]);
if (children?.length) {
await Promise.all(
children.map((child) =>
this.createSpace(
{ ...child, parentUuid: newSpace.uuid },
{ communityUuid, projectUuid },
queryRunner,
newSpace,
),
),
);
}
if (!isRecursiveCall) {
await queryRunner.commitTransaction();
}
return new SuccessResponseDto({
statusCode: HttpStatus.CREATED,
data: JSON.parse(JSON.stringify(newSpace, removeCircularReferences())),
message: 'Space created successfully',
});
} catch (error) {
!isRecursiveCall ? await queryRunner.rollbackTransaction() : null;
if (error instanceof HttpException) {
throw error;
}
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
} finally {
!isRecursiveCall ? await queryRunner.release() : null;
}
}
private checkDuplicateTags(allocations: CreateProductAllocationDto[]) {
const tagUuidSet = new Set<string>();
const tagNameProductSet = new Set<string>();
for (const allocation of allocations) {
if (allocation.tagUuid) {
if (tagUuidSet.has(allocation.tagUuid)) {
throw new HttpException(
`Duplicate tag UUID found: ${allocation.tagUuid}`,
HttpStatus.BAD_REQUEST,
);
}
tagUuidSet.add(allocation.tagUuid);
} else {
const tagKey = `${allocation.tagName}-${allocation.productUuid}`;
if (tagNameProductSet.has(tagKey)) {
throw new HttpException(
`Duplicate tag found with name "${allocation.tagName}" and product "${allocation.productUuid}".`,
HttpStatus.BAD_REQUEST,
);
}
tagNameProductSet.add(tagKey);
}
}
}
async getSpacesHierarchyForCommunity(
params: CommunitySpaceParam,
getSpaceDto?: GetSpaceDto & { search?: string },
): Promise<BaseResponseDto> {
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.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<BaseResponseDto> {
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();
if (!space) {
throw new HttpException(
`Space with ID ${spaceUuid} not found`,
HttpStatus.NOT_FOUND,
);
}
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 fetching the space',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
async updateSpacesOrder(
parentSpaceUuid: string,
{ spacesUuids }: OrderSpacesDto,
) {
const parentSpace = await this.spaceRepository.findOne({
where: { uuid: parentSpaceUuid, disabled: false },
relations: ['children'],
});
if (!parentSpace) {
throw new HttpException(
`Parent space with ID ${parentSpaceUuid} not found`,
HttpStatus.NOT_FOUND,
);
}
// ensure that all sent spaces belong to the parent space
const missingSpaces = spacesUuids.filter(
(uuid) => !parentSpace.children.some((child) => child.uuid === uuid),
);
if (missingSpaces.length > 0) {
throw new HttpException(
`Some spaces with IDs ${missingSpaces.join(
', ',
)} do not belong to the parent space with ID ${parentSpaceUuid}`,
HttpStatus.BAD_REQUEST,
);
}
try {
await this.spaceRepository.update(
{ uuid: In(spacesUuids), parent: { uuid: parentSpaceUuid } },
{
order: () =>
'CASE ' +
spacesUuids
.map((s, index) => `WHEN uuid = '${s}' THEN ${index + 1}`)
.join(' ') +
' END',
},
);
return;
} catch (error) {
console.error('Error updating spaces order:', error);
throw new HttpException(
'An error occurred while updating spaces order',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async delete(params: GetSpaceParam): Promise<BaseResponseDto> {
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();
}
}
private async disableSpace(space: SpaceEntity, orphanSpace: SpaceEntity) {
await this.commandBus.execute(
new DisableSpaceCommand({ spaceUuid: space.uuid, orphanSpace }),
);
}
async updateSpace(
params: GetSpaceParam,
updateSpaceDto: UpdateSpaceDto,
): Promise<BaseResponseDto> {
const { communityUuid, spaceUuid, projectUuid } = params;
const queryRunner = this.dataSource.createQueryRunner();
const hasSubspace = updateSpaceDto.subspaces?.length > 0;
const hasAllocations = updateSpaceDto.productAllocations?.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 || hasAllocations) {
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 (hasAllocations && space.productAllocations && space.spaceModel) {
await this.spaceProductAllocationService.unlinkModels(
space,
queryRunner,
);
}
if (updateSpaceDto.subspaces) {
await this.subSpaceService.updateSubspaceInSpace(
updateSpaceDto.subspaces,
queryRunner,
space,
projectUuid,
);
}
if (updateSpaceDto.productAllocations) {
await queryRunner.manager.delete(SpaceProductAllocationEntity, {
space: { uuid: space.uuid },
tag: {
uuid: Not(
In(
updateSpaceDto.productAllocations
.filter((tag) => tag.tagUuid)
.map((tag) => tag.tagUuid),
),
),
},
});
await this.createAllocations(
updateSpaceDto.productAllocations,
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<void> {
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<void> {
const { spaceName, x, y, icon } = updateSpaceDto;
const updateFields: Partial<SpaceEntity> = {};
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<BaseResponseDto> {
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<any> {
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);
}
}
}
buildSpaceHierarchy(spaces: SpaceEntity[]): SpaceEntity[] {
const map = new Map<string, SpaceEntity>();
// 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
}
});
rootSpaces.forEach(this.sortSpaceChildren.bind(this));
return rootSpaces;
}
private sortSpaceChildren(space: SpaceEntity) {
if (space.children && space.children.length > 0) {
space.children.sort((a, b) => {
const aOrder = a.order ?? Infinity;
const bOrder = b.order ?? Infinity;
return aOrder - bOrder;
});
space.children.forEach(this.sortSpaceChildren.bind(this)); // Recursively sort children of children
}
}
private validateSpaceCreationCriteria({
spaceModelUuid,
productAllocations,
subspaces,
}: Pick<
AddSpaceDto,
'spaceModelUuid' | 'productAllocations' | 'subspaces'
>): void {
const hasProductsOrSubspaces =
(productAllocations && productAllocations.length > 0) ||
(subspaces && subspaces.length > 0);
if (spaceModelUuid && hasProductsOrSubspaces) {
throw new HttpException(
'For space creation choose either space model or products and subspace',
HttpStatus.CONFLICT,
);
}
}
private async createAllocations(
productAllocations: CreateProductAllocationDto[],
projectUuid: string,
queryRunner: QueryRunner,
space: SpaceEntity,
): Promise<void> {
const allocationsData = await this.tagService.upsertTags(
productAllocations,
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 = productAllocations.map(
({ tagUuid, tagName, productUuid }) => {
const inputTag = tagUuid
? createdTagsByUUID.get(tagUuid)
: createdTagsByName.get(tagName);
return {
tag: inputTag?.uuid,
product: productUuid,
};
},
);
await this.spaceProductAllocationService.createProductAllocations(
space,
productTagMapping,
queryRunner,
);
}
}