diff --git a/libs/common/src/constants/controller-route.ts b/libs/common/src/constants/controller-route.ts index db65dd4..3ed009e 100644 --- a/libs/common/src/constants/controller-route.ts +++ b/libs/common/src/constants/controller-route.ts @@ -43,6 +43,11 @@ export class ControllerRoute { 'Get user by uuid in project'; public static readonly GET_USER_BY_UUID_IN_PROJECT_DESCRIPTION = 'This endpoint retrieves a user by their unique identifier (UUID) associated with a specific project.'; + + public static readonly EXPORT_STRUCTURE_CSV_SUMMARY = + 'Export project with their full structure to a CSV file'; + public static readonly EXPORT_STRUCTURE_CSV_DESCRIPTION = + 'This endpoint exports project along with their associated communities, spaces, and nested space hierarchy into a downloadable CSV file. Useful for backups, reports, or audits'; }; }; static PROJECT_USER = class { diff --git a/libs/common/src/constants/role-permissions.ts b/libs/common/src/constants/role-permissions.ts index cd4593c..2414f22 100644 --- a/libs/common/src/constants/role-permissions.ts +++ b/libs/common/src/constants/role-permissions.ts @@ -3,6 +3,7 @@ import { RoleType } from './role.type.enum'; export const RolePermissions = { [RoleType.SUPER_ADMIN]: [ 'DEVICE_SINGLE_CONTROL', + 'COMMISSION_DEVICE', 'DEVICE_VIEW', 'DEVICE_DELETE', 'DEVICE_UPDATE', @@ -58,6 +59,7 @@ export const RolePermissions = { 'PRODUCT_ADD', ], [RoleType.ADMIN]: [ + 'COMMISSION_DEVICE', 'DEVICE_SINGLE_CONTROL', 'DEVICE_VIEW', 'DEVICE_DELETE', @@ -127,6 +129,7 @@ export const RolePermissions = { 'SCENES_CONTROL', ], [RoleType.SPACE_OWNER]: [ + 'COMMISSION_DEVICE', 'DEVICE_SINGLE_CONTROL', 'DEVICE_VIEW', 'DEVICE_DELETE', diff --git a/libs/common/src/modules/device/entities/device.entity.ts b/libs/common/src/modules/device/entities/device.entity.ts index fb30bf8..6ed8e42 100644 --- a/libs/common/src/modules/device/entities/device.entity.ts +++ b/libs/common/src/modules/device/entities/device.entity.ts @@ -78,7 +78,7 @@ export class DeviceEntity extends AbstractEntity { @OneToMany(() => NewTagEntity, (tag) => tag.devices) // @JoinTable({ name: 'device_tags' }) - public tag: NewTagEntity[]; + public tag: NewTagEntity; constructor(partial: Partial) { super(); diff --git a/libs/common/src/modules/space/entities/space.entity.ts b/libs/common/src/modules/space/entities/space.entity.ts index 815302f..9087d80 100644 --- a/libs/common/src/modules/space/entities/space.entity.ts +++ b/libs/common/src/modules/space/entities/space.entity.ts @@ -8,7 +8,6 @@ import { SpaceLinkEntity } from './space-link.entity'; import { SceneEntity } from '../../scene/entities'; import { SpaceModelEntity } from '../../space-model'; import { InviteUserSpaceEntity } from '../../Invite-user/entities'; -import { TagEntity } from './tag.entity'; import { SpaceProductAllocationEntity } from './space-product-allocation.entity'; import { SubspaceEntity } from './subspace/subspace.entity'; @@ -103,9 +102,6 @@ export class SpaceEntity extends AbstractEntity { ) invitedUsers: InviteUserSpaceEntity[]; - @OneToMany(() => TagEntity, (tag) => tag.space) - tags: TagEntity[]; - @OneToMany( () => SpaceProductAllocationEntity, (allocation) => allocation.space, diff --git a/libs/common/src/modules/space/entities/tag.entity.ts b/libs/common/src/modules/space/entities/tag.entity.ts index 059208c..cfa895c 100644 --- a/libs/common/src/modules/space/entities/tag.entity.ts +++ b/libs/common/src/modules/space/entities/tag.entity.ts @@ -3,7 +3,6 @@ import { AbstractEntity } from '../../abstract/entities/abstract.entity'; import { ProductEntity } from '../../product/entities'; import { TagDto } from '../dtos'; import { TagModel } from '../../space-model/entities/tag-model.entity'; -import { SpaceEntity } from './space.entity'; import { DeviceEntity } from '../../device/entities'; import { SubspaceEntity } from './subspace/subspace.entity'; @@ -22,9 +21,6 @@ export class TagEntity extends AbstractEntity { }) product: ProductEntity; - @ManyToOne(() => SpaceEntity, (space) => space.tags, { nullable: true }) - space: SpaceEntity; - @ManyToOne(() => SubspaceEntity, (subspace) => subspace.tags, { nullable: true, }) diff --git a/package-lock.json b/package-lock.json index 80a709f..9f26111 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@fast-csv/format": "^5.0.2", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.0.0", @@ -913,6 +914,19 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fast-csv/format": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-5.0.2.tgz", + "integrity": "sha512-fRYcWvI8vs0Zxa/8fXd/QlmQYWWkJqKZPAXM+vksnplb3owQFKTPPh9JqOtD0L3flQw/AZjjXdPkD7Kp/uHm8g==", + "license": "MIT", + "dependencies": { + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, "node_modules/@firebase/analytics": { "version": "0.10.8", "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.8.tgz", @@ -9441,6 +9455,12 @@ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -9459,12 +9479,31 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "license": "MIT" }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", diff --git a/package.json b/package.json index 835e650..820dc5b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test:e2e": "jest --config ./apps/backend/test/jest-e2e.json" }, "dependencies": { + "@fast-csv/format": "^5.0.2", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.0.0", diff --git a/src/commission-device/commission-device.module.ts b/src/commission-device/commission-device.module.ts index 62fb581..ebf0516 100644 --- a/src/commission-device/commission-device.module.ts +++ b/src/commission-device/commission-device.module.ts @@ -19,6 +19,8 @@ import { SceneRepository, } from '@app/common/modules/scene/repositories'; import { AutomationRepository } from '@app/common/modules/automation/repositories'; +import { CommunityRepository } from '@app/common/modules/community/repositories'; +import { SubspaceRepository } from '@app/common/modules/space/repositories/subspace.repository'; @Module({ imports: [ConfigModule, SpaceRepositoryModule], @@ -39,6 +41,8 @@ import { AutomationRepository } from '@app/common/modules/automation/repositorie SceneIconRepository, SceneRepository, AutomationRepository, + CommunityRepository, + SubspaceRepository, ], exports: [], }) diff --git a/src/commission-device/controllers/commission-device.controller.ts b/src/commission-device/controllers/commission-device.controller.ts index 59f0ee8..2c715ba 100644 --- a/src/commission-device/controllers/commission-device.controller.ts +++ b/src/commission-device/controllers/commission-device.controller.ts @@ -22,9 +22,10 @@ import { ControllerRoute } from '@app/common/constants/controller-route'; import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { CommissionDeviceCsvDto } from '../dto'; -import { CommunityParam } from '@app/common/dto/community-space.param'; import { DeviceCommissionService } from '../services'; import { ProjectParam } from '@app/common/dto/project-param.dto'; +import { Permissions } from 'src/decorators/permissions.decorator'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; @ApiTags('Commission Devices Module') @Controller({ @@ -34,6 +35,8 @@ import { ProjectParam } from '@app/common/dto/project-param.dto'; export class DeviceCommissionController { constructor(private readonly commissionService: DeviceCommissionService) {} + @UseGuards(PermissionsGuard) + @Permissions('COMMISSION_DEVICE') @ApiBearerAuth() @Post() @ApiConsumes('multipart/form-data') @@ -65,7 +68,7 @@ export class DeviceCommissionController { @Param() param: ProjectParam, @Req() req: any, ): Promise { - await this.commissionService.processCsv(file.path); + await this.commissionService.processCsv(param, file.path); return { message: 'CSV file received and processing started', success: true, diff --git a/src/commission-device/services/commission-device.service.ts b/src/commission-device/services/commission-device.service.ts index 15fc6ef..1d472bc 100644 --- a/src/commission-device/services/commission-device.service.ts +++ b/src/commission-device/services/commission-device.service.ts @@ -2,44 +2,185 @@ import * as fs from 'fs'; import * as csv from 'csv-parser'; import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; -import { Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { DeviceService } from 'src/device/services'; +import { CommunityRepository } from '@app/common/modules/community/repositories'; +import { SpaceRepository } from '@app/common/modules/space'; +import { SubspaceRepository } from '@app/common/modules/space/repositories/subspace.repository'; +import { SubspaceEntity } from '@app/common/modules/space/entities/subspace/subspace.entity'; +import { DeviceRepository } from '@app/common/modules/device/repositories'; +import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; +import { ProjectParam } from '@app/common/dto/project-param.dto'; +import { ProjectRepository } from '@app/common/modules/project/repositiories'; @Injectable() export class DeviceCommissionService { constructor( private readonly tuyaService: TuyaService, private readonly deviceService: DeviceService, + private readonly communityRepository: CommunityRepository, + private readonly spaceRepository: SpaceRepository, + private readonly subspaceRepository: SubspaceRepository, + private readonly deviceRepository: DeviceRepository, + private readonly projectRepository: ProjectRepository, ) {} - async processCsv(filePath: string): Promise { - return new Promise((resolve, reject) => { - const results = []; + async processCsv(param: ProjectParam, filePath: string) { + const successCount = { value: 0 }; + const failureCount = { value: 0 }; - fs.createReadStream(filePath) - .pipe(csv()) - .on('data', async (row) => { - console.log(`Device: ${JSON.stringify(row)}`); - const deviceId = row.deviceId?.trim(); + const projectId = param.projectUuid; - if (!deviceId) { - console.error('Missing deviceId or deviceName in row:', row); - return; - } else { - const device = await this.tuyaService.getDeviceDetails( - row.deviceId, - ); - console.log(device); - } - }) - .on('end', () => { - console.log(`Finished processing ${results.length} devices.`); - resolve(); - }) - .on('error', (error) => { - console.error('Error reading CSV', error); - reject(error); - }); + const project = await this.projectRepository.findOne({ + where: { uuid: projectId }, }); + + if (!project) { + throw new HttpException('Project not found', HttpStatus.NOT_FOUND); + } + + const rows: any[] = []; + try { + await new Promise((resolve, reject) => { + fs.createReadStream(filePath) + .pipe(csv()) + .on('data', (row) => rows.push(row)) + .on('end', () => resolve()) + .on('error', (error) => reject(error)); + }); + + for (const row of rows) { + await this.processCsvRow(param, row, successCount, failureCount); + } + + return new SuccessResponseDto({ + message: `Successfully processed CSV file`, + data: { + successCount: successCount.value, + failureCount: failureCount.value, + }, + statusCode: HttpStatus.ACCEPTED, + }); + } catch (error) { + throw new HttpException( + 'Failed to process CSV file', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async processCsvRow( + param: ProjectParam, + row: any, + successCount: { value: number }, + failureCount: { value: number }, + ) { + try { + const rawDeviceId = row['Device ID']?.trim(); + const communityId = row['Community UUID']?.trim(); + const spaceId = row['Space UUID']?.trim(); + const subspaceId = row['Subspace UUID']?.trim(); + const tagName = row['Tag']?.trim(); + const productName = row['Product Name']?.trim(); + const projectId = param.projectUuid; + + if (!rawDeviceId) { + console.error('Missing Device ID in row:', row); + failureCount.value++; + return; + } + + const device = await this.tuyaService.getDeviceDetails(rawDeviceId); + if (!device) { + console.error(`Device not found for Device ID: ${rawDeviceId}`); + failureCount.value++; + return; + } + + const community = await this.communityRepository.findOne({ + where: { uuid: communityId, project: { uuid: projectId } }, + }); + + if (!community) { + console.error(`Community not found: ${communityId}`); + failureCount.value++; + return; + } + + const tuyaSpaceId = community.externalId; + + const space = await this.spaceRepository.findOne({ + where: { uuid: spaceId }, + relations: [ + 'productAllocations', + 'productAllocations.tags', + 'productAllocations.product', + ], + }); + + if (!space) { + console.error(`Space not found: ${spaceId}`); + failureCount.value++; + return; + } + + let subspace: SubspaceEntity | null = null; + if (subspaceId?.trim()) { + subspace = await this.subspaceRepository.findOne({ + where: { uuid: subspaceId }, + relations: [ + 'productAllocations', + 'productAllocations.tags', + 'productAllocations.product', + ], + }); + + if (!subspace) { + console.error(`Subspace not found: ${subspaceId}`); + failureCount.value++; + return; + } + } + + const allocations = + subspace?.productAllocations || space.productAllocations; + + const match = allocations + .flatMap((pa) => + (pa.tags || []).map((tag) => ({ product: pa.product, tag })), + ) + .find(({ tag }) => tag.name === tagName); + + if (!match) { + console.error(`No matching tag found for Device ID: ${rawDeviceId}`); + failureCount.value++; + return; + } + + if (match.product.name !== productName) { + console.error(`Product name mismatch for Device ID: ${rawDeviceId}`); + failureCount.value++; + return; + } + + const middlewareDevice = this.deviceRepository.create({ + deviceTuyaUuid: rawDeviceId, + isActive: true, + spaceDevice: space, + subspace: subspace || null, + productDevice: match.product, + tag: match.tag, + }); + + await this.deviceRepository.save(middlewareDevice); + + await this.deviceService.transferDeviceInSpacesTuya( + rawDeviceId, + tuyaSpaceId, + ); + successCount.value++; + } catch (err) { + failureCount.value++; + } } } diff --git a/src/project/controllers/project.controller.ts b/src/project/controllers/project.controller.ts index a39ced1..24d5a44 100644 --- a/src/project/controllers/project.controller.ts +++ b/src/project/controllers/project.controller.ts @@ -4,13 +4,23 @@ import { Controller, Delete, Get, + Header, + HttpStatus, Param, Post, Put, Query, + Res, UseGuards, } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; +import { + ApiBearerAuth, + ApiOperation, + ApiProduces, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { ProjectService } from '../services'; import { CreateProjectDto, GetProjectParam } from '../dto'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; @@ -86,4 +96,34 @@ export class ProjectController { async findOne(@Param() params: GetProjectParam): Promise { return this.projectService.getProject(params.projectUuid); } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: ControllerRoute.PROJECT.ACTIONS.EXPORT_STRUCTURE_CSV_SUMMARY, + description: + ControllerRoute.PROJECT.ACTIONS.EXPORT_STRUCTURE_CSV_DESCRIPTION, + }) + @Get(':projectUuid/structure/export-csv') + @ApiProduces('text/csv') + @ApiResponse({ + status: 200, + description: + 'A CSV file containing project details and their structural hierarchy (Project, Community, Space, Parent Space).', + }) + @Header('Content-Type', 'text/csv') + @Header('Content-Disposition', 'attachment; filename=project-structure.csv') + async exportStructures( + @Param() params: GetProjectParam, + @Res() res: Response, + ): Promise { + try { + const csvStream = await this.projectService.exportToCsv(params); + csvStream.pipe(res as unknown as NodeJS.WritableStream); + } catch (error) { + res + .status(error.status || HttpStatus.INTERNAL_SERVER_ERROR) + .json({ message: error.message || 'Failed to generate CSV file.' }); + } + } } diff --git a/src/project/project.module.ts b/src/project/project.module.ts index f16302f..6c8bef8 100644 --- a/src/project/project.module.ts +++ b/src/project/project.module.ts @@ -4,7 +4,13 @@ import { ProjectController } from './controllers'; import { ProjectService } from './services'; import { ProjectRepository } from '@app/common/modules/project/repositiories'; import { CreateOrphanSpaceHandler } from './handler'; -import { SpaceRepository } from '@app/common/modules/space'; +import { + InviteSpaceRepository, + SpaceLinkRepository, + SpaceProductAllocationRepository, + SpaceRepository, + TagRepository, +} from '@app/common/modules/space'; import { CommunityRepository } from '@app/common/modules/community/repositories'; import { InviteUserRepository } from '@app/common/modules/Invite-user/repositiories'; import { ProjectUserController } from './controllers/project-user.controller'; @@ -13,6 +19,47 @@ import { UserRepository, UserSpaceRepository, } from '@app/common/modules/user/repositories'; +import { + SpaceLinkService, + SpaceService, + SubspaceDeviceService, + SubSpaceService, + ValidationService, +} from 'src/space/services'; +import { TagService } from 'src/tags/services'; +import { + SpaceModelService, + SubSpaceModelService, +} from 'src/space-model/services'; +import { DeviceService } from 'src/device/services'; +import { SpaceProductAllocationService } from 'src/space/services/space-product-allocation.service'; +import { + SubspaceProductAllocationRepository, + SubspaceRepository, +} from '@app/common/modules/space/repositories/subspace.repository'; +import { SubspaceProductAllocationService } from 'src/space/services/subspace/subspace-product-allocation.service'; +import { CommunityService } from 'src/community/services'; +import { + SpaceModelProductAllocationRepoitory, + SpaceModelRepository, + SubspaceModelProductAllocationRepoitory, + SubspaceModelRepository, +} from '@app/common/modules/space-model'; +import { DeviceRepository } from '@app/common/modules/device/repositories'; +import { NewTagRepository } from '@app/common/modules/tag/repositories/tag-repository'; +import { ProductRepository } from '@app/common/modules/product/repositories'; +import { SpaceModelProductAllocationService } from 'src/space-model/services/space-model-product-allocation.service'; +import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; +import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service'; +import { SceneService } from 'src/scene/services'; +import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; +import { SubspaceModelProductAllocationService } from 'src/space-model/services/subspace/subspace-model-product-allocation.service'; +import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories'; +import { + SceneIconRepository, + SceneRepository, +} from '@app/common/modules/scene/repositories'; +import { AutomationRepository } from '@app/common/modules/automation/repositories'; const CommandHandlers = [CreateOrphanSpaceHandler]; @@ -30,6 +77,41 @@ const CommandHandlers = [CreateOrphanSpaceHandler]; InviteUserRepository, UserSpaceRepository, UserRepository, + SpaceService, + InviteSpaceRepository, + SpaceLinkService, + SubSpaceService, + ValidationService, + TagService, + SpaceModelService, + DeviceService, + SpaceProductAllocationService, + SpaceLinkRepository, + SubspaceRepository, + SubspaceDeviceService, + SubspaceProductAllocationService, + CommunityService, + SpaceModelRepository, + DeviceRepository, + NewTagRepository, + ProductRepository, + SubSpaceModelService, + SpaceModelProductAllocationService, + SpaceProductAllocationRepository, + SubspaceProductAllocationRepository, + SceneDeviceRepository, + DeviceStatusFirebaseService, + SceneService, + TuyaService, + TagRepository, + SubspaceModelRepository, + SubspaceModelProductAllocationService, + SpaceModelProductAllocationRepoitory, + DeviceStatusLogRepository, + SceneIconRepository, + SceneRepository, + AutomationRepository, + SubspaceModelProductAllocationRepoitory, ], exports: [ProjectService, CqrsModule], }) diff --git a/src/project/services/project.service.ts b/src/project/services/project.service.ts index ad9cdbf..f771c3a 100644 --- a/src/project/services/project.service.ts +++ b/src/project/services/project.service.ts @@ -1,6 +1,12 @@ import { ProjectRepository } from '@app/common/modules/project/repositiories'; -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import { CreateProjectDto } from '../dto'; +import { + forwardRef, + HttpException, + HttpStatus, + Inject, + Injectable, +} from '@nestjs/common'; +import { CreateProjectDto, GetProjectParam } from '../dto'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { ProjectEntity } from '@app/common/modules/project/entities'; @@ -13,6 +19,11 @@ import { PageResponse } from '@app/common/dto/pagination.response.dto'; import { CommandBus } from '@nestjs/cqrs'; import { CreateOrphanSpaceCommand } from '../command/create-orphan-space-command'; import { UserRepository } from '@app/common/modules/user/repositories'; +import { format } from '@fast-csv/format'; +import { PassThrough } from 'stream'; +import { SpaceEntity } from '@app/common/modules/space/entities/space.entity'; +import { SpaceService } from 'src/space/services'; +import { ORPHAN_COMMUNITY_NAME } from '@app/common/constants/orphan-constant'; @Injectable() export class ProjectService { @@ -20,6 +31,8 @@ export class ProjectService { private readonly projectRepository: ProjectRepository, private readonly userRepository: UserRepository, private commandBus: CommandBus, + @Inject(forwardRef(() => SpaceService)) + private readonly spaceService: SpaceService, ) {} async createProject( @@ -213,4 +226,129 @@ export class ProjectService { async validate(name: string): Promise { return await this.projectRepository.exists({ where: { name } }); } + + async exportToCsv(param: GetProjectParam): Promise { + const project = await this.projectRepository.findOne({ + where: { uuid: param.projectUuid }, + relations: [ + 'communities', + 'communities.spaces', + 'communities.spaces.parent', + 'communities.spaces.productAllocations', + 'communities.spaces.productAllocations.product', + 'communities.spaces.productAllocations.tags', + 'communities.spaces.subspaces', + 'communities.spaces.subspaces.productAllocations', + 'communities.spaces.subspaces.productAllocations.product', + 'communities.spaces.subspaces.productAllocations.tags', + ], + }); + + if (!project) { + throw new HttpException( + `Project with UUID ${param.projectUuid} not found`, + HttpStatus.NOT_FOUND, + ); + } + + const stream = new PassThrough(); + const csvStream = format({ + headers: [ + 'Device ID', + 'Community Name', + 'Space Name', + 'Space Location', + 'Subspace Name', + 'Tag', + 'Product Name', + 'Community UUID', + 'Space UUID', + 'Subspace UUID', + ], + }); + + csvStream.pipe(stream); + + const allSpaces: SpaceEntity[] = []; + for (const community of project.communities) { + if (community.name === `${ORPHAN_COMMUNITY_NAME}-${project.name}`) + continue; + for (const space of community.spaces) { + if (!space.disabled) { + space.community = community; + allSpaces.push(space); + } + } + } + + const spaceMap = new Map(); + for (const space of allSpaces) { + spaceMap.set(space.uuid, space); + } + + function buildSpaceLocation(space: SpaceEntity): string { + const path: string[] = []; + let current = space.parent; + while (current) { + path.unshift(current.spaceName); + current = current.parent ?? spaceMap.get(current.uuid)?.parent ?? null; + } + return path.join(' > '); + } + + for (const space of allSpaces) { + const spaceLocation = buildSpaceLocation(space); + + for (const subspace of space.subspaces || []) { + if (subspace.disabled) continue; + + for (const productAllocation of subspace.productAllocations || []) { + for (const tag of productAllocation.tags || []) { + csvStream.write({ + 'Device ID': '', + 'Community Name': space.community?.name || '', + 'Space Name': space.spaceName, + 'Space Location': spaceLocation, + 'Subspace Name': subspace.subspaceName || '', + Tag: tag.name, + 'Product Name': productAllocation.product.name || '', + 'Community UUID': space.community?.uuid || '', + 'Space UUID': space.uuid, + 'Subspace UUID': subspace.uuid, + }); + } + } + } + + for (const productAllocation of space.productAllocations || []) { + for (const tag of productAllocation.tags || []) { + csvStream.write({ + 'Device ID': '', + 'Community Name': space.community?.name || '', + 'Space Name': space.spaceName, + 'Space Location': spaceLocation, + 'Subspace Name': '', + Tag: tag.name, + 'Product Name': productAllocation.product.name || '', + 'Community UUID': space.community?.uuid || '', + 'Space UUID': space.uuid, + 'Subspace UUID': '', + }); + } + } + } + + csvStream.end(); + return stream; + } + + getSpaceLocation(space: SpaceEntity): string { + const names = []; + let current = space.parent; + while (current) { + names.unshift(current.spaceName); + current = current.parent; + } + return names.join(' > '); + } } diff --git a/src/space/handlers/disable-space.handler.ts b/src/space/handlers/disable-space.handler.ts index 81a90f9..1dea9ca 100644 --- a/src/space/handlers/disable-space.handler.ts +++ b/src/space/handlers/disable-space.handler.ts @@ -65,7 +65,7 @@ export class DisableSpaceHandler } } - const tagUuids = space.tags?.map((tag) => tag.uuid) || []; + const tagUuids = space.productAllocations?.map((tag) => tag.uuid) || []; /* const subspaceDtos = space.subspaces?.map((subspace) => ({ subspaceUuid: subspace.uuid, diff --git a/src/space/services/space.service.ts b/src/space/services/space.service.ts index 2caae61..246f583 100644 --- a/src/space/services/space.service.ts +++ b/src/space/services/space.service.ts @@ -582,8 +582,8 @@ export class SpaceService { queryRunner: QueryRunner, ): Promise { try { - if (space.subspaces || space.tags) { - if (space.tags) { + if (space.subspaces || space.productAllocations) { + if (space.productAllocations) { await this.spaceProductAllocationService.unlinkModels( space, queryRunner, diff --git a/src/space/services/subspace/subspace.service.ts b/src/space/services/subspace/subspace.service.ts index 73c28d4..d74d355 100644 --- a/src/space/services/subspace/subspace.service.ts +++ b/src/space/services/subspace/subspace.service.ts @@ -18,7 +18,6 @@ import { In, QueryRunner } from 'typeorm'; import { SubspaceModelEntity } from '@app/common/modules/space-model'; import { ValidationService } from '../space-validation.service'; import { SubspaceRepository } from '@app/common/modules/space/repositories/subspace.repository'; -import { TagService } from '../tag'; import { ModifyAction } from '@app/common/constants/modify-action.enum'; import { SubspaceDeviceService } from './subspace-device.service'; import { ModifyTagDto } from 'src/space/dtos/tag/modify-tag.dto'; @@ -34,7 +33,6 @@ export class SubSpaceService { constructor( private readonly subspaceRepository: SubspaceRepository, private readonly validationService: ValidationService, - private readonly tagService: TagService, private readonly newTagService: NewTagService, public readonly deviceService: SubspaceDeviceService, private readonly subspaceProductAllocationService: SubspaceProductAllocationService, @@ -481,21 +479,6 @@ export class SubSpaceService { { disabled: true }, ); - if (subspace.tags?.length) { - const modifyTagDtos: ProcessTagDto[] = subspace.tags.map((tag) => ({ - uuid: tag.uuid, - action: ModifyAction.ADD, - name: tag.tag, - productUuid: tag.product.uuid, - })); - await this.tagService.moveTags( - modifyTagDtos, - queryRunner, - subspace.space, - null, - ); - } - if (subspace.devices.length > 0) { await this.deviceService.deleteSubspaceDevices( subspace.devices, diff --git a/src/space/services/tag/tag.service.ts b/src/space/services/tag/tag.service.ts index f40785b..1f3e977 100644 --- a/src/space/services/tag/tag.service.ts +++ b/src/space/services/tag/tag.service.ts @@ -118,7 +118,7 @@ export class TagService { await queryRunner.manager.update( this.tagRepository.target, { uuid: tag.uuid }, - { subspace, space: null }, + { subspace }, ); tag.subspace = subspace; } @@ -127,10 +127,9 @@ export class TagService { await queryRunner.manager.update( this.tagRepository.target, { uuid: tag.uuid }, - { subspace: null, space: space }, + { subspace: null }, ); tag.subspace = null; - tag.space = space; } return tag; @@ -367,7 +366,6 @@ export class TagService { where: [ { tag, - space: { uuid: spaceUuid }, product: { uuid: productUuid }, disabled: false, },