diff --git a/libs/common/src/constants/controller-route.ts b/libs/common/src/constants/controller-route.ts index 331baf1..b4dcbf9 100644 --- a/libs/common/src/constants/controller-route.ts +++ b/libs/common/src/constants/controller-route.ts @@ -482,6 +482,7 @@ export class ControllerRoute { 'This endpoint retrieves all devices in a specified group within a space, based on the group name and space UUID.'; }; }; + static DEVICE = class { public static readonly ROUTE = 'devices'; @@ -574,7 +575,15 @@ export class ControllerRoute { 'This endpoint deletes all scenes associated with a specific switch device.'; }; }; + static DEVICE_COMMISSION = class { + public static readonly ROUTE = '/projects/:projectUuid/devices/commission'; + static ACTIONS = class { + public static readonly ADD_ALL_DEVICES_SUMMARY = 'Add all devices'; + public static readonly ADD_ALL_DEVICES_DESCRIPTION = + 'This endpoint add all devices in the system from tuya.'; + }; + }; static DEVICE_PROJECT = class { public static readonly ROUTE = '/projects/:projectUuid/devices'; static ACTIONS = class { diff --git a/libs/common/src/constants/role-permissions.ts b/libs/common/src/constants/role-permissions.ts index f4c077d..9427034 100644 --- a/libs/common/src/constants/role-permissions.ts +++ b/libs/common/src/constants/role-permissions.ts @@ -53,6 +53,7 @@ export const RolePermissions = { 'VISITOR_PASSWORD_DELETE', 'USER_ADD', 'SPACE_MEMBER_ADD', + 'COMMISSION_DEVICE', ], [RoleType.ADMIN]: [ 'DEVICE_SINGLE_CONTROL', @@ -106,6 +107,7 @@ export const RolePermissions = { 'VISITOR_PASSWORD_DELETE', 'USER_ADD', 'SPACE_MEMBER_ADD', + 'COMMISSION_DEVICE', ], [RoleType.SPACE_MEMBER]: [ 'DEVICE_SINGLE_CONTROL', @@ -163,5 +165,6 @@ export const RolePermissions = { 'VISITOR_PASSWORD_DELETE', 'USER_ADD', 'SPACE_MEMBER_ADD', + 'COMMISSION_DEVICE', ], }; diff --git a/libs/common/src/type/express/index.d.ts b/libs/common/src/type/express/index.d.ts new file mode 100644 index 0000000..93008c2 --- /dev/null +++ b/libs/common/src/type/express/index.d.ts @@ -0,0 +1,9 @@ +import { File } from 'multer'; + +declare global { + namespace Express { + interface Request { + file?: File; + } + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index bb9ab94..b0d9130 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "crypto-js": "^4.2.0", + "csv-parser": "^3.2.0", "express-rate-limit": "^7.1.5", "firebase": "^10.12.5", "google-auth-library": "^9.14.1", @@ -47,8 +48,9 @@ "@nestjs/testing": "^10.0.0", "@types/bcryptjs": "^2.4.6", "@types/crypto-js": "^4.2.2", - "@types/express": "^4.17.17", + "@types/express": "^4.17.21", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.12", "@types/node": "^20.3.1", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", @@ -3105,6 +3107,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz", + "integrity": "sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.17.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.19.tgz", @@ -5181,6 +5193,18 @@ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", "license": "MIT" }, + "node_modules/csv-parser": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz", + "integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==", + "license": "MIT", + "bin": { + "csv-parser": "bin/csv-parser" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", diff --git a/package.json b/package.json index 6598f15..a44084a 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "crypto-js": "^4.2.0", + "csv-parser": "^3.2.0", "express-rate-limit": "^7.1.5", "firebase": "^10.12.5", "google-auth-library": "^9.14.1", @@ -58,8 +59,9 @@ "@nestjs/testing": "^10.0.0", "@types/bcryptjs": "^2.4.6", "@types/crypto-js": "^4.2.2", - "@types/express": "^4.17.17", + "@types/express": "^4.17.21", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.12", "@types/node": "^20.3.1", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index 3187210..cebd61a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -30,6 +30,7 @@ import { TermsConditionsModule } from './terms-conditions/terms-conditions.modul import { PrivacyPolicyModule } from './privacy-policy/privacy-policy.module'; import { TagModule } from './tags/tags.module'; import { ClientModule } from './client/client.module'; +import { DeviceCommissionModule } from './commission-device/commission-device.module'; @Module({ imports: [ ConfigModule.forRoot({ @@ -63,6 +64,8 @@ import { ClientModule } from './client/client.module'; TermsConditionsModule, PrivacyPolicyModule, TagModule, + + DeviceCommissionModule, ], providers: [ { diff --git a/src/commission-device/commission-device.module.ts b/src/commission-device/commission-device.module.ts new file mode 100644 index 0000000..62fb581 --- /dev/null +++ b/src/commission-device/commission-device.module.ts @@ -0,0 +1,45 @@ +import { SpaceRepositoryModule } from '@app/common/modules/space/space.repository.module'; +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { DeviceCommissionController } from './controllers'; +import { UserRepository } from '@app/common/modules/user/repositories'; +import { DeviceRepository } from '@app/common/modules/device/repositories'; +import { DeviceCommissionService } from './services'; +import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; +import { DeviceService } from 'src/device/services'; +import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; +import { ProductRepository } from '@app/common/modules/product/repositories'; +import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service'; +import { SpaceRepository } from '@app/common/modules/space'; +import { SceneService } from 'src/scene/services'; +import { ProjectRepository } from '@app/common/modules/project/repositiories'; +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'; + +@Module({ + imports: [ConfigModule, SpaceRepositoryModule], + controllers: [DeviceCommissionController], + providers: [ + UserRepository, + DeviceRepository, + DeviceCommissionService, + TuyaService, + DeviceService, + SceneDeviceRepository, + ProductRepository, + DeviceStatusFirebaseService, + SpaceRepository, + SceneService, + ProjectRepository, + DeviceStatusLogRepository, + SceneIconRepository, + SceneRepository, + AutomationRepository, + ], + exports: [], +}) +export class DeviceCommissionModule {} diff --git a/src/commission-device/controllers/commission-device.controller.ts b/src/commission-device/controllers/commission-device.controller.ts new file mode 100644 index 0000000..59f0ee8 --- /dev/null +++ b/src/commission-device/controllers/commission-device.controller.ts @@ -0,0 +1,74 @@ +import { + Controller, + Post, + Req, + UseGuards, + UploadedFile, + UseInterceptors, + UsePipes, + ValidationPipe, + Param, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiConsumes, + ApiOperation, + ApiTags, + ApiBody, +} from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +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'; + +@ApiTags('Commission Devices Module') +@Controller({ + version: EnableDisableStatusEnum.ENABLED, + path: ControllerRoute.DEVICE_COMMISSION.ROUTE, +}) +export class DeviceCommissionController { + constructor(private readonly commissionService: DeviceCommissionService) {} + + @ApiBearerAuth() + @Post() + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FileInterceptor('file', { + storage: diskStorage({ + destination: './uploads', + filename: (req, file, cb) => { + cb(null, `${Date.now()}-${file.originalname}`); + }, + }), + fileFilter: (req, file, cb) => { + if (!file.originalname.match(/\.(csv)$/)) { + return cb(new Error('Only CSV files are allowed!'), false); + } + cb(null, true); + }, + }), + ) + @ApiBody({ type: CommissionDeviceCsvDto }) + @ApiOperation({ + summary: ControllerRoute.DEVICE_COMMISSION.ACTIONS.ADD_ALL_DEVICES_SUMMARY, + description: + ControllerRoute.DEVICE_COMMISSION.ACTIONS.ADD_ALL_DEVICES_DESCRIPTION, + }) + @UsePipes(new ValidationPipe({ transform: true })) + async addNewDevice( + @UploadedFile() file: Express.Multer.File, + @Param() param: ProjectParam, + @Req() req: any, + ): Promise { + await this.commissionService.processCsv(file.path); + return { + message: 'CSV file received and processing started', + success: true, + }; + } +} diff --git a/src/commission-device/controllers/index.ts b/src/commission-device/controllers/index.ts new file mode 100644 index 0000000..d14f1f0 --- /dev/null +++ b/src/commission-device/controllers/index.ts @@ -0,0 +1 @@ +export * from './commission-device.controller'; diff --git a/src/commission-device/dto/commission-device.dto.ts b/src/commission-device/dto/commission-device.dto.ts new file mode 100644 index 0000000..0c9f126 --- /dev/null +++ b/src/commission-device/dto/commission-device.dto.ts @@ -0,0 +1,18 @@ +// dto/commission-device.dto.ts + +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, Validate } from 'class-validator'; +import { FileValidator } from 'src/validators/file.validator'; + +export class CommissionDeviceCsvDto { + @ApiProperty({ + type: 'string', + format: 'binary', + description: 'CSV file containing device data', + }) + @IsNotEmpty({ message: 'CSV file is required' }) + @Validate(FileValidator, ['text/csv', 'application/vnd.ms-excel'], { + message: 'Only CSV files are allowed', + }) + file: any; +} diff --git a/src/commission-device/dto/index.ts b/src/commission-device/dto/index.ts new file mode 100644 index 0000000..7fa4ae7 --- /dev/null +++ b/src/commission-device/dto/index.ts @@ -0,0 +1 @@ +export * from './commission-device.dto'; diff --git a/src/commission-device/services/commission-device.service.ts b/src/commission-device/services/commission-device.service.ts new file mode 100644 index 0000000..15fc6ef --- /dev/null +++ b/src/commission-device/services/commission-device.service.ts @@ -0,0 +1,45 @@ +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 { DeviceService } from 'src/device/services'; + +@Injectable() +export class DeviceCommissionService { + constructor( + private readonly tuyaService: TuyaService, + private readonly deviceService: DeviceService, + ) {} + + async processCsv(filePath: string): Promise { + return new Promise((resolve, reject) => { + const results = []; + + fs.createReadStream(filePath) + .pipe(csv()) + .on('data', async (row) => { + console.log(`Device: ${JSON.stringify(row)}`); + const deviceId = row.deviceId?.trim(); + + 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); + }); + }); + } +} diff --git a/src/commission-device/services/index.ts b/src/commission-device/services/index.ts new file mode 100644 index 0000000..e9203a9 --- /dev/null +++ b/src/commission-device/services/index.ts @@ -0,0 +1 @@ +export * from './commission-device.service'; diff --git a/src/validators/file.validator.ts b/src/validators/file.validator.ts new file mode 100644 index 0000000..29c5422 --- /dev/null +++ b/src/validators/file.validator.ts @@ -0,0 +1,19 @@ +import { + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, +} from 'class-validator'; + +@ValidatorConstraint({ name: 'fileValidator', async: false }) +export class FileValidator implements ValidatorConstraintInterface { + constructor(private readonly allowedMimeTypes: string[]) {} + + validate(file: Express.Multer.File, _args: ValidationArguments) { + if (!file || !file.mimetype) return false; + return this.allowedMimeTypes.includes(file.mimetype); + } + + defaultMessage(_args: ValidationArguments) { + return 'Invalid file type'; + } +}