Compare commits

...

2 Commits

Author SHA1 Message Date
331c8dffdc Merge pull request #482 from SyncrowIOT/task/add-spaces-and-communities-filters-to-automations-and-scenes
SP-1883, SP-1884: Task/add spaces and communities filters to automations and scenes
2025-07-25 08:26:59 +03:00
d065742d87 task: add space filter to scenes & automations 2025-07-22 15:38:09 +03:00
7 changed files with 751 additions and 660 deletions

View File

@ -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 =

View File

@ -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')

View File

@ -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[];
}

View File

@ -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 automation = await this.findAutomationByUuid(
param.automationUuid,
param.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);
const automationDetails = await this.getAutomation(automation);
return automationDetails;
}),
);
return automations;
} catch (err) {
} 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<AutomationDetailsResult> {
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<AutomationEntity> {
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,
);

View File

@ -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<BaseResponseDto> {
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')

View File

@ -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[];
}

View File

@ -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<SceneEntity> {
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<SceneDetailsResult> {
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<BaseResponseDto> {
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<SceneDetails> {
async findScene(sceneUuid: string): Promise<SceneEntity> {
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<SceneEntity> {
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<SceneDetailsResult> {
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<SceneDetails> {
try {
const { actions, name, status } = await this.fetchSceneDetailsFromTuya(
scene.sceneTuyaUuid,
@ -519,54 +633,7 @@ export class SceneService {
}
}
async deleteScene(params: SceneParamDto): Promise<BaseResponseDto> {
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<SceneEntity> {
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,
);
}
}
}
}