Merge pull request #139 from SyncrowIOT/feature/space-management

Feature/space management
This commit is contained in:
faris Aljohari
2024-11-05 03:15:31 -06:00
committed by GitHub
24 changed files with 595 additions and 208 deletions

View File

@ -0,0 +1,9 @@
import { TuyaResponseInterface } from './tuya.response.interface';
export interface AddTuyaResponseInterface extends TuyaResponseInterface {
result: {
id: string;
};
t?: number;
tid?: string;
}

View File

@ -1,2 +1,3 @@
export * from './tuya.response.interface'; export * from './tuya.response.interface';
export * from './tap-to-run-action.interface'; export * from './tap-to-run-action.interface';
export * from './automation.interface';

View File

@ -1,5 +1,5 @@
export interface TuyaResponseInterface { export interface TuyaResponseInterface {
success: boolean; success: boolean;
msg?: string; msg?: string;
result: boolean; result: boolean | { id: string };
} }

View File

@ -1,7 +1,11 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { TuyaContext } from '@tuya/tuya-connector-nodejs'; import { TuyaContext } from '@tuya/tuya-connector-nodejs';
import { ConvertedAction, TuyaResponseInterface } from '../interfaces'; import {
AddTuyaResponseInterface,
ConvertedAction,
TuyaResponseInterface,
} from '../interfaces';
@Injectable() @Injectable()
export class TuyaService { export class TuyaService {
@ -144,8 +148,7 @@ export class TuyaService {
) { ) {
const path = `/v2.0/cloud/scene/rule`; const path = `/v2.0/cloud/scene/rule`;
try { const response: AddTuyaResponseInterface = await this.tuya.request({
const response = await this.tuya.request({
method: 'POST', method: 'POST',
path, path,
body: { body: {
@ -166,10 +169,51 @@ export class TuyaService {
throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); throw new HttpException(response.msg, HttpStatus.BAD_REQUEST);
} }
return response.result; return response;
}
async deleteAutomation(spaceId: string, automationUuid: string) {
const path = `/v2.0/cloud/scene/rule?ids=${automationUuid}&space_id=${spaceId}`;
const response = await this.tuya.request({
method: 'DELETE',
path,
});
if (!response.success) {
throw new HttpException(
'Failed to delete automation',
HttpStatus.NOT_FOUND,
);
}
return response;
}
async updateAutomationState(
spaceId: string,
automationUuid: string,
isEnable: boolean,
) {
const path = `/v2.0/cloud/scene/rule/state?space_id=${spaceId}`;
try {
const response: TuyaResponseInterface = await this.tuya.request({
method: 'PUT',
path,
body: {
ids: automationUuid,
is_enable: isEnable,
},
});
if (!response.success) {
throw new HttpException('Automation not found', HttpStatus.NOT_FOUND);
}
return response;
} catch (error) { } catch (error) {
throw new HttpException( throw new HttpException(
error.message || 'Failed to create automation in Tuya', error.message || 'Failed to update automation state',
error.status || HttpStatus.INTERNAL_SERVER_ERROR, error.status || HttpStatus.INTERNAL_SERVER_ERROR,
); );
} }

View File

@ -18,11 +18,7 @@ import {
} from '../dtos/automation.dto'; } from '../dtos/automation.dto';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { import { AutomationParamDto, SpaceParamDto } from '../dtos';
AutomationParamDto,
DeleteAutomationParamDto,
SpaceParamDto,
} from '../dtos';
@ApiTags('Automation Module') @ApiTags('Automation Module')
@Controller({ @Controller({
@ -45,6 +41,7 @@ export class AutomationController {
data: automation, data: automation,
}; };
} }
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get(':spaceUuid') @Get(':spaceUuid')
@ -67,17 +64,18 @@ export class AutomationController {
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Delete(':unitUuid/:automationId') @Delete(':automationUuid')
async deleteAutomation(@Param() param: DeleteAutomationParamDto) { async deleteAutomation(@Param() param: AutomationParamDto) {
await this.automationService.deleteAutomation(param); await this.automationService.deleteAutomation(param);
return { return {
statusCode: HttpStatus.OK, statusCode: HttpStatus.OK,
message: 'Automation Deleted Successfully', message: 'Automation Deleted Successfully',
}; };
} }
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Put(':automationId') @Put(':automationUuid')
async updateAutomation( async updateAutomation(
@Body() updateAutomationDto: UpdateAutomationDto, @Body() updateAutomationDto: UpdateAutomationDto,
@Param() param: AutomationParamDto, @Param() param: AutomationParamDto,
@ -96,7 +94,7 @@ export class AutomationController {
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Put('status/:automationId') @Put('status/:automationUuid')
async updateAutomationStatus( async updateAutomationStatus(
@Body() updateAutomationStatusDto: UpdateAutomationStatusDto, @Body() updateAutomationStatusDto: UpdateAutomationStatusDto,
@Param() param: AutomationParamDto, @Param() param: AutomationParamDto,

View File

@ -10,7 +10,7 @@ import {
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
class EffectiveTime { export class EffectiveTime {
@ApiProperty({ description: 'Start time', required: true }) @ApiProperty({ description: 'Start time', required: true })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()

View File

@ -1,11 +1,12 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator'; import { IsNotEmpty, IsString } from 'class-validator';
export class AutomationParamDto { export class AutomationParamDto {
@ApiProperty({ @ApiProperty({
description: 'UUID of the automation', description: 'TuyaId of the automation',
example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', example: 'SfFi2Tbn09btes84',
}) })
@IsUUID() @IsString()
@IsNotEmpty()
automationUuid: string; automationUuid: string;
} }

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator'; import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
export class DeleteAutomationParamDto { export class DeleteAutomationParamDto {
@ApiProperty({ @ApiProperty({
@ -10,9 +10,10 @@ export class DeleteAutomationParamDto {
spaceUuid: string; spaceUuid: string;
@ApiProperty({ @ApiProperty({
description: 'UUID of the Automation', description: 'TuyaId of the automation',
example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', example: 'SfFi2Tbn09btes84',
}) })
@IsUUID() @IsString()
@IsNotEmpty()
automationUuid: string; automationUuid: string;
} }

View File

@ -1,3 +1,5 @@
import { EffectiveTime } from '../dtos';
export interface AddAutomationInterface { export interface AddAutomationInterface {
success: boolean; success: boolean;
msg?: string; msg?: string;
@ -48,3 +50,12 @@ export interface AutomationDetailsResult {
name: string; name: string;
type: string; type: string;
} }
export interface AddAutomationParams {
actions: Action[];
conditions: Condition[];
automationName: string;
effectiveTime: EffectiveTime;
decisionExpr: string;
spaceTuyaId: string;
}

View File

@ -7,7 +7,7 @@ import {
import { SpaceRepository } from '@app/common/modules/space/repositories'; import { SpaceRepository } from '@app/common/modules/space/repositories';
import { import {
AddAutomationDto, AddAutomationDto,
DeleteAutomationParamDto, AutomationParamDto,
UpdateAutomationDto, UpdateAutomationDto,
UpdateAutomationStatusDto, UpdateAutomationStatusDto,
} from '../dtos'; } from '../dtos';
@ -17,11 +17,10 @@ import { convertKeysToSnakeCase } from '@app/common/helper/snakeCaseConverter';
import { DeviceService } from 'src/device/services'; import { DeviceService } from 'src/device/services';
import { import {
Action, Action,
AddAutomationInterface, AddAutomationParams,
AutomationDetailsResult, AutomationDetailsResult,
AutomationResponseData, AutomationResponseData,
Condition, Condition,
DeleteAutomationInterface,
GetAutomationBySpaceInterface, GetAutomationBySpaceInterface,
} from '../interface/automation.interface'; } from '../interface/automation.interface';
import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter'; import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter';
@ -30,6 +29,7 @@ import {
EntityTypeEnum, EntityTypeEnum,
} from '@app/common/constants/automation.enum'; } from '@app/common/constants/automation.enum';
import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service';
import { ConvertedAction } from '@app/common/integrations/tuya/interfaces';
@Injectable() @Injectable()
export class AutomationService { export class AutomationService {
@ -50,39 +50,26 @@ export class AutomationService {
}); });
} }
async addAutomation(addAutomationDto: AddAutomationDto, spaceTuyaId = null) { async addAutomation(addAutomationDto: AddAutomationDto) {
try { try {
const { automationName, effectiveTime, decisionExpr } = addAutomationDto; const {
const space = await this.getSpaceByUuid(addAutomationDto.spaceUuid);
const actions = await this.processEntities<Action>(
addAutomationDto.actions,
'actionExecutor',
{ [ActionExecutorEnum.DEVICE_ISSUE]: true },
this.deviceService,
);
const conditions = await this.processEntities<Condition>(
addAutomationDto.conditions,
'entityType',
{ [EntityTypeEnum.DEVICE_REPORT]: true },
this.deviceService,
);
const response = (await this.tuyaService.createAutomation(
space.spaceTuyaUuid,
automationName, automationName,
effectiveTime, effectiveTime,
decisionExpr, decisionExpr,
conditions,
actions, actions,
)) as AddAutomationInterface; conditions,
} = addAutomationDto;
return { const space = await this.getSpaceByUuid(addAutomationDto.spaceUuid);
id: response?.result.id, const response = await this.add({
}; automationName,
effectiveTime,
decisionExpr,
actions,
conditions,
spaceTuyaId: space.spaceTuyaUuid,
});
return response;
} catch (err) { } catch (err) {
console.log(err);
if (err instanceof BadRequestException) { if (err instanceof BadRequestException) {
throw err; throw err;
} else { } else {
@ -94,6 +81,33 @@ export class AutomationService {
} }
} }
async add(params: AddAutomationParams) {
try {
const formattedActions = await this.prepareActions(params.actions);
const formattedCondition = await this.prepareConditions(
params.conditions,
);
const response = await this.tuyaService.createAutomation(
params.spaceTuyaId,
params.automationName,
params.effectiveTime,
params.decisionExpr,
formattedCondition,
formattedActions,
);
return {
id: response?.result.id,
};
} catch (error) {
throw new HttpException(
error.message || 'Failed to add automation',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async getSpaceByUuid(spaceUuid: string) { async getSpaceByUuid(spaceUuid: string) {
try { try {
const space = await this.spaceRepository.findOne({ const space = await this.spaceRepository.findOne({
@ -120,6 +134,7 @@ export class AutomationService {
} }
} }
} }
async getAutomationBySpace(spaceUuid: string) { async getAutomationBySpace(spaceUuid: string) {
try { try {
const space = await this.getSpaceByUuid(spaceUuid); const space = await this.getSpaceByUuid(spaceUuid);
@ -277,34 +292,45 @@ export class AutomationService {
} }
} }
async deleteAutomation(param: DeleteAutomationParamDto, spaceTuyaId = null) { async deleteAutomation(param: AutomationParamDto) {
try { try {
const { automationUuid, spaceUuid } = param; const { automationUuid } = param;
let tuyaSpaceId;
if (!spaceTuyaId) {
const space = await this.getSpaceByUuid(spaceUuid);
tuyaSpaceId = space.spaceTuyaUuid;
if (!tuyaSpaceId) {
throw new BadRequestException(`Invalid space UUID ${spaceUuid}`);
}
} else {
tuyaSpaceId = spaceTuyaId;
}
const path = `/v2.0/cloud/scene/rule?ids=${automationUuid}&space_id=${tuyaSpaceId}`; const automation = await this.getAutomationDetails(automationUuid, true);
const response: DeleteAutomationInterface = await this.tuya.request({
method: 'DELETE',
path,
});
if (!response.success) { if (!automation && !automation.spaceId) {
throw new HttpException('Automation not found', HttpStatus.NOT_FOUND); throw new HttpException(
`Invalid automationid ${automationUuid}`,
HttpStatus.BAD_REQUEST,
);
} }
const response = this.tuyaService.deleteAutomation(
automation.spaceId,
automationUuid,
);
return response; return response;
} catch (err) { } catch (err) {
if (err instanceof BadRequestException) { if (err instanceof HttpException) {
throw err; // Re-throw BadRequestException throw err;
} else {
throw new HttpException(
err.message || 'Automation not found',
err.status || HttpStatus.NOT_FOUND,
);
}
}
}
async delete(tuyaSpaceId: string, automationUuid: string) {
try {
const response = await this.tuyaService.deleteAutomation(
tuyaSpaceId,
automationUuid,
);
return response;
} catch (err) {
if (err instanceof HttpException) {
throw err;
} else { } else {
throw new HttpException( throw new HttpException(
err.message || 'Automation not found', err.message || 'Automation not found',
@ -318,28 +344,28 @@ export class AutomationService {
updateAutomationDto: UpdateAutomationDto, updateAutomationDto: UpdateAutomationDto,
automationUuid: string, automationUuid: string,
) { ) {
const { actions, conditions, automationName, effectiveTime, decisionExpr } =
updateAutomationDto;
try { try {
const spaceTuyaId = await this.getAutomationDetails(automationUuid, true); const automation = await this.getAutomationDetails(automationUuid, true);
if (!spaceTuyaId.spaceId) { if (!automation.spaceId) {
throw new HttpException( throw new HttpException(
"Automation doesn't exist", "Automation doesn't exist",
HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND,
); );
} }
const addAutomation = {
...updateAutomationDto, const newAutomation = await this.add({
spaceUuid: null, actions,
}; conditions,
const newAutomation = await this.addAutomation( automationName,
addAutomation, effectiveTime,
spaceTuyaId.spaceId, decisionExpr,
); spaceTuyaId: automation.spaceId,
const params: DeleteAutomationParamDto = { });
spaceUuid: spaceTuyaId.spaceId,
automationUuid: automationUuid,
};
if (newAutomation.id) { if (newAutomation.id) {
await this.deleteAutomation(null, params); await this.delete(automation.spaceId, automationUuid);
return newAutomation; return newAutomation;
} }
} catch (err) { } catch (err) {
@ -357,29 +383,21 @@ export class AutomationService {
updateAutomationStatusDto: UpdateAutomationStatusDto, updateAutomationStatusDto: UpdateAutomationStatusDto,
automationUuid: string, automationUuid: string,
) { ) {
const { isEnable, spaceUuid } = updateAutomationStatusDto;
try { try {
const space = await this.getSpaceByUuid( const space = await this.getSpaceByUuid(spaceUuid);
updateAutomationStatusDto.spaceUuid,
);
if (!space.spaceTuyaUuid) { if (!space.spaceTuyaUuid) {
throw new BadRequestException( throw new HttpException(
`Invalid space UUID ${updateAutomationStatusDto.spaceUuid}`, `Invalid space UUID ${spaceUuid}`,
HttpStatus.NOT_FOUND,
); );
} }
const path = `/v2.0/cloud/scene/rule/state?space_id=${space.spaceTuyaUuid}`; const response = await this.tuyaService.updateAutomationState(
const response: DeleteAutomationInterface = await this.tuya.request({ space.spaceTuyaUuid,
method: 'PUT', automationUuid,
path, isEnable,
body: { );
ids: automationUuid,
is_enable: updateAutomationStatusDto.isEnable,
},
});
if (!response.success) {
throw new HttpException('Automation not found', HttpStatus.NOT_FOUND);
}
return response; return response;
} catch (err) { } catch (err) {
@ -394,41 +412,41 @@ export class AutomationService {
} }
} }
async processEntities<T extends Action | Condition>( private async prepareActions(actions: Action[]): Promise<ConvertedAction[]> {
entities: T[], // Accepts either Action[] or Condition[] const convertedData = convertKeysToSnakeCase(actions) as ConvertedAction[];
lookupKey: keyof T, // The key to look up, specific to T
entityTypeOrExecutorMap: {
[key in ActionExecutorEnum | EntityTypeEnum]?: boolean;
},
deviceService: {
getDeviceByDeviceUuid: (
id: string,
flag: boolean,
) => Promise<{ deviceTuyaUuid: string } | null>;
},
): Promise<T[]> {
// Returns the same type as provided in the input
return Promise.all(
entities.map(async (entity) => {
// Convert keys to snake case (assuming a utility function exists)
const processedEntity = convertKeysToSnakeCase(entity) as T;
// Check if entity needs device UUID lookup await Promise.all(
const key = processedEntity[lookupKey]; convertedData.map(async (action) => {
if ( if (action.action_executor === ActionExecutorEnum.DEVICE_ISSUE) {
entityTypeOrExecutorMap[key as ActionExecutorEnum | EntityTypeEnum] const device = await this.deviceService.getDeviceByDeviceUuid(
) { action.entity_id,
const device = await deviceService.getDeviceByDeviceUuid(
processedEntity.entityId,
false, false,
); );
if (device) { if (device) {
processedEntity.entityId = device.deviceTuyaUuid; action.entity_id = device.deviceTuyaUuid;
} }
} }
return processedEntity;
}), }),
); );
return convertedData;
}
private async prepareConditions(conditions: Condition[]) {
const convertedData = convertKeysToSnakeCase(conditions);
await Promise.all(
convertedData.map(async (condition) => {
if (condition.entity_type === EntityTypeEnum.DEVICE_REPORT) {
const device = await this.deviceService.getDeviceByDeviceUuid(
condition.entity_id,
false,
);
if (device) {
condition.entity_id = device.deviceTuyaUuid;
}
}
}),
);
return convertedData;
} }
} }

View File

@ -2,7 +2,6 @@ import { GroupService } from '../services/group.service';
import { Controller, Get, UseGuards, Param, Req } from '@nestjs/common'; import { Controller, Get, UseGuards, Param, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { UnitPermissionGuard } from 'src/guards/unit.permission.guard';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
@ApiTags('Group Module') @ApiTags('Group Module')
@ -17,21 +16,21 @@ export class GroupController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get(':spaceUuid') @Get(':spaceUuid')
async getGroupsBySpaceUuid(@Param('spaceUuid') spaceUuid: string) { async getGroupsBySpaceUuid(@Param('spaceUuid') spaceUuid: string) {
return await this.groupService.getGroupsByUnitUuid(spaceUuid); return await this.groupService.getGroupsBySpaceUuid(spaceUuid);
} }
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get(':unitUuid/devices/:groupName') @Get(':spaceUuid/devices/:groupName')
async getUnitDevicesByGroupName( async getUnitDevicesByGroupName(
@Param('unitUuid') unitUuid: string, @Param('spaceUuid') spaceUuid: string,
@Param('groupName') groupName: string, @Param('groupName') groupName: string,
@Req() req: any, @Req() req: any,
) { ) {
const userUuid = req.user.uuid; const userUuid = req.user.uuid;
return await this.groupService.getUnitDevicesByGroupName( return await this.groupService.getSpaceDevicesByGroupName(
unitUuid, spaceUuid,
groupName, groupName,
userUuid, userUuid,
); );

View File

@ -27,11 +27,11 @@ export class GroupService {
}); });
} }
async getGroupsByUnitUuid(unitUuid: string) { async getGroupsBySpaceUuid(spaceUuid: string) {
try { try {
const spaces = await this.spaceRepository.find({ const spaces = await this.spaceRepository.find({
where: { where: {
uuid: unitUuid, uuid: spaceUuid,
}, },
relations: ['devices', 'devices.productDevice'], relations: ['devices', 'devices.productDevice'],
}); });
@ -61,23 +61,21 @@ export class GroupService {
); );
} catch (error) { } catch (error) {
throw new HttpException( throw new HttpException(
'This unit does not have any groups', 'This space does not have any groups',
HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND,
); );
} }
} }
async getUnitDevicesByGroupName( async getSpaceDevicesByGroupName(
unitUuid: string, spaceUuid: string,
groupName: string, groupName: string,
userUuid: string, userUuid: string,
) { ) {
try { try {
const spaces = await this.spaceRepository.find({ const spaces = await this.spaceRepository.find({
where: { where: {
parent: { uuid: spaceUuid,
uuid: unitUuid,
},
devices: { devices: {
productDevice: { productDevice: {
prodType: groupName, prodType: groupName,
@ -122,8 +120,9 @@ export class GroupService {
throw new HttpException('No devices found', HttpStatus.NOT_FOUND); throw new HttpException('No devices found', HttpStatus.NOT_FOUND);
return devices.flat(); // Flatten the array since flatMap was used return devices.flat(); // Flatten the array since flatMap was used
} catch (error) { } catch (error) {
console.log(error);
throw new HttpException( throw new HttpException(
'This unit does not have any devices for the specified group name', 'This space does not have any devices for the specified group name',
HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND,
); );
} }

View File

@ -157,7 +157,7 @@ export class SceneService {
); );
} else { } else {
throw new HttpException( throw new HttpException(
'An Internal error has been occured', `An Internal error has been occured ${err}`,
HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR,
); );
} }
@ -172,13 +172,13 @@ export class SceneService {
const scenesData = await this.sceneRepository.find({ const scenesData = await this.sceneRepository.find({
where: { where: {
spaceUuid, spaceUuid,
...(showInHomePage !== undefined ? { showInHomePage } : {}), ...(showInHomePage ? { showInHomePage } : {}),
}, },
}); });
if (!scenesData.length) { if (!scenesData.length) {
throw new HttpException( throw new HttpException(
`No scenes found for space UUID ${spaceUuid}`, `No scenes found for space UUID ${spaceUuid} with showInHomePage ${showInHomePage} `,
HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND,
); );
} }
@ -199,7 +199,7 @@ export class SceneService {
err.message, err.message,
); );
if (err instanceof BadRequestException) { if (err instanceof HttpException) {
throw err; throw err;
} else { } else {
throw new HttpException( throw new HttpException(

View File

@ -111,6 +111,7 @@ export class SpaceController {
return this.spaceService.getSpacesHierarchyForSpace(params.spaceUuid); return this.spaceService.getSpacesHierarchyForSpace(params.spaceUuid);
} }
//should it be post?
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiOperation({ @ApiOperation({
@ -118,7 +119,7 @@ export class SpaceController {
description: description:
ControllerRoute.SPACE.ACTIONS.CREATE_INVITATION_CODE_SPACE_DESCRIPTION, ControllerRoute.SPACE.ACTIONS.CREATE_INVITATION_CODE_SPACE_DESCRIPTION,
}) })
@Post(':spaceUuid/invitation-code') @Get(':spaceUuid/invitation-code')
async generateSpaceInvitationCode( async generateSpaceInvitationCode(
@Param() params: GetSpaceParam, @Param() params: GetSpaceParam,
): Promise<BaseResponseDto> { ): Promise<BaseResponseDto> {

View File

@ -1,6 +1,13 @@
import { ControllerRoute } from '@app/common/constants/controller-route'; import { ControllerRoute } from '@app/common/constants/controller-route';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import {
Controller,
Delete,
Get,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { DeviceSubSpaceParam, GetSubSpaceParam } from '../../dtos'; import { DeviceSubSpaceParam, GetSubSpaceParam } from '../../dtos';
import { SubspaceDeviceService } from 'src/space/services'; import { SubspaceDeviceService } from 'src/space/services';
@ -55,10 +62,12 @@ export class SubSpaceDeviceController {
ControllerRoute.SUBSPACE_DEVICE.ACTIONS ControllerRoute.SUBSPACE_DEVICE.ACTIONS
.DISASSOCIATE_SUBSPACE_DEVICE_DESCRIPTION, .DISASSOCIATE_SUBSPACE_DEVICE_DESCRIPTION,
}) })
@Post('/:deviceUuid') @Delete('/:deviceUuid')
async disassociateDeviceFromSubspace( async disassociateDeviceFromSubspace(
@Param() params: DeviceSubSpaceParam, @Param() params: DeviceSubSpaceParam,
): Promise<BaseResponseDto> { ): Promise<BaseResponseDto> {
return await this.subspaceDeviceService.associateDeviceToSubspace(params); return await this.subspaceDeviceService.disassociateDeviceFromSubspace(
params,
);
} }
} }

View File

@ -33,3 +33,43 @@ export class AddSpaceDto {
@IsBoolean() @IsBoolean()
isPrivate: boolean; isPrivate: boolean;
} }
export class AddUserSpaceDto {
@ApiProperty({
description: 'spaceUuid',
required: true,
})
@IsString()
@IsNotEmpty()
public spaceUuid: string;
@ApiProperty({
description: 'userUuid',
required: true,
})
@IsString()
@IsNotEmpty()
public userUuid: string;
constructor(dto: Partial<AddUserSpaceDto>) {
Object.assign(this, dto);
}
}
export class AddUserSpaceUsingCodeDto {
@ApiProperty({
description: 'userUuid',
required: true,
})
@IsString()
@IsNotEmpty()
public userUuid: string;
@ApiProperty({
description: 'inviteCode',
required: true,
})
@IsString()
@IsNotEmpty()
public inviteCode: string;
constructor(dto: Partial<AddUserSpaceDto>) {
Object.assign(this, dto);
}
}

View File

@ -100,7 +100,8 @@ export class SpaceDeviceService {
}); });
// Exclude specific keys and add `productUuid` // Exclude specific keys and add `productUuid`
const { ...rest } = camelCaseResponse; // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { uuid, ...rest } = camelCaseResponse;
return { return {
...rest, ...rest,
productUuid: product?.uuid, productUuid: product?.uuid,

View File

@ -1,9 +1,4 @@
import { import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
BadRequestException,
HttpException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { GetSpaceParam } from '../dtos'; import { GetSpaceParam } from '../dtos';
import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SpaceService } from './space.service'; import { SpaceService } from './space.service';
@ -42,8 +37,8 @@ export class SpaceSceneService {
} catch (error) { } catch (error) {
console.error('Error retrieving scenes:', error); console.error('Error retrieving scenes:', error);
if (error instanceof BadRequestException) { if (error instanceof HttpException) {
throw new HttpException(error.message, HttpStatus.BAD_REQUEST); throw error;
} else { } else {
throw new HttpException( throw new HttpException(
'An error occurred while retrieving scenes', 'An error occurred while retrieving scenes',

View File

@ -57,40 +57,72 @@ export class SubspaceDeviceService {
message: 'Successfully retrieved list of devices', message: 'Successfully retrieved list of devices',
}); });
} }
async associateDeviceToSubspace(params: DeviceSubSpaceParam) {
const { subSpaceUuid, deviceUuid, spaceUuid, communityUuid } = params;
async associateDeviceToSubspace(
params: DeviceSubSpaceParam,
): Promise<BaseResponseDto> {
const { subSpaceUuid, deviceUuid, spaceUuid, communityUuid } = params;
try {
await this.validateCommunityAndSpace(communityUuid, spaceUuid); await this.validateCommunityAndSpace(communityUuid, spaceUuid);
const subspace = await this.findSubspace(subSpaceUuid); const subspace = await this.findSubspace(subSpaceUuid);
const device = await this.findDevice(deviceUuid); const device = await this.findDevice(deviceUuid);
device.subspace = subspace; device.subspace = subspace;
await this.deviceRepository.save(device);
const newDevice = await this.deviceRepository.save(device);
return new SuccessResponseDto({ return new SuccessResponseDto({
data: device, data: newDevice,
message: 'Successfully associated device to subspace', message: 'Successfully associated device to subspace',
}); });
} catch (error) {
if (error instanceof HttpException) {
throw error;
} else {
throw new HttpException(
'Failed to associate device to subspace',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
} }
async disassociateDeviceFromSubspace(params: DeviceSubSpaceParam) { async disassociateDeviceFromSubspace(
params: DeviceSubSpaceParam,
): Promise<BaseResponseDto> {
const { subSpaceUuid, deviceUuid, spaceUuid, communityUuid } = params; const { subSpaceUuid, deviceUuid, spaceUuid, communityUuid } = params;
try {
await this.validateCommunityAndSpace(communityUuid, spaceUuid); await this.validateCommunityAndSpace(communityUuid, spaceUuid);
await this.findSubspace(subSpaceUuid); const subspace = await this.findSubspace(subSpaceUuid);
const device = await this.findDevice(deviceUuid); const device = await this.findDevice(deviceUuid);
device.subspace = null; if (!device.subspace || device.subspace.uuid !== subspace.uuid) {
await this.deviceRepository.save(device); throw new HttpException(
`Device ${deviceUuid} is not associated with the specified subspace ${subSpaceUuid} `,
return new SuccessResponseDto({ HttpStatus.BAD_REQUEST,
data: device, );
message: 'Successfully dissociated device from subspace',
});
} }
device.subspace = null;
const updatedDevice = await this.deviceRepository.save(device);
return new SuccessResponseDto({
data: updatedDevice,
message: 'Successfully dissociated device from subspace',
});
} catch (error) {
if (error instanceof HttpException) {
throw error;
} else {
throw new HttpException(
'Failed to dissociate device from subspace',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
// Helper method to validate community and space // Helper method to validate community and space
private async validateCommunityAndSpace( private async validateCommunityAndSpace(
communityUuid: string, communityUuid: string,
@ -137,6 +169,7 @@ export class SubspaceDeviceService {
private async findDevice(deviceUuid: string) { private async findDevice(deviceUuid: string) {
const device = await this.deviceRepository.findOne({ const device = await this.deviceRepository.findOne({
where: { uuid: deviceUuid }, where: { uuid: deviceUuid },
relations: ['subspace'],
}); });
if (!device) { if (!device) {
this.throwNotFound('Device', deviceUuid); this.throwNotFound('Device', deviceUuid);
@ -173,7 +206,7 @@ export class SubspaceDeviceService {
} as GetDeviceDetailsInterface; } as GetDeviceDetailsInterface;
} catch (error) { } catch (error) {
throw new HttpException( throw new HttpException(
'Error fetching device details from Tuya', `Error fetching device details from Tuya for device uuid ${deviceId}.`,
HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR,
); );
} }

View File

@ -1,11 +1,20 @@
import { ControllerRoute } from '@app/common/constants/controller-route'; import { ControllerRoute } from '@app/common/constants/controller-route';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { Controller, Get, Param, UseGuards } from '@nestjs/common'; import {
Body,
Controller,
Get,
HttpException,
HttpStatus,
Param,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { UserSpaceService } from '../services'; import { UserSpaceService } from '../services';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { UserParamDto } from '../dtos'; import { AddUserSpaceUsingCodeDto, UserParamDto } from '../dtos';
@ApiTags('User Module') @ApiTags('User Module')
@Controller({ @Controller({
@ -27,4 +36,29 @@ export class UserSpaceController {
): Promise<BaseResponseDto> { ): Promise<BaseResponseDto> {
return this.userSpaceService.getSpacesForUser(params.userUuid); return this.userSpaceService.getSpacesForUser(params.userUuid);
} }
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post('/verify-code')
async verifyCodeAndAddUserSpace(
@Body() dto: AddUserSpaceUsingCodeDto,
@Param() params: UserParamDto,
) {
try {
await this.userSpaceService.verifyCodeAndAddUserSpace(
dto,
params.userUuid,
);
return {
statusCode: HttpStatus.CREATED,
success: true,
message: 'user space added successfully',
};
} catch (error) {
throw new HttpException(
error.message || 'Internal server error',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
} }

View File

@ -0,0 +1,68 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
export class AddSpaceDto {
@ApiProperty({
description: 'Name of the space (e.g., Floor 1, Unit 101)',
example: 'Unit 101',
})
@IsString()
@IsNotEmpty()
spaceName: string;
@ApiProperty({
description: 'UUID of the parent space (if any, for hierarchical spaces)',
example: 'f5d7e9c3-44bc-4b12-88f1-1b3cda84752e',
required: false,
})
@IsUUID()
@IsOptional()
parentUuid?: string;
@ApiProperty({
description: 'Indicates whether the space is private or public',
example: false,
default: false,
})
@IsBoolean()
isPrivate: boolean;
}
export class AddUserSpaceDto {
@ApiProperty({
description: 'spaceUuid',
required: true,
})
@IsString()
@IsNotEmpty()
public spaceUuid: string;
@ApiProperty({
description: 'userUuid',
required: true,
})
@IsString()
@IsNotEmpty()
public userUuid: string;
constructor(dto: Partial<AddUserSpaceDto>) {
Object.assign(this, dto);
}
}
export class AddUserSpaceUsingCodeDto {
@ApiProperty({
description: 'inviteCode',
required: true,
})
@IsString()
@IsNotEmpty()
public inviteCode: string;
constructor(dto: Partial<AddUserSpaceDto>) {
Object.assign(this, dto);
}
}

View File

@ -1,3 +1,4 @@
export * from './update.user.dto'; export * from './update.user.dto';
export * from './user-community-param.dto'; export * from './user-community-param.dto';
export * from './user-param.dto'; export * from './user-param.dto';
export * from './add.space.dto';

View File

@ -2,10 +2,20 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { UserSpaceRepository } from '@app/common/modules/user/repositories'; import { UserSpaceRepository } from '@app/common/modules/user/repositories';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { AddUserSpaceDto, AddUserSpaceUsingCodeDto } from '../dtos';
import { SpaceRepository } from '@app/common/modules/space/repositories';
import { CommonErrorCodes } from '@app/common/constants/error-codes.enum';
import { UserDevicePermissionService } from 'src/user-device-permission/services';
import { PermissionType } from '@app/common/constants/permission-type.enum';
import { SpaceEntity } from '@app/common/modules/space/entities';
@Injectable() @Injectable()
export class UserSpaceService { export class UserSpaceService {
constructor(private readonly userSpaceRepository: UserSpaceRepository) {} constructor(
private readonly userSpaceRepository: UserSpaceRepository,
private readonly spaceRepository: SpaceRepository,
private readonly userDevicePermissionService: UserDevicePermissionService,
) {}
async getSpacesForUser(userUuid: string): Promise<BaseResponseDto> { async getSpacesForUser(userUuid: string): Promise<BaseResponseDto> {
const userSpaces = await this.userSpaceRepository.find({ const userSpaces = await this.userSpaceRepository.find({
@ -25,4 +35,110 @@ export class UserSpaceService {
message: `Spaces for user ${userUuid} retrieved successfully`, message: `Spaces for user ${userUuid} retrieved successfully`,
}); });
} }
async verifyCodeAndAddUserSpace(
params: AddUserSpaceUsingCodeDto,
userUuid: string,
) {
try {
const space = await this.findUnitByInviteCode(params.inviteCode);
await this.addUserToSpace(userUuid, space.uuid);
await this.clearUnitInvitationCode(space.uuid);
const deviceUUIDs = await this.getDeviceUUIDsForSpace(space.uuid);
await this.addUserPermissionsToDevices(userUuid, deviceUUIDs);
} catch (err) {
throw new HttpException(
'Invalid invitation code',
HttpStatus.BAD_REQUEST,
);
}
}
private async findUnitByInviteCode(inviteCode: string): Promise<SpaceEntity> {
const space = await this.spaceRepository.findOneOrFail({
where: {
invitationCode: inviteCode,
},
});
return space;
}
private async addUserToSpace(userUuid: string, spaceUuid: string) {
const user = await this.addUserSpace({ userUuid, spaceUuid });
if (user.uuid) {
return user;
} else {
throw new HttpException(
'Failed to add user to unit',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async addUserSpace(addUserSpaceDto: AddUserSpaceDto) {
try {
return await this.userSpaceRepository.save({
user: { uuid: addUserSpaceDto.userUuid },
space: { uuid: addUserSpaceDto.spaceUuid },
});
} catch (err) {
if (err.code === CommonErrorCodes.DUPLICATE_ENTITY) {
throw new HttpException(
'User already belongs to this unit',
HttpStatus.BAD_REQUEST,
);
}
throw new HttpException(
err.message || 'Internal Server Error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private async clearUnitInvitationCode(spaceUuid: string) {
await this.spaceRepository.update(
{ uuid: spaceUuid },
{ invitationCode: null },
);
}
private async getDeviceUUIDsForSpace(
unitUuid: string,
): Promise<{ uuid: string }[]> {
const devices = await this.spaceRepository.find({
where: { uuid: unitUuid },
relations: ['devicesSpaceEntity', 'devicesSpaceEntity.productDevice'],
});
const allDevices = devices.flatMap((space) => space.devices);
return allDevices.map((device) => ({ uuid: device.uuid }));
}
private async addUserPermissionsToDevices(
userUuid: string,
deviceUUIDs: { uuid: string }[],
): Promise<void> {
const permissionPromises = deviceUUIDs.map(async (device) => {
try {
await this.userDevicePermissionService.addUserPermission({
userUuid,
deviceUuid: device.uuid,
permissionType: PermissionType.CONTROLLABLE,
});
} catch (error) {
console.error(
`Failed to add permission for device ${device.uuid}: ${error.message}`,
);
}
});
await Promise.all(permissionPromises);
}
} }

View File

@ -12,6 +12,10 @@ import { UserSpaceController } from './controllers';
import { CommunityModule } from 'src/community/community.module'; import { CommunityModule } from 'src/community/community.module';
import { UserSpaceService } from './services'; import { UserSpaceService } from './services';
import { CommunityRepository } from '@app/common/modules/community/repositories'; import { CommunityRepository } from '@app/common/modules/community/repositories';
import { SpaceRepository } from '@app/common/modules/space/repositories';
import { UserDevicePermissionService } from 'src/user-device-permission/services';
import { DeviceUserPermissionRepository } from '@app/common/modules/device/repositories';
import { PermissionTypeRepository } from '@app/common/modules/permission/repositories';
@Module({ @Module({
imports: [ConfigModule, CommunityModule], imports: [ConfigModule, CommunityModule],
@ -20,9 +24,13 @@ import { CommunityRepository } from '@app/common/modules/community/repositories'
UserService, UserService,
UserRepository, UserRepository,
RegionRepository, RegionRepository,
SpaceRepository,
TimeZoneRepository, TimeZoneRepository,
UserSpaceRepository, UserSpaceRepository,
CommunityRepository, CommunityRepository,
UserDevicePermissionService,
DeviceUserPermissionRepository,
PermissionTypeRepository,
UserSpaceService, UserSpaceService,
], ],
exports: [UserService], exports: [UserService],