From d065742d87211b569891afe6d9ecac4e9b7f5988 Mon Sep 17 00:00:00 2001 From: Mhd Zayd Skaff Date: Tue, 22 Jul 2025 15:38:09 +0300 Subject: [PATCH] task: add space filter to scenes & automations --- libs/common/src/constants/controller-route.ts | 9 + .../controllers/automation.controller.ts | 41 +- .../dtos/get-automation-by-spaces.dto.ts | 20 + src/automation/services/automation.service.ts | 649 +++++++++--------- src/scene/controllers/scene.controller.ts | 40 +- src/scene/dtos/scene.dto.ts | 38 +- src/scene/services/scene.service.ts | 614 +++++++++-------- 7 files changed, 751 insertions(+), 660 deletions(-) create mode 100644 src/automation/dtos/get-automation-by-spaces.dto.ts diff --git a/libs/common/src/constants/controller-route.ts b/libs/common/src/constants/controller-route.ts index 2f106b8..5f7e78e 100644 --- a/libs/common/src/constants/controller-route.ts +++ b/libs/common/src/constants/controller-route.ts @@ -188,6 +188,11 @@ export class ControllerRoute { static SCENE = class { public static readonly ROUTE = 'scene'; static ACTIONS = class { + public static readonly GET_TAP_TO_RUN_SCENES_SUMMARY = + 'Get Tap-to-Run Scenes by spaces'; + public static readonly GET_TAP_TO_RUN_SCENES_DESCRIPTION = + 'Gets Tap-to-Run scenes by spaces'; + public static readonly CREATE_TAP_TO_RUN_SCENE_SUMMARY = 'Create a Tap-to-Run Scene'; public static readonly CREATE_TAP_TO_RUN_SCENE_DESCRIPTION = @@ -772,6 +777,10 @@ export class ControllerRoute { public static readonly ADD_AUTOMATION_DESCRIPTION = 'This endpoint creates a new automation based on the provided details.'; + public static readonly GET_AUTOMATION_SUMMARY = 'Get all automations'; + public static readonly GET_AUTOMATION_DESCRIPTION = + 'This endpoint retrieves automations data'; + public static readonly GET_AUTOMATION_DETAILS_SUMMARY = 'Get automation details'; public static readonly GET_AUTOMATION_DETAILS_DESCRIPTION = diff --git a/src/automation/controllers/automation.controller.ts b/src/automation/controllers/automation.controller.ts index f97499a..077ad7f 100644 --- a/src/automation/controllers/automation.controller.ts +++ b/src/automation/controllers/automation.controller.ts @@ -1,4 +1,7 @@ -import { AutomationService } from '../services/automation.service'; +import { ControllerRoute } from '@app/common/constants/controller-route'; +import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; +import { ProjectParam } from '@app/common/dto/project-param.dto'; +import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { Body, Controller, @@ -9,20 +12,20 @@ import { Patch, Post, Put, + Query, UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Permissions } from 'src/decorators/permissions.decorator'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { AutomationParamDto } from '../dtos'; import { AddAutomationDto, UpdateAutomationDto, UpdateAutomationStatusDto, } from '../dtos/automation.dto'; -import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; -import { AutomationParamDto } from '../dtos'; -import { ControllerRoute } from '@app/common/constants/controller-route'; -import { PermissionsGuard } from 'src/guards/permissions.guard'; -import { Permissions } from 'src/decorators/permissions.decorator'; -import { ProjectParam } from '@app/common/dto/project-param.dto'; +import { GetAutomationBySpacesDto } from '../dtos/get-automation-by-spaces.dto'; +import { AutomationService } from '../services/automation.service'; @ApiTags('Automation Module') @Controller({ @@ -56,6 +59,28 @@ export class AutomationController { }; } + @ApiBearerAuth() + @UseGuards(PermissionsGuard) + @Permissions('AUTOMATION_VIEW') + @Get('') + @ApiOperation({ + summary: ControllerRoute.AUTOMATION.ACTIONS.GET_AUTOMATION_SUMMARY, + description: ControllerRoute.AUTOMATION.ACTIONS.GET_AUTOMATION_DESCRIPTION, + }) + async getAutomationBySpaces( + @Param() param: ProjectParam, + @Query() spaces: GetAutomationBySpacesDto, + ) { + const automation = await this.automationService.getAutomationBySpaces( + spaces, + param.projectUuid, + ); + return new SuccessResponseDto({ + message: 'Automation retrieved Successfully', + data: automation, + }); + } + @ApiBearerAuth() @UseGuards(PermissionsGuard) @Permissions('AUTOMATION_VIEW') diff --git a/src/automation/dtos/get-automation-by-spaces.dto.ts b/src/automation/dtos/get-automation-by-spaces.dto.ts new file mode 100644 index 0000000..538cb29 --- /dev/null +++ b/src/automation/dtos/get-automation-by-spaces.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsOptional, IsUUID } from 'class-validator'; + +export class GetAutomationBySpacesDto { + @ApiProperty({ + description: 'List of Space IDs to filter automation', + required: false, + example: ['60d21b4667d0d8992e610c85', '60d21b4967d0d8992e610c86'], + }) + @IsOptional() + @Transform(({ value }) => { + if (!Array.isArray(value)) { + return [value]; + } + return value; + }) + @IsUUID('4', { each: true }) + public spaces?: string[]; +} diff --git a/src/automation/services/automation.service.ts b/src/automation/services/automation.service.ts index 41ace2f..3a58ea1 100644 --- a/src/automation/services/automation.service.ts +++ b/src/automation/services/automation.service.ts @@ -1,28 +1,3 @@ -import { - Injectable, - HttpException, - HttpStatus, - BadRequestException, -} from '@nestjs/common'; -import { SpaceRepository } from '@app/common/modules/space/repositories'; -import { - AddAutomationDto, - AutomationParamDto, - UpdateAutomationDto, - UpdateAutomationStatusDto, -} from '../dtos'; -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 { - Action, - AddAutomationParams, - AutomationDetailsResult, - AutomationResponseData, - Condition, -} from '../interface/automation.interface'; -import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter'; import { ActionExecutorEnum, ActionTypeEnum, @@ -30,18 +5,45 @@ import { AUTOMATION_TYPE, EntityTypeEnum, } from '@app/common/constants/automation.enum'; -import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; +import { BaseResponseDto } from '@app/common/dto/base.response.dto'; +import { GetSpaceParam } from '@app/common/dto/get.space.param'; +import { ProjectParam } from '@app/common/dto/project-param.dto'; +import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; +import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter'; +import { convertKeysToSnakeCase } from '@app/common/helper/snakeCaseConverter'; import { ConvertedAction } from '@app/common/integrations/tuya/interfaces'; +import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; +import { AutomationEntity } from '@app/common/modules/automation/entities'; +import { AutomationRepository } from '@app/common/modules/automation/repositories'; +import { ProjectRepository } from '@app/common/modules/project/repositiories'; import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; import { SceneRepository } from '@app/common/modules/scene/repositories'; -import { AutomationRepository } from '@app/common/modules/automation/repositories'; -import { BaseResponseDto } from '@app/common/dto/base.response.dto'; -import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; -import { AutomationEntity } from '@app/common/modules/automation/entities'; +import { SpaceRepository } from '@app/common/modules/space/repositories'; +import { + BadRequestException, + HttpException, + HttpStatus, + Injectable, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TuyaContext } from '@tuya/tuya-connector-nodejs'; +import { DeviceService } from 'src/device/services'; import { DeleteTapToRunSceneInterface } from 'src/scene/interface/scene.interface'; -import { ProjectParam } from '@app/common/dto/project-param.dto'; -import { ProjectRepository } from '@app/common/modules/project/repositiories'; -import { GetSpaceParam } from '@app/common/dto/get.space.param'; +import { In } from 'typeorm'; +import { + AddAutomationDto, + AutomationParamDto, + UpdateAutomationDto, + UpdateAutomationStatusDto, +} from '../dtos'; +import { GetAutomationBySpacesDto } from '../dtos/get-automation-by-spaces.dto'; +import { + Action, + AddAutomationParams, + AutomationDetailsResult, + AutomationResponseData, + Condition, +} from '../interface/automation.interface'; @Injectable() export class AutomationService { @@ -112,128 +114,25 @@ export class AutomationService { ); } } - async createAutomationExternalService( - params: AddAutomationParams, + async getAutomationBySpace({ projectUuid, spaceUuid }: GetSpaceParam) { + return this.getAutomationBySpaces({ spaces: [spaceUuid] }, projectUuid); + } + + async getAutomationBySpaces( + { spaces }: GetAutomationBySpacesDto, projectUuid: string, ) { try { - const formattedActions = await this.prepareActions( - params.actions, - projectUuid, - ); - const formattedCondition = await this.prepareConditions( - params.conditions, - projectUuid, - ); - - const response = await this.tuyaService.createAutomation( - params.spaceTuyaId, - params.automationName, - params.effectiveTime, - params.decisionExpr, - formattedCondition, - formattedActions, - ); - - if (!response.result?.id) { - throw new HttpException( - 'Failed to create automation in Tuya', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - return response; - } catch (err) { - if (err instanceof HttpException) { - throw err; - } else if (err.message?.includes('tuya')) { - throw new HttpException( - 'API error: Failed to create automation', - HttpStatus.BAD_GATEWAY, - ); - } else { - throw new HttpException( - `An Internal error has been occured ${err}`, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } - } - async add(params: AddAutomationParams, projectUuid: string) { - try { - const response = await this.createAutomationExternalService( - params, - projectUuid, - ); - - const automation = await this.automationRepository.save({ - automationTuyaUuid: response.result.id, - space: { uuid: params.spaceUuid }, - }); - - return automation; - } catch (err) { - if (err instanceof HttpException) { - throw err; - } else if (err.message?.includes('tuya')) { - throw new HttpException( - 'API error: Failed to create automation', - HttpStatus.BAD_GATEWAY, - ); - } else { - throw new HttpException( - 'Database error: Failed to save automation', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } - } - - async getSpaceByUuid(spaceUuid: string, projectUuid: string) { - try { - const space = await this.spaceRepository.findOne({ - where: { - uuid: spaceUuid, - community: { - project: { - uuid: projectUuid, - }, - }, - }, - relations: ['community'], - }); - if (!space) { - throw new BadRequestException(`Invalid space UUID ${spaceUuid}`); - } - return { - uuid: space.uuid, - createdAt: space.createdAt, - updatedAt: space.updatedAt, - name: space.spaceName, - spaceTuyaUuid: space.community.externalId, - }; - } catch (err) { - if (err instanceof BadRequestException) { - throw err; // Re-throw BadRequestException - } else { - throw new HttpException('Space not found', HttpStatus.NOT_FOUND); - } - } - } - - async getAutomationBySpace(param: GetSpaceParam) { - try { - await this.validateProject(param.projectUuid); + await this.validateProject(projectUuid); // Fetch automation data from the repository const automationData = await this.automationRepository.find({ where: { space: { - uuid: param.spaceUuid, + uuid: In(spaces ?? []), community: { - uuid: param.communityUuid, project: { - uuid: param.projectUuid, + uuid: projectUuid, }, }, }, @@ -290,46 +189,277 @@ export class AutomationService { } } - async findAutomationBySpace(spaceUuid: string, projectUuid: string) { + async getAutomationDetails(param: AutomationParamDto) { + await this.validateProject(param.projectUuid); + try { - await this.getSpaceByUuid(spaceUuid, projectUuid); - - const automationData = await this.automationRepository.find({ - where: { - space: { uuid: spaceUuid }, - disabled: false, - }, - relations: ['space'], - }); - - const automations = await Promise.all( - automationData.map(async (automation) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { actions, ...automationDetails } = - await this.getAutomation(automation); - - return automationDetails; - }), + const automation = await this.findAutomationByUuid( + param.automationUuid, + param.projectUuid, ); - return automations; - } catch (err) { + const automationDetails = await this.getAutomation(automation); + + return automationDetails; + } catch (error) { console.error( - `Error fetching Tap-to-Run scenes for space UUID ${spaceUuid}:`, - err.message, + `Error fetching automation details for automationUuid ${param.automationUuid}:`, + error, ); - if (err instanceof HttpException) { - throw err; + if (error instanceof HttpException) { + throw error; } else { throw new HttpException( - 'An error occurred while retrieving scenes', + 'An error occurred while retrieving automation details', HttpStatus.INTERNAL_SERVER_ERROR, ); } } } - async getTapToRunSceneDetailsTuya( + async updateAutomation( + updateAutomationDto: UpdateAutomationDto, + param: AutomationParamDto, + ) { + await this.validateProject(param.projectUuid); + + try { + const automation = await this.findAutomationByUuid( + param.automationUuid, + param.projectUuid, + ); + const space = await this.getSpaceByUuid( + automation.space.uuid, + param.projectUuid, + ); + + const updateTuyaAutomationResponse = + await this.updateAutomationExternalService( + space.spaceTuyaUuid, + automation.automationTuyaUuid, + updateAutomationDto, + param.projectUuid, + ); + + if (!updateTuyaAutomationResponse.success) { + throw new HttpException( + `Failed to update a external automation`, + HttpStatus.BAD_GATEWAY, + ); + } + const updatedScene = await this.automationRepository.update( + { uuid: param.automationUuid }, + { + space: { uuid: automation.space.uuid }, + }, + ); + return new SuccessResponseDto({ + data: updatedScene, + message: `Automation with ID ${param.automationUuid} updated successfully`, + }); + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException( + err.message || 'Automation not found', + err.status || HttpStatus.NOT_FOUND, + ); + } + } + } + async updateAutomationStatus( + updateAutomationStatusDto: UpdateAutomationStatusDto, + param: AutomationParamDto, + ) { + const { isEnable, spaceUuid } = updateAutomationStatusDto; + await this.validateProject(param.projectUuid); + + try { + const automation = await this.findAutomationByUuid( + param.automationUuid, + param.projectUuid, + ); + const space = await this.getSpaceByUuid(spaceUuid, param.projectUuid); + if (!space.spaceTuyaUuid) { + throw new HttpException( + `Invalid space UUID ${spaceUuid}`, + HttpStatus.NOT_FOUND, + ); + } + + const response = await this.tuyaService.updateAutomationState( + space.spaceTuyaUuid, + automation.automationTuyaUuid, + isEnable, + ); + + return response; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException( + err.message || 'Automation not found', + err.status || HttpStatus.NOT_FOUND, + ); + } + } + } + async deleteAutomation(param: AutomationParamDto) { + const { automationUuid } = param; + await this.validateProject(param.projectUuid); + + try { + const automationData = await this.findAutomationByUuid( + automationUuid, + param.projectUuid, + ); + const space = await this.getSpaceByUuid( + automationData.space.uuid, + param.projectUuid, + ); + await this.delete(automationData.automationTuyaUuid, space.spaceTuyaUuid); + const existingSceneDevice = await this.sceneDeviceRepository.findOne({ + where: { automationTuyaUuid: automationData.automationTuyaUuid }, + }); + + if (existingSceneDevice) { + await this.sceneDeviceRepository.delete({ + automationTuyaUuid: automationData.automationTuyaUuid, + }); + } + await this.automationRepository.update( + { + uuid: automationUuid, + }, + { disabled: true }, + ); + return new SuccessResponseDto({ + message: `Automation with ID ${automationUuid} deleted successfully`, + }); + } catch (err) { + if (err instanceof HttpException) { + throw err; + } else { + throw new HttpException( + err.message || `Automation not found for id ${param.automationUuid}`, + err.status || HttpStatus.NOT_FOUND, + ); + } + } + } + + private async createAutomationExternalService( + params: AddAutomationParams, + projectUuid: string, + ) { + try { + const formattedActions = await this.prepareActions( + params.actions, + projectUuid, + ); + const formattedCondition = await this.prepareConditions( + params.conditions, + projectUuid, + ); + + const response = await this.tuyaService.createAutomation( + params.spaceTuyaId, + params.automationName, + params.effectiveTime, + params.decisionExpr, + formattedCondition, + formattedActions, + ); + + if (!response.result?.id) { + throw new HttpException( + 'Failed to create automation in Tuya', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return response; + } catch (err) { + if (err instanceof HttpException) { + throw err; + } else if (err.message?.includes('tuya')) { + throw new HttpException( + 'API error: Failed to create automation', + HttpStatus.BAD_GATEWAY, + ); + } else { + throw new HttpException( + `An Internal error has been occured ${err}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + } + private async add(params: AddAutomationParams, projectUuid: string) { + try { + const response = await this.createAutomationExternalService( + params, + projectUuid, + ); + + const automation = await this.automationRepository.save({ + automationTuyaUuid: response.result.id, + space: { uuid: params.spaceUuid }, + }); + + return automation; + } catch (err) { + if (err instanceof HttpException) { + throw err; + } else if (err.message?.includes('tuya')) { + throw new HttpException( + 'API error: Failed to create automation', + HttpStatus.BAD_GATEWAY, + ); + } else { + throw new HttpException( + 'Database error: Failed to save automation', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + } + + private async getSpaceByUuid(spaceUuid: string, projectUuid: string) { + try { + const space = await this.spaceRepository.findOne({ + where: { + uuid: spaceUuid, + community: { + project: { + uuid: projectUuid, + }, + }, + }, + relations: ['community'], + }); + if (!space) { + throw new BadRequestException(`Invalid space UUID ${spaceUuid}`); + } + return { + uuid: space.uuid, + createdAt: space.createdAt, + updatedAt: space.updatedAt, + name: space.spaceName, + spaceTuyaUuid: space.community.externalId, + }; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException('Space not found', HttpStatus.NOT_FOUND); + } + } + } + + private async getTapToRunSceneDetailsTuya( sceneUuid: string, ): Promise { try { @@ -361,35 +491,8 @@ export class AutomationService { } } } - async getAutomationDetails(param: AutomationParamDto) { - await this.validateProject(param.projectUuid); - try { - const automation = await this.findAutomation( - param.automationUuid, - param.projectUuid, - ); - - const automationDetails = await this.getAutomation(automation); - - return automationDetails; - } catch (error) { - console.error( - `Error fetching automation details for automationUuid ${param.automationUuid}:`, - error, - ); - - if (error instanceof HttpException) { - throw error; - } else { - throw new HttpException( - 'An error occurred while retrieving automation details', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } - } - async getAutomation(automation: AutomationEntity) { + private async getAutomation(automation: AutomationEntity) { try { const response = await this.tuyaService.getSceneRule( automation.automationTuyaUuid, @@ -496,13 +599,13 @@ export class AutomationService { } } } - async findAutomation( - sceneUuid: string, + private async findAutomationByUuid( + uuid: string, projectUuid: string, ): Promise { const automation = await this.automationRepository.findOne({ where: { - uuid: sceneUuid, + uuid: uuid, space: { community: { project: { uuid: projectUuid } } }, }, relations: ['space'], @@ -510,57 +613,14 @@ export class AutomationService { if (!automation) { throw new HttpException( - `Invalid automation with id ${sceneUuid}`, + `Invalid automation with id ${uuid}`, HttpStatus.NOT_FOUND, ); } return automation; } - async deleteAutomation(param: AutomationParamDto) { - const { automationUuid } = param; - await this.validateProject(param.projectUuid); - - try { - const automationData = await this.findAutomation( - automationUuid, - param.projectUuid, - ); - const space = await this.getSpaceByUuid( - automationData.space.uuid, - param.projectUuid, - ); - await this.delete(automationData.automationTuyaUuid, space.spaceTuyaUuid); - const existingSceneDevice = await this.sceneDeviceRepository.findOne({ - where: { automationTuyaUuid: automationData.automationTuyaUuid }, - }); - - if (existingSceneDevice) { - await this.sceneDeviceRepository.delete({ - automationTuyaUuid: automationData.automationTuyaUuid, - }); - } - await this.automationRepository.update( - { - uuid: automationUuid, - }, - { disabled: true }, - ); - return new SuccessResponseDto({ - message: `Automation with ID ${automationUuid} deleted successfully`, - }); - } catch (err) { - if (err instanceof HttpException) { - throw err; - } else { - throw new HttpException( - err.message || `Automation not found for id ${param.automationUuid}`, - err.status || HttpStatus.NOT_FOUND, - ); - } - } - } - async delete(tuyaAutomationId: string, tuyaSpaceId: string) { + private async delete(tuyaAutomationId: string, tuyaSpaceId: string) { try { const response = (await this.tuyaService.deleteSceneRule( tuyaAutomationId, @@ -578,7 +638,7 @@ export class AutomationService { } } } - async updateAutomationExternalService( + private async updateAutomationExternalService( spaceTuyaUuid: string, automationUuid: string, updateAutomationDto: UpdateAutomationDto, @@ -626,95 +686,6 @@ export class AutomationService { } } } - async updateAutomation( - updateAutomationDto: UpdateAutomationDto, - param: AutomationParamDto, - ) { - await this.validateProject(param.projectUuid); - - try { - const automation = await this.findAutomation( - param.automationUuid, - param.projectUuid, - ); - const space = await this.getSpaceByUuid( - automation.space.uuid, - param.projectUuid, - ); - - const updateTuyaAutomationResponse = - await this.updateAutomationExternalService( - space.spaceTuyaUuid, - automation.automationTuyaUuid, - updateAutomationDto, - param.projectUuid, - ); - - if (!updateTuyaAutomationResponse.success) { - throw new HttpException( - `Failed to update a external automation`, - HttpStatus.BAD_GATEWAY, - ); - } - const updatedScene = await this.automationRepository.update( - { uuid: param.automationUuid }, - { - space: { uuid: automation.space.uuid }, - }, - ); - return new SuccessResponseDto({ - data: updatedScene, - message: `Automation with ID ${param.automationUuid} updated successfully`, - }); - } catch (err) { - if (err instanceof BadRequestException) { - throw err; // Re-throw BadRequestException - } else { - throw new HttpException( - err.message || 'Automation not found', - err.status || HttpStatus.NOT_FOUND, - ); - } - } - } - async updateAutomationStatus( - updateAutomationStatusDto: UpdateAutomationStatusDto, - param: AutomationParamDto, - ) { - const { isEnable, spaceUuid } = updateAutomationStatusDto; - await this.validateProject(param.projectUuid); - - try { - const automation = await this.findAutomation( - param.automationUuid, - param.projectUuid, - ); - const space = await this.getSpaceByUuid(spaceUuid, param.projectUuid); - if (!space.spaceTuyaUuid) { - throw new HttpException( - `Invalid space UUID ${spaceUuid}`, - HttpStatus.NOT_FOUND, - ); - } - - const response = await this.tuyaService.updateAutomationState( - space.spaceTuyaUuid, - automation.automationTuyaUuid, - isEnable, - ); - - return response; - } catch (err) { - if (err instanceof BadRequestException) { - throw err; // Re-throw BadRequestException - } else { - throw new HttpException( - err.message || 'Automation not found', - err.status || HttpStatus.NOT_FOUND, - ); - } - } - } private async prepareActions( actions: Action[], @@ -753,7 +724,7 @@ export class AutomationService { action.action_executor === ActionExecutorEnum.RULE_ENABLE ) { if (action.action_type === ActionTypeEnum.AUTOMATION) { - const automation = await this.findAutomation( + const automation = await this.findAutomationByUuid( action.entity_id, projectUuid, ); diff --git a/src/scene/controllers/scene.controller.ts b/src/scene/controllers/scene.controller.ts index 5e0194a..793cf90 100644 --- a/src/scene/controllers/scene.controller.ts +++ b/src/scene/controllers/scene.controller.ts @@ -1,4 +1,7 @@ -import { SceneService } from '../services/scene.service'; +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 { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { Body, Controller, @@ -8,21 +11,21 @@ import { Param, Post, Put, + Query, Req, UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Permissions } from 'src/decorators/permissions.decorator'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { SceneParamDto } from '../dtos'; import { AddSceneIconDto, AddSceneTapToRunDto, + GetSceneDto, UpdateSceneTapToRunDto, } from '../dtos/scene.dto'; -import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; -import { SceneParamDto } from '../dtos'; -import { BaseResponseDto } from '@app/common/dto/base.response.dto'; -import { ControllerRoute } from '@app/common/constants/controller-route'; -import { PermissionsGuard } from 'src/guards/permissions.guard'; -import { Permissions } from 'src/decorators/permissions.decorator'; +import { SceneService } from '../services/scene.service'; @ApiTags('Scene Module') @Controller({ @@ -52,6 +55,27 @@ export class SceneController { ); } + @ApiBearerAuth() + @UseGuards(PermissionsGuard) + @Permissions('SCENES_VIEW') + @Get('tap-to-run') + @ApiOperation({ + summary: ControllerRoute.SCENE.ACTIONS.GET_TAP_TO_RUN_SCENES_SUMMARY, + description: + ControllerRoute.SCENE.ACTIONS.GET_TAP_TO_RUN_SCENES_DESCRIPTION, + }) + async getTapToRunSceneBySpaces( + @Query() dto: GetSceneDto, + @Req() req: any, + ): Promise { + const projectUuid = req.user.project.uuid; + const data = await this.sceneService.findScenesBySpaces(dto, projectUuid); + return new SuccessResponseDto({ + message: 'Scenes Retrieved Successfully', + data, + }); + } + @ApiBearerAuth() @UseGuards(PermissionsGuard) @Permissions('SCENES_DELETE') diff --git a/src/scene/dtos/scene.dto.ts b/src/scene/dtos/scene.dto.ts index 1062d90..13b23e7 100644 --- a/src/scene/dtos/scene.dto.ts +++ b/src/scene/dtos/scene.dto.ts @@ -1,15 +1,16 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { - IsNotEmpty, - IsString, - IsArray, - ValidateNested, - IsOptional, - IsNumber, - IsBoolean, -} from 'class-validator'; -import { Transform, Type } from 'class-transformer'; import { BooleanValues } from '@app/common/constants/boolean-values.enum'; +import { ApiProperty } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; class ExecutorProperty { @ApiProperty({ @@ -187,4 +188,19 @@ export class GetSceneDto { return value.obj.showInHomePage === BooleanValues.TRUE; }) public showInHomePage: boolean = false; + + @ApiProperty({ + description: 'List of Space IDs to filter automation', + required: false, + example: ['60d21b4667d0d8992e610c85', '60d21b4967d0d8992e610c86'], + }) + @IsOptional() + @Transform(({ value }) => { + if (!Array.isArray(value)) { + return [value]; + } + return value; + }) + @IsUUID('4', { each: true }) + public spaces?: string[]; } diff --git a/src/scene/services/scene.service.ts b/src/scene/services/scene.service.ts index fc2731b..9c69bc2 100644 --- a/src/scene/services/scene.service.ts +++ b/src/scene/services/scene.service.ts @@ -1,12 +1,36 @@ import { - Injectable, - HttpException, - HttpStatus, + ActionExecutorEnum, + ActionTypeEnum, +} from '@app/common/constants/automation.enum'; +import { SceneIconType } from '@app/common/constants/secne-icon-type.enum'; +import { BaseResponseDto } from '@app/common/dto/base.response.dto'; +import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; +import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter'; +import { convertKeysToSnakeCase } from '@app/common/helper/snakeCaseConverter'; +import { ConvertedAction } from '@app/common/integrations/tuya/interfaces'; +import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; +import { AutomationRepository } from '@app/common/modules/automation/repositories'; +import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; +import { + SceneEntity, + SceneIconEntity, +} from '@app/common/modules/scene/entities'; +import { + SceneIconRepository, + SceneRepository, +} from '@app/common/modules/scene/repositories'; +import { SpaceRepository } from '@app/common/modules/space/repositories'; +import { BadRequestException, forwardRef, + HttpException, + HttpStatus, Inject, + Injectable, } from '@nestjs/common'; -import { SpaceRepository } from '@app/common/modules/space/repositories'; +import { HttpStatusCode } from 'axios'; +import { DeviceService } from 'src/device/services'; +import { In } from 'typeorm'; import { Action, AddSceneIconDto, @@ -15,35 +39,12 @@ import { SceneParamDto, UpdateSceneTapToRunDto, } from '../dtos'; -import { convertKeysToSnakeCase } from '@app/common/helper/snakeCaseConverter'; import { AddTapToRunSceneInterface, DeleteTapToRunSceneInterface, SceneDetails, SceneDetailsResult, } from '../interface/scene.interface'; -import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter'; -import { - ActionExecutorEnum, - ActionTypeEnum, -} from '@app/common/constants/automation.enum'; -import { - SceneIconRepository, - SceneRepository, -} from '@app/common/modules/scene/repositories'; -import { SceneIconType } from '@app/common/constants/secne-icon-type.enum'; -import { - SceneEntity, - SceneIconEntity, -} from '@app/common/modules/scene/entities'; -import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; -import { BaseResponseDto } from '@app/common/dto/base.response.dto'; -import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; -import { HttpStatusCode } from 'axios'; -import { ConvertedAction } from '@app/common/integrations/tuya/interfaces'; -import { DeviceService } from 'src/device/services'; -import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; -import { AutomationRepository } from '@app/common/modules/automation/repositories'; @Injectable() export class SceneService { @@ -92,158 +93,48 @@ export class SceneService { } } - async create( - spaceTuyaUuid: string, - addSceneTapToRunDto: AddSceneTapToRunDto, - projectUuid: string, - ): Promise { - const { iconUuid, showInHomePage, spaceUuid } = addSceneTapToRunDto; - - try { - const [defaultSceneIcon] = await Promise.all([ - this.getDefaultSceneIcon(), - ]); - if (!defaultSceneIcon) { - throw new HttpException( - 'Default scene icon not found', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - const response = await this.createSceneExternalService( - spaceTuyaUuid, - addSceneTapToRunDto, - projectUuid, - ); - - const scene = await this.sceneRepository.save({ - sceneTuyaUuid: response.result.id, - sceneIcon: { uuid: iconUuid || defaultSceneIcon.uuid }, - showInHomePage, - space: { uuid: spaceUuid }, - }); - - return scene; - } catch (err) { - if (err instanceof HttpException) { - throw err; - } else if (err.message?.includes('tuya')) { - throw new HttpException( - 'API error: Failed to create scene', - HttpStatus.BAD_GATEWAY, - ); - } else { - throw new HttpException( - 'Database error: Failed to save scene', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } - } - async updateSceneExternalService( - spaceTuyaUuid: string, - sceneTuyaUuid: string, - updateSceneTapToRunDto: UpdateSceneTapToRunDto, - projectUuid: string, - ) { - const { sceneName, decisionExpr, actions } = updateSceneTapToRunDto; - try { - const formattedActions = await this.prepareActions(actions, projectUuid); - - const response = (await this.tuyaService.updateTapToRunScene( - sceneTuyaUuid, - spaceTuyaUuid, - sceneName, - formattedActions, - decisionExpr, - )) as AddTapToRunSceneInterface; - - if (!response.success) { - throw new HttpException( - 'Failed to update scene in Tuya', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - return response; - } catch (err) { - if (err instanceof HttpException) { - throw err; - } else if (err.message?.includes('tuya')) { - throw new HttpException( - 'API error: Failed to update scene', - HttpStatus.BAD_GATEWAY, - ); - } else { - throw new HttpException( - `An Internal error has been occured ${err}`, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } - } - - async createSceneExternalService( - spaceTuyaUuid: string, - addSceneTapToRunDto: AddSceneTapToRunDto, - projectUuid: string, - ) { - const { sceneName, decisionExpr, actions } = addSceneTapToRunDto; - try { - const formattedActions = await this.prepareActions(actions, projectUuid); - - const response = (await this.tuyaService.addTapToRunScene( - spaceTuyaUuid, - sceneName, - formattedActions, - decisionExpr, - )) as AddTapToRunSceneInterface; - - if (!response.result?.id) { - throw new HttpException( - 'Failed to create scene in Tuya', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - return response; - } catch (err) { - if (err instanceof HttpException) { - throw err; - } else if (err.message?.includes('tuya')) { - throw new HttpException( - 'API error: Failed to create scene', - HttpStatus.BAD_GATEWAY, - ); - } else { - throw new HttpException( - `An Internal error has been occured ${err}`, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } - } - - async findScenesBySpace(spaceUuid: string, filter: GetSceneDto) { + async findScenesBySpace(spaceUuid: string, { showInHomePage }: GetSceneDto) { try { await this.getSpaceByUuid(spaceUuid); - const showInHomePage = filter?.showInHomePage; + return this.findScenesBySpaces({ showInHomePage, spaces: [spaceUuid] }); + } catch (error) { + console.error( + `Error fetching Tap-to-Run scenes for space UUID ${spaceUuid}:`, + error.message, + ); + throw error instanceof HttpException + ? error + : new HttpException( + 'An error occurred while retrieving scenes', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async findScenesBySpaces( + { showInHomePage, spaces }: GetSceneDto, + projectUuid?: string, + ) { + try { const scenesData = await this.sceneRepository.find({ where: { - space: { uuid: spaceUuid }, + space: { + uuid: In(spaces ?? []), + community: projectUuid ? { project: { uuid: projectUuid } } : null, + }, disabled: false, ...(showInHomePage ? { showInHomePage } : {}), }, relations: ['sceneIcon', 'space', 'space.community'], }); - const safeFetch = async (scene: any) => { + const safeFetch = async (scene: SceneEntity) => { try { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { actions, ...sceneDetails } = await this.getScene( scene, - spaceUuid, + scene.space.uuid, ); return sceneDetails; } catch (error) { @@ -259,7 +150,7 @@ export class SceneService { return scenes.filter(Boolean); // Remove null values } catch (error) { console.error( - `Error fetching Tap-to-Run scenes for space UUID ${spaceUuid}:`, + `Error fetching Tap-to-Run scenes for specified spaces:`, error.message, ); @@ -291,45 +182,6 @@ export class SceneService { } } - async fetchSceneDetailsFromTuya( - sceneId: string, - ): Promise { - try { - const response = await this.tuyaService.getSceneRule(sceneId); - const camelCaseResponse = convertKeysToCamelCase(response); - const { - id, - name, - type, - status, - actions: tuyaActions = [], - } = camelCaseResponse.result; - - const actions = tuyaActions.map((action) => ({ ...action })); - - return { - id, - name, - type, - status, - actions, - } as SceneDetailsResult; - } catch (err) { - console.error( - `Error fetching scene details for scene ID ${sceneId}:`, - err, - ); - if (err instanceof HttpException) { - throw err; - } else { - throw new HttpException( - 'An error occurred while fetching scene details from Tuya', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } - } - async updateTapToRunScene( updateSceneTapToRunDto: UpdateSceneTapToRunDto, sceneUuid: string, @@ -386,6 +238,38 @@ export class SceneService { } } + async deleteScene(params: SceneParamDto): Promise { + const { sceneUuid } = params; + try { + const scene = await this.findScene(sceneUuid); + const space = await this.getSpaceByUuid(scene.space.uuid); + + await this.delete(scene.sceneTuyaUuid, space.spaceTuyaUuid); + await this.sceneDeviceRepository.update( + { uuid: sceneUuid }, + { disabled: true }, + ); + await this.sceneRepository.update( + { + uuid: sceneUuid, + }, + { disabled: true }, + ); + return new SuccessResponseDto({ + message: `Scene with ID ${sceneUuid} deleted successfully`, + }); + } catch (err) { + if (err instanceof HttpException) { + throw err; + } else { + throw new HttpException( + err.message || `Scene not found for id ${params.sceneUuid}`, + err.status || HttpStatus.NOT_FOUND, + ); + } + } + } + async addSceneIcon(addSceneIconDto: AddSceneIconDto) { try { const icon = await this.sceneIconRepository.save({ @@ -454,7 +338,237 @@ export class SceneService { } } - async getScene(scene: SceneEntity, spaceUuid: string): Promise { + async findScene(sceneUuid: string): Promise { + const scene = await this.sceneRepository.findOne({ + where: { uuid: sceneUuid }, + relations: ['sceneIcon', 'space', 'space.community'], + }); + + if (!scene) { + throw new HttpException( + `Invalid scene with id ${sceneUuid}`, + HttpStatus.NOT_FOUND, + ); + } + return scene; + } + + async getSpaceByUuid(spaceUuid: string) { + try { + const space = await this.spaceRepository.findOne({ + where: { + uuid: spaceUuid, + }, + relations: ['community'], + }); + + if (!space) { + throw new HttpException( + `Invalid space UUID ${spaceUuid}`, + HttpStatusCode.BadRequest, + ); + } + + if (!space.community.externalId) { + throw new HttpException( + `Space doesn't have any association with tuya${spaceUuid}`, + HttpStatusCode.BadRequest, + ); + } + return { + uuid: space.uuid, + createdAt: space.createdAt, + updatedAt: space.updatedAt, + name: space.spaceName, + spaceTuyaUuid: space.community.externalId, + }; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; + } else { + throw new HttpException( + `Space with id ${spaceUuid} not found`, + HttpStatus.NOT_FOUND, + ); + } + } + } + + private async create( + spaceTuyaUuid: string, + addSceneTapToRunDto: AddSceneTapToRunDto, + projectUuid: string, + ): Promise { + const { iconUuid, showInHomePage, spaceUuid } = addSceneTapToRunDto; + + try { + const [defaultSceneIcon] = await Promise.all([ + this.getDefaultSceneIcon(), + ]); + if (!defaultSceneIcon) { + throw new HttpException( + 'Default scene icon not found', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const response = await this.createSceneExternalService( + spaceTuyaUuid, + addSceneTapToRunDto, + projectUuid, + ); + + const scene = await this.sceneRepository.save({ + sceneTuyaUuid: response.result.id, + sceneIcon: { uuid: iconUuid || defaultSceneIcon.uuid }, + showInHomePage, + space: { uuid: spaceUuid }, + }); + + return scene; + } catch (err) { + if (err instanceof HttpException) { + throw err; + } else if (err.message?.includes('tuya')) { + throw new HttpException( + 'API error: Failed to create scene', + HttpStatus.BAD_GATEWAY, + ); + } else { + throw new HttpException( + 'Database error: Failed to save scene', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + } + private async updateSceneExternalService( + spaceTuyaUuid: string, + sceneTuyaUuid: string, + updateSceneTapToRunDto: UpdateSceneTapToRunDto, + projectUuid: string, + ) { + const { sceneName, decisionExpr, actions } = updateSceneTapToRunDto; + try { + const formattedActions = await this.prepareActions(actions, projectUuid); + + const response = (await this.tuyaService.updateTapToRunScene( + sceneTuyaUuid, + spaceTuyaUuid, + sceneName, + formattedActions, + decisionExpr, + )) as AddTapToRunSceneInterface; + + if (!response.success) { + throw new HttpException( + 'Failed to update scene in Tuya', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return response; + } catch (err) { + if (err instanceof HttpException) { + throw err; + } else if (err.message?.includes('tuya')) { + throw new HttpException( + 'API error: Failed to update scene', + HttpStatus.BAD_GATEWAY, + ); + } else { + throw new HttpException( + `An Internal error has been occured ${err}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + } + + private async createSceneExternalService( + spaceTuyaUuid: string, + addSceneTapToRunDto: AddSceneTapToRunDto, + projectUuid: string, + ) { + const { sceneName, decisionExpr, actions } = addSceneTapToRunDto; + try { + const formattedActions = await this.prepareActions(actions, projectUuid); + + const response = (await this.tuyaService.addTapToRunScene( + spaceTuyaUuid, + sceneName, + formattedActions, + decisionExpr, + )) as AddTapToRunSceneInterface; + + if (!response.result?.id) { + throw new HttpException( + 'Failed to create scene in Tuya', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return response; + } catch (err) { + if (err instanceof HttpException) { + throw err; + } else if (err.message?.includes('tuya')) { + throw new HttpException( + 'API error: Failed to create scene', + HttpStatus.BAD_GATEWAY, + ); + } else { + throw new HttpException( + `An Internal error has been occured ${err}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + } + + private async fetchSceneDetailsFromTuya( + sceneId: string, + ): Promise { + try { + const response = await this.tuyaService.getSceneRule(sceneId); + const camelCaseResponse = convertKeysToCamelCase(response); + const { + id, + name, + type, + status, + actions: tuyaActions = [], + } = camelCaseResponse.result; + + const actions = tuyaActions.map((action) => ({ ...action })); + + return { + id, + name, + type, + status, + actions, + } as SceneDetailsResult; + } catch (err) { + console.error( + `Error fetching scene details for scene ID ${sceneId}:`, + err, + ); + if (err instanceof HttpException) { + throw err; + } else { + throw new HttpException( + 'An error occurred while fetching scene details from Tuya', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + } + + private async getScene( + scene: SceneEntity, + spaceUuid: string, + ): Promise { try { const { actions, name, status } = await this.fetchSceneDetailsFromTuya( scene.sceneTuyaUuid, @@ -519,54 +633,7 @@ export class SceneService { } } - async deleteScene(params: SceneParamDto): Promise { - const { sceneUuid } = params; - try { - const scene = await this.findScene(sceneUuid); - const space = await this.getSpaceByUuid(scene.space.uuid); - - await this.delete(scene.sceneTuyaUuid, space.spaceTuyaUuid); - await this.sceneDeviceRepository.update( - { uuid: sceneUuid }, - { disabled: true }, - ); - await this.sceneRepository.update( - { - uuid: sceneUuid, - }, - { disabled: true }, - ); - return new SuccessResponseDto({ - message: `Scene with ID ${sceneUuid} deleted successfully`, - }); - } catch (err) { - if (err instanceof HttpException) { - throw err; - } else { - throw new HttpException( - err.message || `Scene not found for id ${params.sceneUuid}`, - err.status || HttpStatus.NOT_FOUND, - ); - } - } - } - - async findScene(sceneUuid: string): Promise { - const scene = await this.sceneRepository.findOne({ - where: { uuid: sceneUuid }, - relations: ['sceneIcon', 'space', 'space.community'], - }); - - if (!scene) { - throw new HttpException( - `Invalid scene with id ${sceneUuid}`, - HttpStatus.NOT_FOUND, - ); - } - return scene; - } - - async delete(tuyaSceneId: string, tuyaSpaceId: string) { + private async delete(tuyaSceneId: string, tuyaSpaceId: string) { try { const response = (await this.tuyaService.deleteSceneRule( tuyaSceneId, @@ -626,45 +693,4 @@ export class SceneService { }); return defaultIcon; } - - async getSpaceByUuid(spaceUuid: string) { - try { - const space = await this.spaceRepository.findOne({ - where: { - uuid: spaceUuid, - }, - relations: ['community'], - }); - - if (!space) { - throw new HttpException( - `Invalid space UUID ${spaceUuid}`, - HttpStatusCode.BadRequest, - ); - } - - if (!space.community.externalId) { - throw new HttpException( - `Space doesn't have any association with tuya${spaceUuid}`, - HttpStatusCode.BadRequest, - ); - } - return { - uuid: space.uuid, - createdAt: space.createdAt, - updatedAt: space.updatedAt, - name: space.spaceName, - spaceTuyaUuid: space.community.externalId, - }; - } catch (err) { - if (err instanceof BadRequestException) { - throw err; - } else { - throw new HttpException( - `Space with id ${spaceUuid} not found`, - HttpStatus.NOT_FOUND, - ); - } - } - } }