diff --git a/libs/common/src/constants/controller-route.ts b/libs/common/src/constants/controller-route.ts index a9d4607..2f106b8 100644 --- a/libs/common/src/constants/controller-route.ts +++ b/libs/common/src/constants/controller-route.ts @@ -223,6 +223,10 @@ export class ControllerRoute { public static readonly CREATE_SPACE_DESCRIPTION = 'This endpoint allows you to create a space in a specified community. Optionally, you can specify a parent space to nest the new space under it.'; + public static readonly DUPLICATE_SPACE_SUMMARY = 'Duplicate a space'; + public static readonly DUPLICATE_SPACE_DESCRIPTION = + 'This endpoint allows you to create a copy of an existing space in a specified community.'; + public static readonly LIST_SPACE_SUMMARY = 'List spaces in community'; public static readonly LIST_SPACE_DESCRIPTION = 'List spaces in specified community by community id'; diff --git a/libs/common/src/modules/space/entities/space-product-allocation.entity.ts b/libs/common/src/modules/space/entities/space-product-allocation.entity.ts index c9cd7e2..403410e 100644 --- a/libs/common/src/modules/space/entities/space-product-allocation.entity.ts +++ b/libs/common/src/modules/space/entities/space-product-allocation.entity.ts @@ -22,6 +22,11 @@ export class SpaceProductAllocationEntity extends AbstractEntity SpaceModelProductAllocationEntity, { nullable: true, onDelete: 'SET NULL', @@ -31,9 +36,19 @@ export class SpaceProductAllocationEntity extends AbstractEntity ProductEntity, { nullable: false, onDelete: 'CASCADE' }) public product: ProductEntity; + @Column({ + name: 'product_uuid', + }) + public productUuid: string; + @ManyToOne(() => NewTagEntity, { nullable: true, onDelete: 'CASCADE' }) public tag: NewTagEntity; + @Column({ + name: 'tag_uuid', + }) + public tagUuid: string; + constructor(partial: Partial) { super(); Object.assign(this, partial); diff --git a/libs/common/src/modules/space/entities/space.entity.ts b/libs/common/src/modules/space/entities/space.entity.ts index f895de9..a18092b 100644 --- a/libs/common/src/modules/space/entities/space.entity.ts +++ b/libs/common/src/modules/space/entities/space.entity.ts @@ -9,6 +9,7 @@ import { import { AbstractEntity } from '../../abstract/entities/abstract.entity'; import { AqiSpaceDailyPollutantStatsEntity } from '../../aqi/entities'; import { BookableSpaceEntity } from '../../booking/entities/bookable-space.entity'; +import { BookingEntity } from '../../booking/entities/booking.entity'; import { CommunityEntity } from '../../community/entities'; import { DeviceEntity } from '../../device/entities'; import { InviteUserSpaceEntity } from '../../Invite-user/entities'; @@ -20,7 +21,6 @@ import { UserSpaceEntity } from '../../user/entities'; import { SpaceDto } from '../dtos'; import { SpaceProductAllocationEntity } from './space-product-allocation.entity'; import { SubspaceEntity } from './subspace/subspace.entity'; -import { BookingEntity } from '../../booking/entities/booking.entity'; @Entity({ name: 'space' }) export class SpaceEntity extends AbstractEntity { @@ -47,12 +47,26 @@ export class SpaceEntity extends AbstractEntity { @JoinColumn({ name: 'community_id' }) community: CommunityEntity; - @ManyToOne(() => SpaceEntity, (space) => space.children, { nullable: true }) + @Column({ + name: 'community_id', + }) + communityId: string; + + @ManyToOne(() => SpaceEntity, (space) => space.children, { + nullable: true, + }) parent: SpaceEntity; + @Column({ + name: 'parent_uuid', + nullable: true, + }) + public parentUuid: string; + @OneToMany(() => SpaceEntity, (space) => space.parent, { nullable: false, onDelete: 'CASCADE', + cascade: true, }) children: SpaceEntity[]; @@ -73,6 +87,7 @@ export class SpaceEntity extends AbstractEntity { @OneToMany(() => SubspaceEntity, (subspace) => subspace.space, { nullable: true, + cascade: true, }) subspaces?: SubspaceEntity[]; diff --git a/libs/common/src/modules/space/entities/subspace/subspace-product-allocation.entity.ts b/libs/common/src/modules/space/entities/subspace/subspace-product-allocation.entity.ts index 23b3111..bb206cb 100644 --- a/libs/common/src/modules/space/entities/subspace/subspace-product-allocation.entity.ts +++ b/libs/common/src/modules/space/entities/subspace/subspace-product-allocation.entity.ts @@ -22,6 +22,11 @@ export class SubspaceProductAllocationEntity extends AbstractEntity SubspaceModelProductAllocationEntity, { nullable: true, onDelete: 'SET NULL', @@ -31,9 +36,19 @@ export class SubspaceProductAllocationEntity extends AbstractEntity ProductEntity, { nullable: false, onDelete: 'CASCADE' }) public product: ProductEntity; + @Column({ + name: 'product_uuid', + }) + public productUuid: string; + @ManyToOne(() => NewTagEntity, { nullable: true, onDelete: 'CASCADE' }) public tag: NewTagEntity; + @Column({ + name: 'tag_uuid', + }) + public tagUuid: string; + constructor(partial: Partial) { super(); Object.assign(this, partial); diff --git a/libs/common/src/modules/space/entities/subspace/subspace.entity.ts b/libs/common/src/modules/space/entities/subspace/subspace.entity.ts index 885292b..b28d374 100644 --- a/libs/common/src/modules/space/entities/subspace/subspace.entity.ts +++ b/libs/common/src/modules/space/entities/subspace/subspace.entity.ts @@ -26,6 +26,11 @@ export class SubspaceEntity extends AbstractEntity { @JoinColumn({ name: 'space_uuid' }) space: SpaceEntity; + @Column({ + name: 'space_uuid', + }) + public spaceUuid: string; + @Column({ nullable: false, default: false, diff --git a/package-lock.json b/package-lock.json index 39a752b..ed65bb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.20", + "uuid": "^11.1.0", "winston": "^3.17.0", "ws": "^8.17.0" }, @@ -2365,6 +2366,18 @@ "rxjs": "^7.2.0" } }, + "node_modules/@nestjs/cqrs/node_modules/uuid": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.2.tgz", + "integrity": "sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/@nestjs/jwt": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", @@ -12749,18 +12762,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/typeorm/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -12896,9 +12897,9 @@ } }, "node_modules/uuid": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.2.tgz", - "integrity": "sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" diff --git a/package.json b/package.json index f0bc8cc..51c9596 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.20", + "uuid": "^11.1.0", "winston": "^3.17.0", "ws": "^8.17.0" }, diff --git a/src/space/controllers/space.controller.ts b/src/space/controllers/space.controller.ts index 776c222..f595ab4 100644 --- a/src/space/controllers/space.controller.ts +++ b/src/space/controllers/space.controller.ts @@ -17,6 +17,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Permissions } from 'src/decorators/permissions.decorator'; import { PermissionsGuard } from 'src/guards/permissions.guard'; import { AddSpaceDto, CommunitySpaceParam, UpdateSpaceDto } from '../dtos'; +import { DuplicateSpaceDto } from '../dtos/duplicate-space.dto'; import { GetSpaceDto } from '../dtos/get.space.dto'; import { GetSpaceParam } from '../dtos/get.space.param'; import { OrderSpacesDto } from '../dtos/order.spaces.dto'; @@ -48,6 +49,26 @@ export class SpaceController { ); } + @ApiBearerAuth() + @UseGuards(PermissionsGuard) + @Permissions('SPACE_ADD') + @ApiOperation({ + summary: ControllerRoute.SPACE.ACTIONS.DUPLICATE_SPACE_SUMMARY, + description: ControllerRoute.SPACE.ACTIONS.DUPLICATE_SPACE_DESCRIPTION, + }) + @Post(':spaceUuid/duplicate') + async duplicateSpace( + @Param('spaceUuid', ParseUUIDPipe) spaceUuid: string, + @Body() dto: DuplicateSpaceDto, + @Param() communitySpaceParam: CommunitySpaceParam, + ): Promise { + return await this.spaceService.duplicateSpace( + spaceUuid, + communitySpaceParam, + dto, + ); + } + @ApiBearerAuth() @UseGuards(PermissionsGuard) @Permissions('SPACE_VIEW') diff --git a/src/space/dtos/duplicate-space.dto.ts b/src/space/dtos/duplicate-space.dto.ts new file mode 100644 index 0000000..77dd34a --- /dev/null +++ b/src/space/dtos/duplicate-space.dto.ts @@ -0,0 +1,18 @@ +import { ORPHAN_SPACE_NAME } from '@app/common/constants/orphan-constant'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, NotEquals } from 'class-validator'; + +export class DuplicateSpaceDto { + @ApiProperty({ + description: 'Name of the space (e.g., Floor 1, Unit 101)', + example: 'Unit 101', + }) + @IsString() + @IsNotEmpty() + @NotEquals(ORPHAN_SPACE_NAME, { + message() { + return `Space name cannot be "${ORPHAN_SPACE_NAME}". Please choose a different name.`; + }, + }) + spaceName: string; +} diff --git a/src/space/services/space-validation.service.ts b/src/space/services/space-validation.service.ts index efbddeb..b8bcc5d 100644 --- a/src/space/services/space-validation.service.ts +++ b/src/space/services/space-validation.service.ts @@ -133,7 +133,7 @@ export class ValidationService { 'subspaces.productAllocations', 'subspaces.productAllocations.product', 'subspaces.devices', - 'spaceModel', + // 'spaceModel', ], }); diff --git a/src/space/services/space.service.ts b/src/space/services/space.service.ts index 126a3ec..350a019 100644 --- a/src/space/services/space.service.ts +++ b/src/space/services/space.service.ts @@ -8,6 +8,7 @@ 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 { SubspaceProductAllocationEntity } from '@app/common/modules/space/entities/subspace/subspace-product-allocation.entity'; import { SubspaceEntity } from '@app/common/modules/space/entities/subspace/subspace.entity'; import { InviteSpaceRepository, @@ -24,6 +25,7 @@ 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 { v4 as uuidV4 } from 'uuid'; import { DisableSpaceCommand } from '../commands'; import { AddSpaceDto, @@ -32,6 +34,7 @@ import { UpdateSpaceDto, } from '../dtos'; import { CreateProductAllocationDto } from '../dtos/create-product-allocation.dto'; +import { DuplicateSpaceDto } from '../dtos/duplicate-space.dto'; import { GetSpaceDto } from '../dtos/get.space.dto'; import { OrderSpacesDto } from '../dtos/order.spaces.dto'; import { SpaceWithParentsDto } from '../dtos/space.parents.dto'; @@ -181,6 +184,155 @@ export class SpaceService { !isRecursiveCall ? await queryRunner.release() : null; } } + + async duplicateSpace( + spaceUuid: string, + { communityUuid, projectUuid }: CommunitySpaceParam, + dto: DuplicateSpaceDto, + queryRunner?: QueryRunner, + ): Promise { + if (!queryRunner) { + queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + } + + try { + await this.validationService.validateCommunityAndProject( + communityUuid, + projectUuid, + queryRunner, + ); + + const result = await this.handleSpaceDuplication( + spaceUuid, + dto.spaceName, + queryRunner, + ); + await queryRunner.commitTransaction(); + + return new SuccessResponseDto({ + message: `Space with ID ${spaceUuid} successfully duplicated`, + data: result, + }); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); + } finally { + await queryRunner.release(); + } + } + private async handleSpaceDuplication( + spaceUuid: string, + newSpaceName: string | null, + queryRunner: QueryRunner, + parent?: SpaceEntity, + ) { + const space = await this.spaceRepository.findOne({ + where: { uuid: spaceUuid }, + relations: [ + 'children', + 'productAllocations', + 'subspaces', + 'subspaces.productAllocations', + ], + }); + const clonedSpace = structuredClone(space); + console.log(`creating duplicate for space ${clonedSpace.spaceName}`); + const newSpace = queryRunner.manager.create(SpaceEntity, { + ...clonedSpace, + spaceName: newSpaceName || clonedSpace.spaceName, + parent, + children: undefined, + subspaces: undefined, + productAllocations: undefined, + uuid: uuidV4(), + }); + + console.log(`handling space ${newSpace.spaceName} allocations`); + if (clonedSpace.productAllocations?.length) { + newSpace.productAllocations = this.copySpaceAllocations( + newSpace, + clonedSpace.productAllocations, + queryRunner, + ); + } + console.log(`handling space ${newSpace.spaceName} subspaces`); + if (clonedSpace.subspaces?.length) { + newSpace.subspaces = this.copySpaceSubspaces( + newSpace, + clonedSpace.subspaces, + queryRunner, + ); + } + const savedSpace = await queryRunner.manager.save(newSpace); + + console.log(`handling space ${newSpace.spaceName} children`); + if (clonedSpace.children?.length) { + for (const child of clonedSpace.children) { + if (child.disabled) continue; + await this.handleSpaceDuplication( + child.uuid, + child.spaceName, + queryRunner, + savedSpace, + ); + } + } + return savedSpace; + } + private copySpaceSubspaces( + newSpace: SpaceEntity, + subspaces: SubspaceEntity[], + queryRunner: QueryRunner, + ) { + const newSubspaces = []; + for (const sub of subspaces) { + if (sub.disabled) continue; + const clonedSub = structuredClone(sub); + delete clonedSub.uuid; + const newSubspace = queryRunner.manager.create(SubspaceEntity, { + ...clonedSub, + space: newSpace, + productAllocations: [], + uuid: uuidV4(), + }); + if (sub.productAllocations?.length) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const { uuid, ...allocation } of sub.productAllocations) { + newSubspace.productAllocations.push( + queryRunner.manager.create(SubspaceProductAllocationEntity, { + ...allocation, + subspace: newSubspace, + uuid: uuidV4(), + }), + ); + } + } + + newSubspaces.push(newSubspace); + } + return newSubspaces; + } + private copySpaceAllocations( + newSpace: SpaceEntity, + allocations: SpaceProductAllocationEntity[], + queryRunner: QueryRunner, + ) { + const newAllocations = []; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for (const { uuid, ...allocation } of allocations) { + newAllocations.push( + queryRunner.manager.create(SpaceProductAllocationEntity, { + ...allocation, + space: newSpace, + uuid: uuidV4(), + }), + ); + } + return newAllocations; + } + private checkDuplicateTags(allocations: CreateProductAllocationDto[]) { const tagUuidSet = new Set(); const tagNameProductSet = new Set();