diff --git a/src/app.module.ts b/src/app.module.ts index 9a4ef30..59b4275 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ import { RoleModule } from './role/role.module'; import { SeederModule } from '@app/common/seed/seeder.module'; import { UserNotificationModule } from './user-notification/user-notification.module'; import { DeviceMessagesSubscriptionModule } from './device-messages/device-messages.module'; +import { SceneModule } from './scene/scene.module'; @Module({ imports: [ ConfigModule.forRoot({ @@ -36,6 +37,7 @@ import { DeviceMessagesSubscriptionModule } from './device-messages/device-messa UserDevicePermissionModule, UserNotificationModule, SeederModule, + SceneModule, ], controllers: [AuthenticationController], }) diff --git a/src/scene/controllers/index.ts b/src/scene/controllers/index.ts new file mode 100644 index 0000000..10059c1 --- /dev/null +++ b/src/scene/controllers/index.ts @@ -0,0 +1 @@ +export * from './scene.controller'; diff --git a/src/scene/controllers/scene.controller.ts b/src/scene/controllers/scene.controller.ts new file mode 100644 index 0000000..14712b9 --- /dev/null +++ b/src/scene/controllers/scene.controller.ts @@ -0,0 +1,114 @@ +import { SceneService } from '../services/scene.service'; +import { + Body, + Controller, + Delete, + Get, + HttpException, + HttpStatus, + Param, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { AddSceneTapToRunDto } from '../dtos/add.scene.dto'; +import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; + +@ApiTags('Scene Module') +@Controller({ + version: '1', + path: 'scene', +}) +export class SceneController { + constructor(private readonly sceneService: SceneService) {} + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Post('tap-to-run') + async addTapToRunScene(@Body() addSceneTapToRunDto: AddSceneTapToRunDto) { + try { + const tapToRunScene = + await this.sceneService.addTapToRunScene(addSceneTapToRunDto); + return { + statusCode: HttpStatus.CREATED, + success: true, + message: 'Scene added successfully', + data: tapToRunScene, + }; + } catch (error) { + throw new HttpException( + error.message || 'Internal server error', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get('tap-to-run/:unitUuid') + async getTapToRunSceneByUnit(@Param('unitUuid') unitUuid: string) { + try { + const tapToRunScenes = + await this.sceneService.getTapToRunSceneByUnit(unitUuid); + return tapToRunScenes; + } catch (error) { + throw new HttpException( + error.message || 'Internal server error', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Delete('tap-to-run/:unitUuid/:sceneId') + async deleteTapToRunScene( + @Param('unitUuid') unitUuid: string, + @Param('sceneId') sceneId: string, + ) { + try { + await this.sceneService.deleteTapToRunScene(unitUuid, sceneId); + return { + statusCode: HttpStatus.OK, + message: 'Scene Deleted Successfully', + }; + } catch (error) { + throw new HttpException( + error.message || 'Internal server error', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Post('tap-to-run/trigger/:sceneId') + async triggerTapToRunScene(@Param('sceneId') sceneId: string) { + try { + await this.sceneService.triggerTapToRunScene(sceneId); + return { + statusCode: HttpStatus.CREATED, + success: true, + message: 'Scene trigger successfully', + }; + } catch (error) { + throw new HttpException( + error.message || 'Internal server error', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get('tap-to-run/details/:sceneId') + async triggerTapToRunSceneDetails(@Param('sceneId') sceneId: string) { + try { + const tapToRunScenes = + await this.sceneService.triggerTapToRunSceneDetails(sceneId); + return tapToRunScenes; + } catch (error) { + throw new HttpException( + error.message || 'Internal server error', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/scene/dtos/add.scene.dto.ts b/src/scene/dtos/add.scene.dto.ts new file mode 100644 index 0000000..d6b531d --- /dev/null +++ b/src/scene/dtos/add.scene.dto.ts @@ -0,0 +1,103 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsNotEmpty, + IsString, + IsArray, + ValidateNested, + IsOptional, + IsNumber, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +class ExecutorProperty { + @ApiProperty({ + description: 'Function code (for device issue action)', + required: false, + }) + @IsString() + @IsOptional() + public functionCode?: string; + + @ApiProperty({ + description: 'Function value (for device issue action)', + required: false, + }) + @IsOptional() + public functionValue?: any; + + @ApiProperty({ + description: 'Delay in seconds (for delay action)', + required: false, + }) + @IsNumber() + @IsOptional() + public delaySeconds?: number; +} + +class Action { + @ApiProperty({ + description: 'Entity ID', + required: true, + }) + @IsString() + @IsNotEmpty() + public entityId: string; + + @ApiProperty({ + description: 'Action executor', + required: true, + }) + @IsString() + @IsNotEmpty() + public actionExecutor: string; + + @ApiProperty({ + description: 'Executor property', + required: false, // Set required to false + type: ExecutorProperty, + }) + @ValidateNested() + @Type(() => ExecutorProperty) + @IsOptional() // Make executorProperty optional + public executorProperty?: ExecutorProperty; +} + +export class AddSceneTapToRunDto { + @ApiProperty({ + description: 'Unit UUID', + required: true, + }) + @IsString() + @IsNotEmpty() + public unitUuid: string; + + @ApiProperty({ + description: 'Scene name', + required: true, + }) + @IsString() + @IsNotEmpty() + public sceneName: string; + + @ApiProperty({ + description: 'Decision expression', + required: true, + }) + @IsString() + @IsNotEmpty() + public decisionExpr: string; + + @ApiProperty({ + description: 'Actions', + required: true, + type: [Action], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => Action) + public actions: Action[]; + + constructor(dto: Partial) { + Object.assign(this, dto); + } +} diff --git a/src/scene/dtos/index.ts b/src/scene/dtos/index.ts new file mode 100644 index 0000000..fbd1865 --- /dev/null +++ b/src/scene/dtos/index.ts @@ -0,0 +1 @@ +export * from './add.scene.dto'; diff --git a/src/scene/interface/scene.interface.ts b/src/scene/interface/scene.interface.ts new file mode 100644 index 0000000..00ebcae --- /dev/null +++ b/src/scene/interface/scene.interface.ts @@ -0,0 +1,23 @@ +export interface AddTapToRunSceneInterface { + success: boolean; + msg?: string; + result: { + id: string; + }; +} +export interface GetTapToRunSceneByUnitInterface { + success: boolean; + msg?: string; + result: { + list: Array<{ + id: string; + name: string; + status: string; + }>; + }; +} +export interface DeleteTapToRunSceneInterface { + success: boolean; + msg?: string; + result: boolean; +} diff --git a/src/scene/scene.module.ts b/src/scene/scene.module.ts new file mode 100644 index 0000000..248f2e6 --- /dev/null +++ b/src/scene/scene.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common'; +import { SceneService } from './services/scene.service'; +import { SceneController } from './controllers/scene.controller'; +import { ConfigModule } from '@nestjs/config'; +import { SpaceRepositoryModule } from '@app/common/modules/space/space.repository.module'; +import { SpaceRepository } from '@app/common/modules/space/repositories'; +import { DeviceService } from 'src/device/services'; +import { DeviceRepository } from '@app/common/modules/device/repositories'; +import { ProductRepository } from '@app/common/modules/product/repositories'; + +@Module({ + imports: [ConfigModule, SpaceRepositoryModule], + controllers: [SceneController], + providers: [ + SceneService, + SpaceRepository, + DeviceService, + DeviceRepository, + ProductRepository, + ], + exports: [SceneService], +}) +export class SceneModule {} diff --git a/src/scene/services/index.ts b/src/scene/services/index.ts new file mode 100644 index 0000000..3a7442e --- /dev/null +++ b/src/scene/services/index.ts @@ -0,0 +1 @@ +export * from './scene.service'; diff --git a/src/scene/services/scene.service.ts b/src/scene/services/scene.service.ts new file mode 100644 index 0000000..5623956 --- /dev/null +++ b/src/scene/services/scene.service.ts @@ -0,0 +1,239 @@ +import { + Injectable, + HttpException, + HttpStatus, + BadRequestException, +} from '@nestjs/common'; +import { SpaceRepository } from '@app/common/modules/space/repositories'; +import { AddSceneTapToRunDto } from '../dtos'; +import { GetUnitByUuidInterface } from 'src/unit/interface/unit.interface'; +import { ConfigService } from '@nestjs/config'; +import { TuyaContext } from '@tuya/tuya-connector-nodejs'; +import { convertKeysToSnakeCase } from '@app/common/helper/snakeCaseConverter'; +import { DeviceService } from 'src/device/services'; +import { + AddTapToRunSceneInterface, + DeleteTapToRunSceneInterface, + GetTapToRunSceneByUnitInterface, +} from '../interface/scene.interface'; +import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter'; + +@Injectable() +export class SceneService { + private tuya: TuyaContext; + constructor( + private readonly configService: ConfigService, + private readonly spaceRepository: SpaceRepository, + private readonly deviceService: DeviceService, + ) { + const accessKey = this.configService.get('auth-config.ACCESS_KEY'); + const secretKey = this.configService.get('auth-config.SECRET_KEY'); + this.tuya = new TuyaContext({ + baseUrl: 'https://openapi.tuyaeu.com', + accessKey, + secretKey, + }); + } + + async addTapToRunScene(addSceneTapToRunDto: AddSceneTapToRunDto) { + try { + const unit = await this.getUnitByUuid(addSceneTapToRunDto.unitUuid); + if (!unit.spaceTuyaUuid) { + throw new BadRequestException('Invalid unit UUID'); + } + const actions = addSceneTapToRunDto.actions.map((action) => { + return { + ...action, + }; + }); + + const convertedData = convertKeysToSnakeCase(actions); + for (const action of convertedData) { + if (action.action_executor === 'device_issue') { + const device = await this.deviceService.getDeviceByDeviceUuid( + action.entity_id, + false, + ); + if (device) { + action.entity_id = device.deviceTuyaUuid; + } + } + } + const path = `/v2.0/cloud/scene/rule`; + const response: AddTapToRunSceneInterface = await this.tuya.request({ + method: 'POST', + path, + body: { + space_id: unit.spaceTuyaUuid, + name: addSceneTapToRunDto.sceneName, + type: 'scene', + decision_expr: addSceneTapToRunDto.decisionExpr, + actions: convertedData, + }, + }); + if (!response.success) { + throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); + } + return { + id: response.result.id, + }; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException( + err.message || 'Scene not found', + err.status || HttpStatus.NOT_FOUND, + ); + } + } + } + async getUnitByUuid(unitUuid: string): Promise { + try { + const unit = await this.spaceRepository.findOne({ + where: { + uuid: unitUuid, + spaceType: { + type: 'unit', + }, + }, + relations: ['spaceType'], + }); + if (!unit || !unit.spaceType || unit.spaceType.type !== 'unit') { + throw new BadRequestException('Invalid building UUID'); + } + return { + uuid: unit.uuid, + createdAt: unit.createdAt, + updatedAt: unit.updatedAt, + name: unit.spaceName, + type: unit.spaceType.type, + spaceTuyaUuid: unit.spaceTuyaUuid, + }; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException('Unit not found', HttpStatus.NOT_FOUND); + } + } + } + async getTapToRunSceneByUnit(unitUuid: string) { + try { + const unit = await this.getUnitByUuid(unitUuid); + if (!unit.spaceTuyaUuid) { + throw new BadRequestException('Invalid unit UUID'); + } + + const path = `/v2.0/cloud/scene/rule?space_id=${unit.spaceTuyaUuid}&type=scene`; + const response: GetTapToRunSceneByUnitInterface = await this.tuya.request( + { + method: 'GET', + path, + }, + ); + + if (!response.success) { + throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); + } + + return response.result.list.map((item) => { + return { + id: item.id, + name: item.name, + status: item.status, + type: 'tap_to_run', + }; + }); + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException( + err.message || 'Scene not found', + err.status || HttpStatus.NOT_FOUND, + ); + } + } + } + async deleteTapToRunScene(unitUuid: string, sceneId: string) { + try { + const unit = await this.getUnitByUuid(unitUuid); + if (!unit.spaceTuyaUuid) { + throw new BadRequestException('Invalid unit UUID'); + } + + const path = `/v2.0/cloud/scene/rule?ids=${sceneId}&space_id=${unit.spaceTuyaUuid}`; + const response: DeleteTapToRunSceneInterface = await this.tuya.request({ + method: 'DELETE', + path, + }); + + if (!response.success) { + throw new HttpException('Scene not found', HttpStatus.NOT_FOUND); + } + + return response; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException( + err.message || 'Scene not found', + err.status || HttpStatus.NOT_FOUND, + ); + } + } + } + async triggerTapToRunScene(sceneId: string) { + try { + const path = `/v2.0/cloud/scene/rule/${sceneId}/actions/trigger`; + const response: DeleteTapToRunSceneInterface = await this.tuya.request({ + method: 'POST', + path, + }); + if (!response.success) { + throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); + } + return response; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException( + err.message || 'Scene not found', + err.status || HttpStatus.NOT_FOUND, + ); + } + } + } + async triggerTapToRunSceneDetails(sceneId: string) { + try { + const path = `/v2.0/cloud/scene/rule/${sceneId}`; + const response = await this.tuya.request({ + method: 'GET', + path, + }); + if (!response.success) { + throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); + } + const responseData = convertKeysToCamelCase(response.result); + return { + id: responseData.id, + name: responseData.name, + status: responseData.status, + type: 'tap_to_run', + actions: responseData.actions, + }; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException( + err.message || 'Scene not found', + err.status || HttpStatus.NOT_FOUND, + ); + } + } + } +}