Merge pull request #339 from SyncrowIOT/feat/commission-device

Feat/commission device
This commit is contained in:
hannathkadher
2025-04-22 11:04:15 +04:00
committed by GitHub
17 changed files with 495 additions and 66 deletions

View File

@ -43,6 +43,11 @@ export class ControllerRoute {
'Get user by uuid in project'; 'Get user by uuid in project';
public static readonly GET_USER_BY_UUID_IN_PROJECT_DESCRIPTION = 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.'; '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 { static PROJECT_USER = class {

View File

@ -3,6 +3,7 @@ import { RoleType } from './role.type.enum';
export const RolePermissions = { export const RolePermissions = {
[RoleType.SUPER_ADMIN]: [ [RoleType.SUPER_ADMIN]: [
'DEVICE_SINGLE_CONTROL', 'DEVICE_SINGLE_CONTROL',
'COMMISSION_DEVICE',
'DEVICE_VIEW', 'DEVICE_VIEW',
'DEVICE_DELETE', 'DEVICE_DELETE',
'DEVICE_UPDATE', 'DEVICE_UPDATE',
@ -58,6 +59,7 @@ export const RolePermissions = {
'PRODUCT_ADD', 'PRODUCT_ADD',
], ],
[RoleType.ADMIN]: [ [RoleType.ADMIN]: [
'COMMISSION_DEVICE',
'DEVICE_SINGLE_CONTROL', 'DEVICE_SINGLE_CONTROL',
'DEVICE_VIEW', 'DEVICE_VIEW',
'DEVICE_DELETE', 'DEVICE_DELETE',
@ -127,6 +129,7 @@ export const RolePermissions = {
'SCENES_CONTROL', 'SCENES_CONTROL',
], ],
[RoleType.SPACE_OWNER]: [ [RoleType.SPACE_OWNER]: [
'COMMISSION_DEVICE',
'DEVICE_SINGLE_CONTROL', 'DEVICE_SINGLE_CONTROL',
'DEVICE_VIEW', 'DEVICE_VIEW',
'DEVICE_DELETE', 'DEVICE_DELETE',

View File

@ -78,7 +78,7 @@ export class DeviceEntity extends AbstractEntity<DeviceDto> {
@OneToMany(() => NewTagEntity, (tag) => tag.devices) @OneToMany(() => NewTagEntity, (tag) => tag.devices)
// @JoinTable({ name: 'device_tags' }) // @JoinTable({ name: 'device_tags' })
public tag: NewTagEntity[]; public tag: NewTagEntity;
constructor(partial: Partial<DeviceEntity>) { constructor(partial: Partial<DeviceEntity>) {
super(); super();

View File

@ -8,7 +8,6 @@ import { SpaceLinkEntity } from './space-link.entity';
import { SceneEntity } from '../../scene/entities'; import { SceneEntity } from '../../scene/entities';
import { SpaceModelEntity } from '../../space-model'; import { SpaceModelEntity } from '../../space-model';
import { InviteUserSpaceEntity } from '../../Invite-user/entities'; import { InviteUserSpaceEntity } from '../../Invite-user/entities';
import { TagEntity } from './tag.entity';
import { SpaceProductAllocationEntity } from './space-product-allocation.entity'; import { SpaceProductAllocationEntity } from './space-product-allocation.entity';
import { SubspaceEntity } from './subspace/subspace.entity'; import { SubspaceEntity } from './subspace/subspace.entity';
@ -103,9 +102,6 @@ export class SpaceEntity extends AbstractEntity<SpaceDto> {
) )
invitedUsers: InviteUserSpaceEntity[]; invitedUsers: InviteUserSpaceEntity[];
@OneToMany(() => TagEntity, (tag) => tag.space)
tags: TagEntity[];
@OneToMany( @OneToMany(
() => SpaceProductAllocationEntity, () => SpaceProductAllocationEntity,
(allocation) => allocation.space, (allocation) => allocation.space,

View File

@ -3,7 +3,6 @@ import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { ProductEntity } from '../../product/entities'; import { ProductEntity } from '../../product/entities';
import { TagDto } from '../dtos'; import { TagDto } from '../dtos';
import { TagModel } from '../../space-model/entities/tag-model.entity'; import { TagModel } from '../../space-model/entities/tag-model.entity';
import { SpaceEntity } from './space.entity';
import { DeviceEntity } from '../../device/entities'; import { DeviceEntity } from '../../device/entities';
import { SubspaceEntity } from './subspace/subspace.entity'; import { SubspaceEntity } from './subspace/subspace.entity';
@ -22,9 +21,6 @@ export class TagEntity extends AbstractEntity<TagDto> {
}) })
product: ProductEntity; product: ProductEntity;
@ManyToOne(() => SpaceEntity, (space) => space.tags, { nullable: true })
space: SpaceEntity;
@ManyToOne(() => SubspaceEntity, (subspace) => subspace.tags, { @ManyToOne(() => SubspaceEntity, (subspace) => subspace.tags, {
nullable: true, nullable: true,
}) })

39
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.1", "version": "0.0.1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@fast-csv/format": "^5.0.2",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.0", "@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",
@ -913,6 +914,19 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "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": { "node_modules/@firebase/analytics": {
"version": "0.10.8", "version": "0.10.8",
"resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.8.tgz", "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.8.tgz",
@ -9441,6 +9455,12 @@
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"license": "MIT" "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": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@ -9459,12 +9479,31 @@
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT" "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": { "node_modules/lodash.isinteger": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT" "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": { "node_modules/lodash.isnumber": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",

View File

@ -20,6 +20,7 @@
"test:e2e": "jest --config ./apps/backend/test/jest-e2e.json" "test:e2e": "jest --config ./apps/backend/test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@fast-csv/format": "^5.0.2",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.2.0", "@nestjs/config": "^3.2.0",
"@nestjs/core": "^10.0.0", "@nestjs/core": "^10.0.0",

View File

@ -19,6 +19,8 @@ import {
SceneRepository, SceneRepository,
} from '@app/common/modules/scene/repositories'; } from '@app/common/modules/scene/repositories';
import { AutomationRepository } from '@app/common/modules/automation/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({ @Module({
imports: [ConfigModule, SpaceRepositoryModule], imports: [ConfigModule, SpaceRepositoryModule],
@ -39,6 +41,8 @@ import { AutomationRepository } from '@app/common/modules/automation/repositorie
SceneIconRepository, SceneIconRepository,
SceneRepository, SceneRepository,
AutomationRepository, AutomationRepository,
CommunityRepository,
SubspaceRepository,
], ],
exports: [], exports: [],
}) })

View File

@ -22,9 +22,10 @@ import { ControllerRoute } from '@app/common/constants/controller-route';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { CommissionDeviceCsvDto } from '../dto'; import { CommissionDeviceCsvDto } from '../dto';
import { CommunityParam } from '@app/common/dto/community-space.param';
import { DeviceCommissionService } from '../services'; import { DeviceCommissionService } from '../services';
import { ProjectParam } from '@app/common/dto/project-param.dto'; 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') @ApiTags('Commission Devices Module')
@Controller({ @Controller({
@ -34,6 +35,8 @@ import { ProjectParam } from '@app/common/dto/project-param.dto';
export class DeviceCommissionController { export class DeviceCommissionController {
constructor(private readonly commissionService: DeviceCommissionService) {} constructor(private readonly commissionService: DeviceCommissionService) {}
@UseGuards(PermissionsGuard)
@Permissions('COMMISSION_DEVICE')
@ApiBearerAuth() @ApiBearerAuth()
@Post() @Post()
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ -65,7 +68,7 @@ export class DeviceCommissionController {
@Param() param: ProjectParam, @Param() param: ProjectParam,
@Req() req: any, @Req() req: any,
): Promise<BaseResponseDto> { ): Promise<BaseResponseDto> {
await this.commissionService.processCsv(file.path); await this.commissionService.processCsv(param, file.path);
return { return {
message: 'CSV file received and processing started', message: 'CSV file received and processing started',
success: true, success: true,

View File

@ -2,44 +2,185 @@ import * as fs from 'fs';
import * as csv from 'csv-parser'; import * as csv from 'csv-parser';
import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; 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 { 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() @Injectable()
export class DeviceCommissionService { export class DeviceCommissionService {
constructor( constructor(
private readonly tuyaService: TuyaService, private readonly tuyaService: TuyaService,
private readonly deviceService: DeviceService, 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<void> { async processCsv(param: ProjectParam, filePath: string) {
return new Promise((resolve, reject) => { const successCount = { value: 0 };
const results = []; const failureCount = { value: 0 };
fs.createReadStream(filePath) const projectId = param.projectUuid;
.pipe(csv())
.on('data', async (row) => {
console.log(`Device: ${JSON.stringify(row)}`);
const deviceId = row.deviceId?.trim();
if (!deviceId) { const project = await this.projectRepository.findOne({
console.error('Missing deviceId or deviceName in row:', row); where: { uuid: projectId },
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);
});
}); });
if (!project) {
throw new HttpException('Project not found', HttpStatus.NOT_FOUND);
}
const rows: any[] = [];
try {
await new Promise<void>((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++;
}
} }
} }

View File

@ -4,13 +4,23 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Header,
HttpStatus,
Param, Param,
Post, Post,
Put, Put,
Query, Query,
Res,
UseGuards, UseGuards,
} from '@nestjs/common'; } 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 { ProjectService } from '../services';
import { CreateProjectDto, GetProjectParam } from '../dto'; import { CreateProjectDto, GetProjectParam } from '../dto';
import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { BaseResponseDto } from '@app/common/dto/base.response.dto';
@ -86,4 +96,34 @@ export class ProjectController {
async findOne(@Param() params: GetProjectParam): Promise<BaseResponseDto> { async findOne(@Param() params: GetProjectParam): Promise<BaseResponseDto> {
return this.projectService.getProject(params.projectUuid); 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<void> {
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.' });
}
}
} }

View File

@ -4,7 +4,13 @@ import { ProjectController } from './controllers';
import { ProjectService } from './services'; import { ProjectService } from './services';
import { ProjectRepository } from '@app/common/modules/project/repositiories'; import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { CreateOrphanSpaceHandler } from './handler'; 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 { CommunityRepository } from '@app/common/modules/community/repositories';
import { InviteUserRepository } from '@app/common/modules/Invite-user/repositiories'; import { InviteUserRepository } from '@app/common/modules/Invite-user/repositiories';
import { ProjectUserController } from './controllers/project-user.controller'; import { ProjectUserController } from './controllers/project-user.controller';
@ -13,6 +19,47 @@ import {
UserRepository, UserRepository,
UserSpaceRepository, UserSpaceRepository,
} from '@app/common/modules/user/repositories'; } 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]; const CommandHandlers = [CreateOrphanSpaceHandler];
@ -30,6 +77,41 @@ const CommandHandlers = [CreateOrphanSpaceHandler];
InviteUserRepository, InviteUserRepository,
UserSpaceRepository, UserSpaceRepository,
UserRepository, 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], exports: [ProjectService, CqrsModule],
}) })

View File

@ -1,6 +1,12 @@
import { ProjectRepository } from '@app/common/modules/project/repositiories'; import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import {
import { CreateProjectDto } from '../dto'; forwardRef,
HttpException,
HttpStatus,
Inject,
Injectable,
} from '@nestjs/common';
import { CreateProjectDto, GetProjectParam } from '../dto';
import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { ProjectEntity } from '@app/common/modules/project/entities'; 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 { CommandBus } from '@nestjs/cqrs';
import { CreateOrphanSpaceCommand } from '../command/create-orphan-space-command'; import { CreateOrphanSpaceCommand } from '../command/create-orphan-space-command';
import { UserRepository } from '@app/common/modules/user/repositories'; 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() @Injectable()
export class ProjectService { export class ProjectService {
@ -20,6 +31,8 @@ export class ProjectService {
private readonly projectRepository: ProjectRepository, private readonly projectRepository: ProjectRepository,
private readonly userRepository: UserRepository, private readonly userRepository: UserRepository,
private commandBus: CommandBus, private commandBus: CommandBus,
@Inject(forwardRef(() => SpaceService))
private readonly spaceService: SpaceService,
) {} ) {}
async createProject( async createProject(
@ -213,4 +226,129 @@ export class ProjectService {
async validate(name: string): Promise<boolean> { async validate(name: string): Promise<boolean> {
return await this.projectRepository.exists({ where: { name } }); return await this.projectRepository.exists({ where: { name } });
} }
async exportToCsv(param: GetProjectParam): Promise<PassThrough> {
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<string, SpaceEntity>();
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(' > ');
}
} }

View File

@ -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 = /* const subspaceDtos =
space.subspaces?.map((subspace) => ({ space.subspaces?.map((subspace) => ({
subspaceUuid: subspace.uuid, subspaceUuid: subspace.uuid,

View File

@ -582,8 +582,8 @@ export class SpaceService {
queryRunner: QueryRunner, queryRunner: QueryRunner,
): Promise<void> { ): Promise<void> {
try { try {
if (space.subspaces || space.tags) { if (space.subspaces || space.productAllocations) {
if (space.tags) { if (space.productAllocations) {
await this.spaceProductAllocationService.unlinkModels( await this.spaceProductAllocationService.unlinkModels(
space, space,
queryRunner, queryRunner,

View File

@ -18,7 +18,6 @@ import { In, QueryRunner } from 'typeorm';
import { SubspaceModelEntity } from '@app/common/modules/space-model'; import { SubspaceModelEntity } from '@app/common/modules/space-model';
import { ValidationService } from '../space-validation.service'; import { ValidationService } from '../space-validation.service';
import { SubspaceRepository } from '@app/common/modules/space/repositories/subspace.repository'; import { SubspaceRepository } from '@app/common/modules/space/repositories/subspace.repository';
import { TagService } from '../tag';
import { ModifyAction } from '@app/common/constants/modify-action.enum'; import { ModifyAction } from '@app/common/constants/modify-action.enum';
import { SubspaceDeviceService } from './subspace-device.service'; import { SubspaceDeviceService } from './subspace-device.service';
import { ModifyTagDto } from 'src/space/dtos/tag/modify-tag.dto'; import { ModifyTagDto } from 'src/space/dtos/tag/modify-tag.dto';
@ -34,7 +33,6 @@ export class SubSpaceService {
constructor( constructor(
private readonly subspaceRepository: SubspaceRepository, private readonly subspaceRepository: SubspaceRepository,
private readonly validationService: ValidationService, private readonly validationService: ValidationService,
private readonly tagService: TagService,
private readonly newTagService: NewTagService, private readonly newTagService: NewTagService,
public readonly deviceService: SubspaceDeviceService, public readonly deviceService: SubspaceDeviceService,
private readonly subspaceProductAllocationService: SubspaceProductAllocationService, private readonly subspaceProductAllocationService: SubspaceProductAllocationService,
@ -481,21 +479,6 @@ export class SubSpaceService {
{ disabled: true }, { 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) { if (subspace.devices.length > 0) {
await this.deviceService.deleteSubspaceDevices( await this.deviceService.deleteSubspaceDevices(
subspace.devices, subspace.devices,

View File

@ -118,7 +118,7 @@ export class TagService {
await queryRunner.manager.update( await queryRunner.manager.update(
this.tagRepository.target, this.tagRepository.target,
{ uuid: tag.uuid }, { uuid: tag.uuid },
{ subspace, space: null }, { subspace },
); );
tag.subspace = subspace; tag.subspace = subspace;
} }
@ -127,10 +127,9 @@ export class TagService {
await queryRunner.manager.update( await queryRunner.manager.update(
this.tagRepository.target, this.tagRepository.target,
{ uuid: tag.uuid }, { uuid: tag.uuid },
{ subspace: null, space: space }, { subspace: null },
); );
tag.subspace = null; tag.subspace = null;
tag.space = space;
} }
return tag; return tag;
@ -367,7 +366,6 @@ export class TagService {
where: [ where: [
{ {
tag, tag,
space: { uuid: spaceUuid },
product: { uuid: productUuid }, product: { uuid: productUuid },
disabled: false, disabled: false,
}, },