Compare commits

..

1 Commits

21 changed files with 908 additions and 776 deletions

View File

@ -188,11 +188,6 @@ export class ControllerRoute {
static SCENE = class { static SCENE = class {
public static readonly ROUTE = 'scene'; public static readonly ROUTE = 'scene';
static ACTIONS = class { 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 = public static readonly CREATE_TAP_TO_RUN_SCENE_SUMMARY =
'Create a Tap-to-Run Scene'; 'Create a Tap-to-Run Scene';
public static readonly CREATE_TAP_TO_RUN_SCENE_DESCRIPTION = public static readonly CREATE_TAP_TO_RUN_SCENE_DESCRIPTION =
@ -777,10 +772,6 @@ export class ControllerRoute {
public static readonly ADD_AUTOMATION_DESCRIPTION = public static readonly ADD_AUTOMATION_DESCRIPTION =
'This endpoint creates a new automation based on the provided details.'; '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 = public static readonly GET_AUTOMATION_DETAILS_SUMMARY =
'Get automation details'; 'Get automation details';
public static readonly GET_AUTOMATION_DETAILS_DESCRIPTION = public static readonly GET_AUTOMATION_DETAILS_DESCRIPTION =

View File

@ -0,0 +1,3 @@
export enum TimerJobTypeEnum {
INVITE_USER_EMAIL = 'INVITE_USER_EMAIL',
}

View File

@ -54,6 +54,7 @@ import { SpaceEntity } from '../modules/space/entities/space.entity';
import { SubspaceProductAllocationEntity } from '../modules/space/entities/subspace/subspace-product-allocation.entity'; import { SubspaceProductAllocationEntity } from '../modules/space/entities/subspace/subspace-product-allocation.entity';
import { SubspaceEntity } from '../modules/space/entities/subspace/subspace.entity'; import { SubspaceEntity } from '../modules/space/entities/subspace/subspace.entity';
import { NewTagEntity } from '../modules/tag/entities/tag.entity'; import { NewTagEntity } from '../modules/tag/entities/tag.entity';
import { TimerEntity } from '../modules/timer/entities/timer.entity';
import { TimeZoneEntity } from '../modules/timezone/entities'; import { TimeZoneEntity } from '../modules/timezone/entities';
import { import {
UserNotificationEntity, UserNotificationEntity,
@ -121,6 +122,7 @@ import { VisitorPasswordEntity } from '../modules/visitor-password/entities';
SpaceDailyOccupancyDurationEntity, SpaceDailyOccupancyDurationEntity,
BookableSpaceEntity, BookableSpaceEntity,
BookingEntity, BookingEntity,
TimerEntity,
], ],
namingStrategy: new SnakeNamingStrategy(), namingStrategy: new SnakeNamingStrategy(),
synchronize: Boolean(JSON.parse(configService.get('DB_SYNC'))), synchronize: Boolean(JSON.parse(configService.get('DB_SYNC'))),

View File

@ -15,17 +15,16 @@ import { ProjectEntity } from '../../project/entities';
import { RoleTypeEntity } from '../../role-type/entities'; import { RoleTypeEntity } from '../../role-type/entities';
import { SpaceEntity } from '../../space/entities/space.entity'; import { SpaceEntity } from '../../space/entities/space.entity';
import { UserEntity } from '../../user/entities'; import { UserEntity } from '../../user/entities';
import { InviteUserDto, InviteUserSpaceDto } from '../dtos';
@Entity({ name: 'invite-user' }) @Entity({ name: 'invite-user' })
@Unique(['email', 'project']) @Unique(['email', 'project'])
export class InviteUserEntity extends AbstractEntity<InviteUserDto> { export class InviteUserEntity extends AbstractEntity {
@Column({ @Column({
type: 'uuid', type: 'uuid',
default: () => 'gen_random_uuid()', default: () => 'gen_random_uuid()',
nullable: false, nullable: false,
}) })
public uuid: string; uuid: string;
@Column({ @Column({
nullable: false, nullable: false,
@ -49,50 +48,67 @@ export class InviteUserEntity extends AbstractEntity<InviteUserDto> {
status: string; status: string;
@Column() @Column()
public firstName: string; firstName: string;
@Column({ @Column({
nullable: false, nullable: false,
}) })
public lastName: string; lastName: string;
@Column({ @Column({
nullable: true, nullable: true,
}) })
public phoneNumber: string; phoneNumber: string;
@Column({ @Column({
nullable: false, nullable: false,
default: true, default: true,
}) })
public isActive: boolean; isActive: boolean;
@Column({ @Column({
nullable: false, nullable: false,
default: true, default: true,
}) })
public isEnabled: boolean; isEnabled: boolean;
@Column({ @Column({
nullable: false, nullable: false,
unique: true, unique: true,
}) })
public invitationCode: string; invitationCode: string;
@Column({
default: new Date(),
type: 'date',
})
accessStartDate: Date;
@Column({
type: 'date',
nullable: true,
})
accessEndDate?: Date;
@Column({ @Column({
nullable: false, nullable: false,
enum: Object.values(RoleType), enum: Object.values(RoleType),
}) })
public invitedBy: string; invitedBy: string;
@ManyToOne(() => RoleTypeEntity, (roleType) => roleType.invitedUsers, { @ManyToOne(() => RoleTypeEntity, (roleType) => roleType.invitedUsers, {
nullable: false, nullable: false,
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
public roleType: RoleTypeEntity; roleType: RoleTypeEntity;
@OneToOne(() => UserEntity, (user) => user.inviteUser, { @OneToOne(() => UserEntity, (user) => user.inviteUser, {
nullable: true, nullable: true,
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn({ name: 'user_uuid' }) @JoinColumn({ name: 'user_uuid' })
user: UserEntity; user: UserEntity;
@OneToMany( @OneToMany(
() => InviteUserSpaceEntity, () => InviteUserSpaceEntity,
(inviteUserSpace) => inviteUserSpace.inviteUser, (inviteUserSpace) => inviteUserSpace.inviteUser,
@ -103,32 +119,34 @@ export class InviteUserEntity extends AbstractEntity<InviteUserDto> {
nullable: true, nullable: true,
}) })
@JoinColumn({ name: 'project_uuid' }) @JoinColumn({ name: 'project_uuid' })
public project: ProjectEntity; project: ProjectEntity;
constructor(partial: Partial<InviteUserEntity>) { constructor(partial: Partial<InviteUserEntity>) {
super(); super();
Object.assign(this, partial); Object.assign(this, partial);
} }
} }
@Entity({ name: 'invite-user-space' }) @Entity({ name: 'invite-user-space' })
@Unique(['inviteUser', 'space']) @Unique(['inviteUser', 'space'])
export class InviteUserSpaceEntity extends AbstractEntity<InviteUserSpaceDto> { export class InviteUserSpaceEntity extends AbstractEntity {
@Column({ @Column({
type: 'uuid', type: 'uuid',
default: () => 'gen_random_uuid()', default: () => 'gen_random_uuid()',
nullable: false, nullable: false,
}) })
public uuid: string; uuid: string;
@ManyToOne(() => InviteUserEntity, (inviteUser) => inviteUser.spaces, { @ManyToOne(() => InviteUserEntity, (inviteUser) => inviteUser.spaces, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn({ name: 'invite_user_uuid' }) @JoinColumn({ name: 'invite_user_uuid' })
public inviteUser: InviteUserEntity; inviteUser: InviteUserEntity;
@ManyToOne(() => SpaceEntity, (space) => space.invitedUsers) @ManyToOne(() => SpaceEntity, (space) => space.invitedUsers)
@JoinColumn({ name: 'space_uuid' }) @JoinColumn({ name: 'space_uuid' })
public space: SpaceEntity; space: SpaceEntity;
constructor(partial: Partial<InviteUserSpaceEntity>) { constructor(partial: Partial<InviteUserSpaceEntity>) {
super(); super();
Object.assign(this, partial); Object.assign(this, partial);

View File

@ -0,0 +1,37 @@
import { TimerJobTypeEnum } from '@app/common/constants/timer-job-type.enum';
import { Column, Entity } from 'typeorm';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
@Entity({ name: 'timer' })
export class TimerEntity extends AbstractEntity {
@Column({
type: 'uuid',
default: () => 'gen_random_uuid()',
nullable: false,
})
uuid: string;
@Column({
nullable: false,
enum: Object.values(TimerJobTypeEnum),
type: String,
})
type: TimerJobTypeEnum;
@Column({
nullable: false,
type: 'date',
})
triggerDate: Date;
@Column({
type: 'jsonb',
nullable: true,
})
metadata?: Record<string, any>;
constructor(partial: Partial<TimerEntity>) {
super();
Object.assign(this, partial);
}
}

View File

@ -0,0 +1,10 @@
import { DataSource, Repository } from 'typeorm';
import { Injectable } from '@nestjs/common';
import { TimerEntity } from '../entities/timer.entity';
@Injectable()
export class TimerRepository extends Repository<TimerEntity> {
constructor(private dataSource: DataSource) {
super(TimerEntity, dataSource.createEntityManager());
}
}

View File

@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TimerEntity } from './entities/timer.entity';
import { TimerRepository } from './repositories/timer.repository';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([TimerEntity])],
providers: [TimerRepository],
exports: [TimerRepository],
})
export class TimerRepositoryModule {}

View File

@ -11,6 +11,7 @@ import {
} from 'typeorm'; } from 'typeorm';
import { OtpType } from '../../../../src/constants/otp-type.enum'; import { OtpType } from '../../../../src/constants/otp-type.enum';
import { AbstractEntity } from '../../abstract/entities/abstract.entity'; import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { BookingEntity } from '../../booking/entities/booking.entity';
import { ClientEntity } from '../../client/entities'; import { ClientEntity } from '../../client/entities';
import { import {
DeviceNotificationEntity, DeviceNotificationEntity,
@ -29,7 +30,6 @@ import {
UserOtpDto, UserOtpDto,
UserSpaceDto, UserSpaceDto,
} from '../dtos'; } from '../dtos';
import { BookingEntity } from '../../booking/entities/booking.entity';
@Entity({ name: 'user' }) @Entity({ name: 'user' })
export class UserEntity extends AbstractEntity<UserDto> { export class UserEntity extends AbstractEntity<UserDto> {
@ -101,6 +101,9 @@ export class UserEntity extends AbstractEntity<UserDto> {
@Column({ type: 'timestamp', nullable: true }) @Column({ type: 'timestamp', nullable: true })
appAgreementAcceptedAt: Date; appAgreementAcceptedAt: Date;
@Column({ type: Boolean, default: false })
bookingEnabled: boolean;
@OneToMany(() => UserSpaceEntity, (userSpace) => userSpace.user, { @OneToMany(() => UserSpaceEntity, (userSpace) => userSpace.user, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })

View File

@ -35,16 +35,18 @@ import { UserNotificationModule } from './user-notification/user-notification.mo
import { UserModule } from './users/user.module'; import { UserModule } from './users/user.module';
import { VisitorPasswordModule } from './vistor-password/visitor-password.module'; import { VisitorPasswordModule } from './vistor-password/visitor-password.module';
import { TimerRepositoryModule } from '@app/common/modules/timer/timer.repository.module';
import { ScheduleModule as NestScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard } from '@nestjs/throttler'; import { ThrottlerGuard } from '@nestjs/throttler';
import { ThrottlerModule } from '@nestjs/throttler/dist/throttler.module'; import { ThrottlerModule } from '@nestjs/throttler/dist/throttler.module';
import { isArray } from 'class-validator'; import { isArray } from 'class-validator';
import { winstonLoggerOptions } from '../libs/common/src/logger/services/winston.logger'; import { winstonLoggerOptions } from '../libs/common/src/logger/services/winston.logger';
import { AqiModule } from './aqi/aqi.module'; import { AqiModule } from './aqi/aqi.module';
import { OccupancyModule } from './occupancy/occupancy.module';
import { WeatherModule } from './weather/weather.module';
import { ScheduleModule as NestScheduleModule } from '@nestjs/schedule';
import { SchedulerModule } from './scheduler/scheduler.module';
import { BookingModule } from './booking'; import { BookingModule } from './booking';
import { OccupancyModule } from './occupancy/occupancy.module';
import { SchedulerModule } from './scheduler/scheduler.module';
import { TimerModule } from './timer/timer.module';
import { WeatherModule } from './weather/weather.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
@ -63,6 +65,8 @@ import { BookingModule } from './booking';
}, },
}), }),
WinstonModule.forRoot(winstonLoggerOptions), WinstonModule.forRoot(winstonLoggerOptions),
TimerModule,
TimerRepositoryModule,
ClientModule, ClientModule,
AuthenticationModule, AuthenticationModule,
UserModule, UserModule,

View File

@ -1,7 +1,4 @@
import { ControllerRoute } from '@app/common/constants/controller-route'; import { AutomationService } from '../services/automation.service';
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 { import {
Body, Body,
Controller, Controller,
@ -12,20 +9,20 @@ import {
Patch, Patch,
Post, Post,
Put, Put,
Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { Permissions } from 'src/decorators/permissions.decorator';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { AutomationParamDto } from '../dtos';
import { import {
AddAutomationDto, AddAutomationDto,
UpdateAutomationDto, UpdateAutomationDto,
UpdateAutomationStatusDto, UpdateAutomationStatusDto,
} from '../dtos/automation.dto'; } from '../dtos/automation.dto';
import { GetAutomationBySpacesDto } from '../dtos/get-automation-by-spaces.dto'; import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { AutomationService } from '../services/automation.service'; 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';
@ApiTags('Automation Module') @ApiTags('Automation Module')
@Controller({ @Controller({
@ -59,28 +56,6 @@ 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() @ApiBearerAuth()
@UseGuards(PermissionsGuard) @UseGuards(PermissionsGuard)
@Permissions('AUTOMATION_VIEW') @Permissions('AUTOMATION_VIEW')

View File

@ -1,20 +0,0 @@
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,42 +1,20 @@
import { import {
ActionExecutorEnum, Injectable,
ActionTypeEnum,
AUTO_PREFIX,
AUTOMATION_TYPE,
EntityTypeEnum,
} from '@app/common/constants/automation.enum';
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 { SpaceRepository } from '@app/common/modules/space/repositories';
import {
BadRequestException,
HttpException, HttpException,
HttpStatus, HttpStatus,
Injectable, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { SpaceRepository } from '@app/common/modules/space/repositories';
import { TuyaContext } from '@tuya/tuya-connector-nodejs';
import { DeviceService } from 'src/device/services';
import { DeleteTapToRunSceneInterface } from 'src/scene/interface/scene.interface';
import { In } from 'typeorm';
import { import {
AddAutomationDto, AddAutomationDto,
AutomationParamDto, AutomationParamDto,
UpdateAutomationDto, UpdateAutomationDto,
UpdateAutomationStatusDto, UpdateAutomationStatusDto,
} from '../dtos'; } from '../dtos';
import { GetAutomationBySpacesDto } from '../dtos/get-automation-by-spaces.dto'; 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 { import {
Action, Action,
AddAutomationParams, AddAutomationParams,
@ -44,6 +22,26 @@ import {
AutomationResponseData, AutomationResponseData,
Condition, Condition,
} from '../interface/automation.interface'; } 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() @Injectable()
export class AutomationService { export class AutomationService {
@ -114,25 +112,128 @@ export class AutomationService {
); );
} }
} }
async getAutomationBySpace({ projectUuid, spaceUuid }: GetSpaceParam) { async createAutomationExternalService(
return this.getAutomationBySpaces({ spaces: [spaceUuid] }, projectUuid); params: AddAutomationParams,
}
async getAutomationBySpaces(
{ spaces }: GetAutomationBySpacesDto,
projectUuid: string, projectUuid: string,
) { ) {
try { try {
await this.validateProject(projectUuid); 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 // Fetch automation data from the repository
const automationData = await this.automationRepository.find({ const automationData = await this.automationRepository.find({
where: { where: {
space: { space: {
uuid: In(spaces ?? []), uuid: param.spaceUuid,
community: { community: {
uuid: param.communityUuid,
project: { project: {
uuid: projectUuid, uuid: param.projectUuid,
}, },
}, },
}, },
@ -189,277 +290,46 @@ export class AutomationService {
} }
} }
async getAutomationDetails(param: AutomationParamDto) { async findAutomationBySpace(spaceUuid: string, projectUuid: string) {
await this.validateProject(param.projectUuid);
try { try {
const automation = await this.findAutomationByUuid( await this.getSpaceByUuid(spaceUuid, projectUuid);
param.automationUuid,
param.projectUuid,
);
const automationDetails = await this.getAutomation(automation); const automationData = await this.automationRepository.find({
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 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: { where: {
uuid: spaceUuid, space: { uuid: spaceUuid },
community: { disabled: false,
project: {
uuid: projectUuid,
},
},
}, },
relations: ['community'], relations: ['space'],
}); });
if (!space) {
throw new BadRequestException(`Invalid space UUID ${spaceUuid}`); const automations = await Promise.all(
} automationData.map(async (automation) => {
return { // eslint-disable-next-line @typescript-eslint/no-unused-vars
uuid: space.uuid, const { actions, ...automationDetails } =
createdAt: space.createdAt, await this.getAutomation(automation);
updatedAt: space.updatedAt,
name: space.spaceName, return automationDetails;
spaceTuyaUuid: space.community.externalId, }),
}; );
return automations;
} catch (err) { } catch (err) {
if (err instanceof BadRequestException) { console.error(
throw err; // Re-throw BadRequestException `Error fetching Tap-to-Run scenes for space UUID ${spaceUuid}:`,
err.message,
);
if (err instanceof HttpException) {
throw err;
} else { } else {
throw new HttpException('Space not found', HttpStatus.NOT_FOUND); throw new HttpException(
'An error occurred while retrieving scenes',
HttpStatus.INTERNAL_SERVER_ERROR,
);
} }
} }
} }
async getTapToRunSceneDetailsTuya(
private async getTapToRunSceneDetailsTuya(
sceneUuid: string, sceneUuid: string,
): Promise<AutomationDetailsResult> { ): Promise<AutomationDetailsResult> {
try { try {
@ -491,8 +361,35 @@ export class AutomationService {
} }
} }
} }
async getAutomationDetails(param: AutomationParamDto) {
await this.validateProject(param.projectUuid);
private async getAutomation(automation: AutomationEntity) { 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 { try {
const response = await this.tuyaService.getSceneRule( const response = await this.tuyaService.getSceneRule(
automation.automationTuyaUuid, automation.automationTuyaUuid,
@ -599,13 +496,13 @@ export class AutomationService {
} }
} }
} }
private async findAutomationByUuid( async findAutomation(
uuid: string, sceneUuid: string,
projectUuid: string, projectUuid: string,
): Promise<AutomationEntity> { ): Promise<AutomationEntity> {
const automation = await this.automationRepository.findOne({ const automation = await this.automationRepository.findOne({
where: { where: {
uuid: uuid, uuid: sceneUuid,
space: { community: { project: { uuid: projectUuid } } }, space: { community: { project: { uuid: projectUuid } } },
}, },
relations: ['space'], relations: ['space'],
@ -613,14 +510,57 @@ export class AutomationService {
if (!automation) { if (!automation) {
throw new HttpException( throw new HttpException(
`Invalid automation with id ${uuid}`, `Invalid automation with id ${sceneUuid}`,
HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND,
); );
} }
return automation; return automation;
} }
private async delete(tuyaAutomationId: string, tuyaSpaceId: string) { 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 { try {
const response = (await this.tuyaService.deleteSceneRule( const response = (await this.tuyaService.deleteSceneRule(
tuyaAutomationId, tuyaAutomationId,
@ -638,7 +578,7 @@ export class AutomationService {
} }
} }
} }
private async updateAutomationExternalService( async updateAutomationExternalService(
spaceTuyaUuid: string, spaceTuyaUuid: string,
automationUuid: string, automationUuid: string,
updateAutomationDto: UpdateAutomationDto, updateAutomationDto: UpdateAutomationDto,
@ -686,6 +626,95 @@ 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( private async prepareActions(
actions: Action[], actions: Action[],
@ -724,7 +753,7 @@ export class AutomationService {
action.action_executor === ActionExecutorEnum.RULE_ENABLE action.action_executor === ActionExecutorEnum.RULE_ENABLE
) { ) {
if (action.action_type === ActionTypeEnum.AUTOMATION) { if (action.action_type === ActionTypeEnum.AUTOMATION) {
const automation = await this.findAutomationByUuid( const automation = await this.findAutomation(
action.entity_id, action.entity_id,
projectUuid, projectUuid,
); );

View File

@ -2,10 +2,12 @@ import { ApiProperty } from '@nestjs/swagger';
import { import {
ArrayMinSize, ArrayMinSize,
IsArray, IsArray,
IsDate,
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
IsUUID, IsUUID,
MinDate,
} from 'class-validator'; } from 'class-validator';
export class AddUserInvitationDto { export class AddUserInvitationDto {
@ -16,7 +18,7 @@ export class AddUserInvitationDto {
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
public firstName: string; firstName: string;
@ApiProperty({ @ApiProperty({
description: 'The last name of the user', description: 'The last name of the user',
@ -25,7 +27,7 @@ export class AddUserInvitationDto {
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
public lastName: string; lastName: string;
@ApiProperty({ @ApiProperty({
description: 'The email of the user', description: 'The email of the user',
@ -34,7 +36,7 @@ export class AddUserInvitationDto {
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
public email: string; email: string;
@ApiProperty({ @ApiProperty({
description: 'The job title of the user', description: 'The job title of the user',
@ -43,7 +45,7 @@ export class AddUserInvitationDto {
}) })
@IsString() @IsString()
@IsOptional() @IsOptional()
public jobTitle?: string; jobTitle?: string;
@ApiProperty({ @ApiProperty({
description: 'The company name of the user', description: 'The company name of the user',
@ -52,7 +54,27 @@ export class AddUserInvitationDto {
}) })
@IsString() @IsString()
@IsOptional() @IsOptional()
public companyName?: string; companyName?: string;
@ApiProperty({
description: 'Access start date',
example: new Date(),
required: false,
})
@IsDate()
@MinDate(new Date())
@IsOptional()
accessStartDate?: Date;
@ApiProperty({
description: 'Access start date',
example: new Date(),
required: false,
})
@IsDate()
@MinDate(new Date())
@IsOptional()
accessEndDate?: Date;
@ApiProperty({ @ApiProperty({
description: 'The phone number of the user', description: 'The phone number of the user',
@ -61,7 +83,7 @@ export class AddUserInvitationDto {
}) })
@IsString() @IsString()
@IsOptional() @IsOptional()
public phoneNumber?: string; phoneNumber?: string;
@ApiProperty({ @ApiProperty({
description: 'The role uuid of the user', description: 'The role uuid of the user',
@ -70,7 +92,7 @@ export class AddUserInvitationDto {
}) })
@IsUUID('4') @IsUUID('4')
@IsNotEmpty() @IsNotEmpty()
public roleUuid: string; roleUuid: string;
@ApiProperty({ @ApiProperty({
description: 'The project uuid of the user', description: 'The project uuid of the user',
example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', example: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
@ -78,7 +100,7 @@ export class AddUserInvitationDto {
}) })
@IsUUID('4') @IsUUID('4')
@IsNotEmpty() @IsNotEmpty()
public projectUuid: string; projectUuid: string;
@ApiProperty({ @ApiProperty({
description: 'The array of space UUIDs (at least one required)', description: 'The array of space UUIDs (at least one required)',
@ -88,7 +110,7 @@ export class AddUserInvitationDto {
@IsArray() @IsArray()
@IsUUID('4', { each: true }) @IsUUID('4', { each: true })
@ArrayMinSize(1) @ArrayMinSize(1)
public spaceUuids: string[]; spaceUuids: string[];
constructor(dto: Partial<AddUserInvitationDto>) { constructor(dto: Partial<AddUserInvitationDto>) {
Object.assign(this, dto); Object.assign(this, dto);
} }

View File

@ -1,4 +1,5 @@
import { RoleType } from '@app/common/constants/role.type.enum'; import { RoleType } from '@app/common/constants/role.type.enum';
import { TimerJobTypeEnum } from '@app/common/constants/timer-job-type.enum';
import { UserStatusEnum } from '@app/common/constants/user-status.enum'; import { UserStatusEnum } from '@app/common/constants/user-status.enum';
import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
@ -23,6 +24,7 @@ import {
Injectable, Injectable,
} from '@nestjs/common'; } from '@nestjs/common';
import { SpaceUserService } from 'src/space/services'; import { SpaceUserService } from 'src/space/services';
import { TimerService } from 'src/timer/timer.service';
import { UserSpaceService } from 'src/users/services'; import { UserSpaceService } from 'src/users/services';
import { import {
DataSource, DataSource,
@ -52,6 +54,7 @@ export class InviteUserService {
private readonly spaceRepository: SpaceRepository, private readonly spaceRepository: SpaceRepository,
private readonly roleTypeRepository: RoleTypeRepository, private readonly roleTypeRepository: RoleTypeRepository,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly timerService: TimerService,
) {} ) {}
async createUserInvitation( async createUserInvitation(
@ -68,8 +71,14 @@ export class InviteUserService {
roleUuid, roleUuid,
spaceUuids, spaceUuids,
projectUuid, projectUuid,
accessStartDate,
accessEndDate,
} = dto; } = dto;
if (accessStartDate && accessEndDate && accessEndDate <= accessStartDate) {
throw new BadRequestException(
'accessEndDate must be after accessStartDate',
);
}
const invitationCode = generateRandomString(6); const invitationCode = generateRandomString(6);
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
@ -114,6 +123,8 @@ export class InviteUserService {
invitationCode, invitationCode,
invitedBy: roleType, invitedBy: roleType,
project: { uuid: projectUuid }, project: { uuid: projectUuid },
accessEndDate,
accessStartDate,
}); });
const invitedUser = await queryRunner.manager.save(inviteUser); const invitedUser = await queryRunner.manager.save(inviteUser);
@ -132,12 +143,26 @@ export class InviteUserService {
// Send invitation email // Send invitation email
const spaceNames = validSpaces.map((space) => space.spaceName).join(', '); const spaceNames = validSpaces.map((space) => space.spaceName).join(', ');
await this.emailService.sendEmailWithInvitationTemplate(email, { if (accessStartDate) {
name: firstName, await this.timerService.createJob({
invitationCode, type: TimerJobTypeEnum.INVITE_USER_EMAIL,
role: invitedRoleType.replace(/_/g, ' '), triggerDate: accessStartDate,
spacesList: spaceNames, metadata: {
}); email,
name: firstName,
invitationCode,
role: invitedRoleType.replace(/_/g, ' '),
spacesList: spaceNames,
},
});
} else {
await this.emailService.sendEmailWithInvitationTemplate(email, {
name: firstName,
invitationCode,
role: invitedRoleType.replace(/_/g, ' '),
spacesList: spaceNames,
});
}
await queryRunner.commitTransaction(); await queryRunner.commitTransaction();

View File

@ -1,7 +1,4 @@
import { ControllerRoute } from '@app/common/constants/controller-route'; import { SceneService } from '../services/scene.service';
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 { import {
Body, Body,
Controller, Controller,
@ -11,21 +8,21 @@ import {
Param, Param,
Post, Post,
Put, Put,
Query,
Req, Req,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { Permissions } from 'src/decorators/permissions.decorator';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { SceneParamDto } from '../dtos';
import { import {
AddSceneIconDto, AddSceneIconDto,
AddSceneTapToRunDto, AddSceneTapToRunDto,
GetSceneDto,
UpdateSceneTapToRunDto, UpdateSceneTapToRunDto,
} from '../dtos/scene.dto'; } from '../dtos/scene.dto';
import { SceneService } from '../services/scene.service'; 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';
@ApiTags('Scene Module') @ApiTags('Scene Module')
@Controller({ @Controller({
@ -55,27 +52,6 @@ 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() @ApiBearerAuth()
@UseGuards(PermissionsGuard) @UseGuards(PermissionsGuard)
@Permissions('SCENES_DELETE') @Permissions('SCENES_DELETE')

View File

@ -1,16 +1,15 @@
import { BooleanValues } from '@app/common/constants/boolean-values.enum';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { import {
IsArray,
IsBoolean,
IsNotEmpty, IsNotEmpty,
IsNumber,
IsOptional,
IsString, IsString,
IsUUID, IsArray,
ValidateNested, ValidateNested,
IsOptional,
IsNumber,
IsBoolean,
} from 'class-validator'; } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { BooleanValues } from '@app/common/constants/boolean-values.enum';
class ExecutorProperty { class ExecutorProperty {
@ApiProperty({ @ApiProperty({
@ -188,19 +187,4 @@ export class GetSceneDto {
return value.obj.showInHomePage === BooleanValues.TRUE; return value.obj.showInHomePage === BooleanValues.TRUE;
}) })
public showInHomePage: boolean = false; 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,36 +1,12 @@
import { import {
ActionExecutorEnum, Injectable,
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, HttpException,
HttpStatus, HttpStatus,
BadRequestException,
forwardRef,
Inject, Inject,
Injectable,
} from '@nestjs/common'; } from '@nestjs/common';
import { HttpStatusCode } from 'axios'; import { SpaceRepository } from '@app/common/modules/space/repositories';
import { DeviceService } from 'src/device/services';
import { In } from 'typeorm';
import { import {
Action, Action,
AddSceneIconDto, AddSceneIconDto,
@ -39,12 +15,35 @@ import {
SceneParamDto, SceneParamDto,
UpdateSceneTapToRunDto, UpdateSceneTapToRunDto,
} from '../dtos'; } from '../dtos';
import { convertKeysToSnakeCase } from '@app/common/helper/snakeCaseConverter';
import { import {
AddTapToRunSceneInterface, AddTapToRunSceneInterface,
DeleteTapToRunSceneInterface, DeleteTapToRunSceneInterface,
SceneDetails, SceneDetails,
SceneDetailsResult, SceneDetailsResult,
} from '../interface/scene.interface'; } 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() @Injectable()
export class SceneService { export class SceneService {
@ -93,48 +92,158 @@ export class SceneService {
} }
} }
async findScenesBySpace(spaceUuid: string, { showInHomePage }: GetSceneDto) { async create(
spaceTuyaUuid: string,
addSceneTapToRunDto: AddSceneTapToRunDto,
projectUuid: string,
): Promise<SceneEntity> {
const { iconUuid, showInHomePage, spaceUuid } = addSceneTapToRunDto;
try { try {
await this.getSpaceByUuid(spaceUuid); const [defaultSceneIcon] = await Promise.all([
return this.findScenesBySpaces({ showInHomePage, spaces: [spaceUuid] }); this.getDefaultSceneIcon(),
} catch (error) { ]);
console.error( if (!defaultSceneIcon) {
`Error fetching Tap-to-Run scenes for space UUID ${spaceUuid}:`, throw new HttpException(
error.message, 'Default scene icon not found',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
const response = await this.createSceneExternalService(
spaceTuyaUuid,
addSceneTapToRunDto,
projectUuid,
); );
throw error instanceof HttpException const scene = await this.sceneRepository.save({
? error sceneTuyaUuid: response.result.id,
: new HttpException( sceneIcon: { uuid: iconUuid || defaultSceneIcon.uuid },
'An error occurred while retrieving scenes', showInHomePage,
HttpStatus.INTERNAL_SERVER_ERROR, 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 findScenesBySpaces( async createSceneExternalService(
{ showInHomePage, spaces }: GetSceneDto, spaceTuyaUuid: string,
projectUuid?: string, addSceneTapToRunDto: AddSceneTapToRunDto,
projectUuid: string,
) { ) {
const { sceneName, decisionExpr, actions } = addSceneTapToRunDto;
try { 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) {
try {
await this.getSpaceByUuid(spaceUuid);
const showInHomePage = filter?.showInHomePage;
const scenesData = await this.sceneRepository.find({ const scenesData = await this.sceneRepository.find({
where: { where: {
space: { space: { uuid: spaceUuid },
uuid: In(spaces ?? []),
community: projectUuid ? { project: { uuid: projectUuid } } : null,
},
disabled: false, disabled: false,
...(showInHomePage ? { showInHomePage } : {}), ...(showInHomePage ? { showInHomePage } : {}),
}, },
relations: ['sceneIcon', 'space', 'space.community'], relations: ['sceneIcon', 'space', 'space.community'],
}); });
const safeFetch = async (scene: SceneEntity) => { const safeFetch = async (scene: any) => {
try { try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { actions, ...sceneDetails } = await this.getScene( const { actions, ...sceneDetails } = await this.getScene(
scene, scene,
scene.space.uuid, spaceUuid,
); );
return sceneDetails; return sceneDetails;
} catch (error) { } catch (error) {
@ -150,7 +259,7 @@ export class SceneService {
return scenes.filter(Boolean); // Remove null values return scenes.filter(Boolean); // Remove null values
} catch (error) { } catch (error) {
console.error( console.error(
`Error fetching Tap-to-Run scenes for specified spaces:`, `Error fetching Tap-to-Run scenes for space UUID ${spaceUuid}:`,
error.message, error.message,
); );
@ -182,6 +291,45 @@ 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( async updateTapToRunScene(
updateSceneTapToRunDto: UpdateSceneTapToRunDto, updateSceneTapToRunDto: UpdateSceneTapToRunDto,
sceneUuid: string, sceneUuid: string,
@ -238,38 +386,6 @@ 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) { async addSceneIcon(addSceneIconDto: AddSceneIconDto) {
try { try {
const icon = await this.sceneIconRepository.save({ const icon = await this.sceneIconRepository.save({
@ -338,237 +454,7 @@ export class SceneService {
} }
} }
async findScene(sceneUuid: string): Promise<SceneEntity> { async getScene(scene: SceneEntity, spaceUuid: string): Promise<SceneDetails> {
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 { try {
const { actions, name, status } = await this.fetchSceneDetailsFromTuya( const { actions, name, status } = await this.fetchSceneDetailsFromTuya(
scene.sceneTuyaUuid, scene.sceneTuyaUuid,
@ -633,7 +519,54 @@ export class SceneService {
} }
} }
private async delete(tuyaSceneId: string, tuyaSpaceId: string) { 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) {
try { try {
const response = (await this.tuyaService.deleteSceneRule( const response = (await this.tuyaService.deleteSceneRule(
tuyaSceneId, tuyaSceneId,
@ -693,4 +626,45 @@ export class SceneService {
}); });
return defaultIcon; 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,
);
}
}
}
} }

View File

@ -0,0 +1,20 @@
import { TimerJobTypeEnum } from '@app/common/constants/timer-job-type.enum';
export class BaseCreateJobDto {
type: TimerJobTypeEnum;
triggerDate: Date;
metadata: Record<string, any>;
}
// validate based on jobType
export class CreateUserInvitationJobDto extends BaseCreateJobDto {
type: TimerJobTypeEnum.INVITE_USER_EMAIL;
metadata: {
email: string;
name: string;
invitationCode: string;
role: string;
spacesList: string;
};
}
export type CreateJobDto = CreateUserInvitationJobDto;

10
src/timer/timer.module.ts Normal file
View File

@ -0,0 +1,10 @@
import { EmailService } from '@app/common/util/email/email.service';
import { Global, Module } from '@nestjs/common';
import { TimerService } from './timer.service';
@Global()
@Module({
providers: [TimerService, EmailService],
exports: [TimerService],
})
export class TimerModule {}

View File

@ -0,0 +1,53 @@
import { TimerJobTypeEnum } from '@app/common/constants/timer-job-type.enum';
import { TimerEntity } from '@app/common/modules/timer/entities/timer.entity';
import { TimerRepository } from '@app/common/modules/timer/repositories/timer.repository';
import { EmailService } from '@app/common/util/email/email.service';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { In, LessThanOrEqual } from 'typeorm';
import { CreateJobDto } from './create-job.dto';
@Injectable()
export class TimerService {
constructor(
private readonly timerRepository: TimerRepository,
private readonly emailService: EmailService,
) {}
createJob(job: CreateJobDto): Promise<TimerEntity> {
const timerEntity = this.timerRepository.create(job);
return this.timerRepository.save(timerEntity);
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async handleCron() {
const jobsToRun = await this.timerRepository.find({
where: { triggerDate: LessThanOrEqual(new Date()) },
});
const successfulJobs = [];
for (const job of jobsToRun) {
try {
await this.handleJob(job);
successfulJobs.push(job.uuid);
} catch (error) {
console.error(`Job ${job.uuid} failed:`, error);
}
}
await this.timerRepository.delete({ uuid: In(successfulJobs) });
}
handleJob(job: TimerEntity) {
switch (job.type) {
case TimerJobTypeEnum.INVITE_USER_EMAIL:
return this.emailService.sendEmailWithInvitationTemplate(
job.metadata.email,
job.metadata,
);
break;
// Handle other job types as needed
default:
console.warn(`Unhandled job type: ${job.type}`);
}
}
}

View File

@ -30,7 +30,7 @@ export class UserService {
where: { where: {
uuid: userUuid, uuid: userUuid,
}, },
relations: ['region', 'timezone', 'roleType', 'project'], relations: ['region', 'timezone', 'roleType', 'project', 'inviteUser'],
}); });
if (!user) { if (!user) {
throw new BadRequestException('Invalid room UUID'); throw new BadRequestException('Invalid room UUID');
@ -53,6 +53,10 @@ export class UserService {
appAgreementAcceptedAt: user?.appAgreementAcceptedAt, appAgreementAcceptedAt: user?.appAgreementAcceptedAt,
role: user?.roleType, role: user?.roleType,
project: user?.project, project: user?.project,
bookingPoints: user?.bookingPoints ?? 0,
accessStartDate: user?.inviteUser.accessStartDate,
accessEndDate: user?.inviteUser.accessEndDate,
bookingEnabled: user?.bookingEnabled,
}; };
} catch (err) { } catch (err) {
if (err instanceof BadRequestException) { if (err instanceof BadRequestException) {