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, AUTO_PREFIX, AUTOMATION_TYPE, EntityTypeEnum, } from '@app/common/constants/automation.enum'; import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; import { ConvertedAction } from '@app/common/integrations/tuya/interfaces'; 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 { 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'; @Injectable() export class AutomationService { private tuya: TuyaContext; constructor( private readonly configService: ConfigService, private readonly spaceRepository: SpaceRepository, private readonly deviceService: DeviceService, private readonly tuyaService: TuyaService, private readonly sceneDeviceRepository: SceneDeviceRepository, private readonly sceneRepository: SceneRepository, private readonly automationRepository: AutomationRepository, private readonly projectRepository: ProjectRepository, ) { const accessKey = this.configService.get('auth-config.ACCESS_KEY'); const secretKey = this.configService.get('auth-config.SECRET_KEY'); const tuyaEuUrl = this.configService.get('tuya-config.TUYA_EU_URL'); this.tuya = new TuyaContext({ baseUrl: tuyaEuUrl, accessKey, secretKey, }); } async addAutomation( param: ProjectParam, addAutomationDto: AddAutomationDto, ): Promise { await this.validateProject(param.projectUuid); try { const { automationName, effectiveTime, decisionExpr, actions, conditions, spaceUuid, } = addAutomationDto; const space = await this.getSpaceByUuid(spaceUuid, param.projectUuid); const automation = await this.add( { automationName, effectiveTime, decisionExpr, actions, conditions, spaceTuyaId: space.spaceTuyaUuid, spaceUuid, }, param.projectUuid, ); return new SuccessResponseDto({ message: `Successfully created new automation with uuid ${automation.uuid}`, data: automation, statusCode: HttpStatus.CREATED, }); } catch (err) { console.error( `Error in creating automation for space UUID ${addAutomationDto.spaceUuid}:`, err.message, ); throw err instanceof HttpException ? err : new HttpException( 'Failed to create automation', HttpStatus.INTERNAL_SERVER_ERROR, ); } } 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, ); } } } 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); // Fetch automation data from the repository const automationData = await this.automationRepository.find({ where: { space: { uuid: param.spaceUuid, community: { uuid: param.communityUuid, project: { uuid: param.projectUuid, }, }, }, disabled: false, }, relations: ['space', 'space.community'], }); // Safe fetch function to handle individual automation fetching const safeFetch = async (automation: any) => { try { const automationDetails = (await this.tuyaService.getSceneRule( automation.automationTuyaUuid, )) as { result?: { name?: string; status?: string } }; // Explicit type assertion if ( !automationDetails?.result?.name || automationDetails.result.name.startsWith(AUTO_PREFIX) ) { return null; // Skip invalid or auto-generated automations } return { uuid: automation.uuid, name: automationDetails.result.name, status: automationDetails.result.status, type: AUTOMATION_TYPE, spaceId: automation.space.uuid, spaceName: automation.space.spaceName, communityName: automation.space.community.name, communityId: automation.space.community.uuid, }; } catch (error) { console.warn( `Skipping automation with UUID: ${automation.uuid} due to error. ${error.message}`, ); return null; } }; // Process automations using safeFetch const automations = await Promise.all(automationData.map(safeFetch)); return automations.filter(Boolean); // Remove null values } catch (err) { console.error('Error retrieving automations:', err); if (err instanceof BadRequestException) { throw err; } throw new HttpException( err.message || 'Automation not found', err.status || HttpStatus.NOT_FOUND, ); } } async findAutomationBySpace(spaceUuid: string, projectUuid: string) { 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; }), ); return automations; } catch (err) { console.error( `Error fetching Tap-to-Run scenes for space UUID ${spaceUuid}:`, err.message, ); if (err instanceof HttpException) { throw err; } else { throw new HttpException( 'An error occurred while retrieving scenes', HttpStatus.INTERNAL_SERVER_ERROR, ); } } } async getTapToRunSceneDetailsTuya( sceneUuid: string, ): Promise { try { const path = `/v2.0/cloud/scene/rule/${sceneUuid}`; const response = await this.tuya.request({ method: 'GET', path, }); if (!response.success) { throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); } const camelCaseResponse = convertKeysToCamelCase(response); const { id, name, type } = camelCaseResponse.result; return { id, name, type, } as AutomationDetailsResult; } catch (err) { if (err instanceof BadRequestException) { throw err; // Re-throw BadRequestException } else { throw new HttpException( 'Scene not found for Tuya', HttpStatus.NOT_FOUND, ); } } } 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) { try { const response = await this.tuyaService.getSceneRule( automation.automationTuyaUuid, ); if (!response.success) { throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); } const responseData: AutomationResponseData = convertKeysToCamelCase( response.result, ); const actions = responseData.actions.map((action) => ({ ...action, })); for (const action of actions) { if (action.actionExecutor === ActionExecutorEnum.DEVICE_ISSUE) { const device = await this.deviceService.getDeviceByDeviceTuyaUuid( action.entityId, ); if (device) { action.entityId = device.uuid; action.productUuid = device.productDevice.uuid; action.productType = device.productDevice.prodType; action.deviceName = device.name; } } else if ( action.actionExecutor !== ActionExecutorEnum.DEVICE_ISSUE && action.actionExecutor !== ActionExecutorEnum.DELAY ) { const sceneDetails = await this.getTapToRunSceneDetailsTuya( action.entityId, ); if (sceneDetails.id) { if (sceneDetails.type === ActionTypeEnum.SCENE) { const scene = await this.sceneRepository.findOne({ where: { sceneTuyaUuid: action.entityId }, relations: ['sceneIcon'], }); action.entityId = scene.uuid; action.iconUuid = scene.sceneIcon.uuid; action.icon = scene.sceneIcon.icon; } else if (sceneDetails.type === ActionTypeEnum.AUTOMATION) { const automation = await this.automationRepository.findOne({ where: { automationTuyaUuid: action.entityId }, }); action.entityId = automation.uuid; } action.name = sceneDetails.name; action.type = sceneDetails.type; } } } const conditions = responseData.conditions.map((condition) => ({ ...condition, })); for (const condition of conditions) { if (condition.entityType === EntityTypeEnum.DEVICE_REPORT) { const device = await this.deviceService.getDeviceByDeviceTuyaUuid( condition.entityId, ); if (device) { condition.entityId = device.uuid; condition.productUuid = device.productDevice.uuid; condition.productType = device.productDevice.prodType; condition.deviceName = device.name; } } } // eslint-disable-next-line @typescript-eslint/no-unused-vars const { timeZoneId, ...effectiveTimeWithoutTimeZoneId } = responseData.effectiveTime || {}; return { uuid: automation.uuid, name: responseData.name, status: responseData.status, type: 'automation', ...(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { spaceId, id, runningMode, ...rest } = responseData; return rest; })(), actions, conditions, effectiveTime: effectiveTimeWithoutTimeZoneId, // Use modified effectiveTime }; } catch (err) { if (err instanceof BadRequestException) { throw err; } else { throw new HttpException( `An error occurred while retrieving automation details for ${automation.uuid}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } } } async findAutomation( sceneUuid: string, projectUuid: string, ): Promise { const automation = await this.automationRepository.findOne({ where: { uuid: sceneUuid, space: { community: { project: { uuid: projectUuid } } }, }, relations: ['space'], }); if (!automation) { throw new HttpException( `Invalid automation with id ${sceneUuid}`, 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) { try { const response = (await this.tuyaService.deleteSceneRule( tuyaAutomationId, tuyaSpaceId, )) as DeleteTapToRunSceneInterface; return response; } catch (error) { if (error instanceof HttpException) { throw error; } else { throw new HttpException( 'Failed to delete automation rule in Tuya', HttpStatus.INTERNAL_SERVER_ERROR, ); } } } async updateAutomationExternalService( spaceTuyaUuid: string, automationUuid: string, updateAutomationDto: UpdateAutomationDto, projectUuid: string, ) { const { automationName, decisionExpr, actions, conditions, effectiveTime } = updateAutomationDto; try { const formattedActions = await this.prepareActions(actions, projectUuid); const formattedCondition = await this.prepareConditions( conditions, projectUuid, ); const response = await this.tuyaService.updateAutomation( automationUuid, spaceTuyaUuid, automationName, formattedActions, formattedCondition, decisionExpr, effectiveTime, ); if (!response.success) { throw new HttpException( 'Failed to update 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 update automation', HttpStatus.BAD_GATEWAY, ); } else { throw new HttpException( `An Internal error has been occured ${err}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } } } 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[], projectUuid: string, ): Promise { const convertedData = convertKeysToSnakeCase(actions) as ConvertedAction[]; await Promise.all( convertedData.map(async (action) => { if (action.action_executor === ActionExecutorEnum.DEVICE_ISSUE) { const device = await this.deviceService.getDeviceByDeviceUuid( action.entity_id, false, projectUuid, ); if (device) { action.entity_id = device.deviceTuyaUuid; } } else if (action.action_executor === ActionExecutorEnum.RULE_TRIGGER) { // Check if action_type is missing if (!action.action_type) { throw new Error(`actionType is required for rule_trigger actions`); } if (action.action_type === ActionTypeEnum.SCENE) { const scene = await this.sceneRepository.findOne({ where: { uuid: action.entity_id }, }); if (scene) { action.entity_id = scene.sceneTuyaUuid; } } } else if ( action.action_executor === ActionExecutorEnum.RULE_DISABLE || action.action_executor === ActionExecutorEnum.RULE_ENABLE ) { if (action.action_type === ActionTypeEnum.AUTOMATION) { const automation = await this.findAutomation( action.entity_id, projectUuid, ); action.entity_id = automation.automationTuyaUuid; } } }), ); return convertedData; } private async prepareConditions( conditions: Condition[], projectUuid: string, ) { const convertedData = convertKeysToSnakeCase(conditions); await Promise.all( convertedData.map(async (condition) => { if (condition.entity_type === EntityTypeEnum.DEVICE_REPORT) { const device = await this.deviceService.getDeviceByDeviceUuid( condition.entity_id, false, projectUuid, ); if (device) { condition.entity_id = device.deviceTuyaUuid; } } }), ); return convertedData; } private async validateProject(uuid: string) { const project = await this.projectRepository.findOne({ where: { uuid }, }); if (!project) { throw new HttpException( `A project with the uuid '${uuid}' doesn't exists.`, HttpStatus.BAD_REQUEST, ); } return project; } }