diff --git a/.env.example b/.env.example index 8fb1460..c194e08 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ +NODE_ENV= + ACCESS_KEY= AZURE_POSTGRESQL_DATABASE= @@ -52,6 +54,18 @@ SMTP_SECURE= SMTP_USER= +MAILTRAP_API_TOKEN= + +MAILTRAP_INVITATION_TEMPLATE_UUID= + +MAILTRAP_EDIT_USER_TEMPLATE_UUID= + +MAILTRAP_DISABLE_TEMPLATE_UUID= + +MAILTRAP_ENABLE_TEMPLATE_UUID= + +MAILTRAP_DELETE_USER_TEMPLATE_UUID= + WEBSITES_ENABLE_APP_SERVICE_STORAGE= PORT= diff --git a/.eslintrc.js b/.eslintrc.js index 259de13..34440e3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,4 +22,11 @@ module.exports = { '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'off', }, + settings: { + 'import/resolver': { + node: { + caseSensitive: true, + }, + }, + }, }; diff --git a/libs/common/src/auth/interfaces/auth.interface.ts b/libs/common/src/auth/interfaces/auth.interface.ts index 9b73050..ba674ee 100644 --- a/libs/common/src/auth/interfaces/auth.interface.ts +++ b/libs/common/src/auth/interfaces/auth.interface.ts @@ -4,5 +4,5 @@ export class AuthInterface { uuid: string; sessionId: string; id: number; - roles?: string[]; + role?: object; } diff --git a/libs/common/src/auth/services/auth.service.ts b/libs/common/src/auth/services/auth.service.ts index 5792eb0..fdba2f0 100644 --- a/libs/common/src/auth/services/auth.service.ts +++ b/libs/common/src/auth/services/auth.service.ts @@ -11,6 +11,8 @@ import { UserSessionRepository } from '../../../../common/src/modules/session/re import { UserSessionEntity } from '../../../../common/src/modules/session/entities'; import { ConfigService } from '@nestjs/config'; import { OAuth2Client } from 'google-auth-library'; +import { PlatformType } from '@app/common/constants/platform-type.enum'; +import { RoleType } from '@app/common/constants/role.type.enum'; @Injectable() export class AuthService { @@ -29,33 +31,46 @@ export class AuthService { email: string, pass: string, regionUuid?: string, + platform?: PlatformType, ): Promise { const user = await this.userRepository.findOne({ where: { email, - region: regionUuid - ? { - uuid: regionUuid, - } - : undefined, + region: regionUuid ? { uuid: regionUuid } : undefined, }, - relations: ['roles.roleType'], + relations: ['roleType'], }); + if ( + platform === PlatformType.WEB && + (user.roleType.type === RoleType.SPACE_OWNER || + user.roleType.type === RoleType.SPACE_MEMBER) + ) { + throw new UnauthorizedException('Access denied for web platform'); + } + if (!user) { + throw new BadRequestException('Invalid credentials'); + } if (!user.isUserVerified) { throw new BadRequestException('User is not verified'); } - if (user) { - const passwordMatch = this.helperHashService.bcryptCompare( - pass, - user.password, - ); - if (passwordMatch) { - const { ...result } = user; - return result; - } + if (!user.isActive) { + throw new BadRequestException('User is not active'); } - return null; + if (!user.hasAcceptedAppAgreement) { + throw new BadRequestException('User has not accepted app agreement'); + } + const passwordMatch = await this.helperHashService.bcryptCompare( + pass, + user.password, + ); + if (!passwordMatch) { + throw new BadRequestException('Invalid credentials'); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password, ...result } = user; + return result; } async createSession(data): Promise { @@ -85,10 +100,11 @@ export class AuthService { email: user.email, userId: user.userId, uuid: user.uuid, - type: user.type, sessionId: user.sessionId, - roles: user?.roles, + role: user?.role, googleCode: user.googleCode, + hasAcceptedWebAgreement: user.hasAcceptedWebAgreement, + hasAcceptedAppAgreement: user.hasAcceptedAppAgreement, }; if (payload.googleCode) { const profile = await this.getProfile(payload.googleCode); diff --git a/libs/common/src/auth/strategies/jwt.strategy.ts b/libs/common/src/auth/strategies/jwt.strategy.ts index d548dd8..88ecb99 100644 --- a/libs/common/src/auth/strategies/jwt.strategy.ts +++ b/libs/common/src/auth/strategies/jwt.strategy.ts @@ -31,7 +31,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { userUuid: payload.uuid, uuid: payload.uuid, sessionId: payload.sessionId, - roles: payload?.roles, + role: payload?.role, }; } else { throw new BadRequestException('Unauthorized'); diff --git a/libs/common/src/auth/strategies/refresh-token.strategy.ts b/libs/common/src/auth/strategies/refresh-token.strategy.ts index b6d6c2a..6061bd2 100644 --- a/libs/common/src/auth/strategies/refresh-token.strategy.ts +++ b/libs/common/src/auth/strategies/refresh-token.strategy.ts @@ -34,7 +34,7 @@ export class RefreshTokenStrategy extends PassportStrategy( userUuid: payload.uuid, uuid: payload.uuid, sessionId: payload.sessionId, - roles: payload?.roles, + role: payload?.role, }; } else { throw new BadRequestException('Unauthorized'); diff --git a/libs/common/src/common.module.ts b/libs/common/src/common.module.ts index de0780a..91e5507 100644 --- a/libs/common/src/common.module.ts +++ b/libs/common/src/common.module.ts @@ -9,6 +9,12 @@ import { EmailService } from './util/email.service'; import { ErrorMessageService } from 'src/error-message/error-message.service'; import { TuyaService } from './integrations/tuya/services/tuya.service'; import { SceneDeviceRepository } from './modules/scene-device/repositories'; +import { SpaceRepository } from './modules/space'; +import { + SpaceModelRepository, + SubspaceModelRepository, +} from './modules/space-model'; +import { SubspaceRepository } from './modules/space/repositories/subspace.repository'; @Module({ providers: [ CommonService, @@ -16,6 +22,10 @@ import { SceneDeviceRepository } from './modules/scene-device/repositories'; ErrorMessageService, TuyaService, SceneDeviceRepository, + SpaceRepository, + SubspaceRepository, + SubspaceModelRepository, + SpaceModelRepository, ], exports: [ CommonService, @@ -25,6 +35,10 @@ import { SceneDeviceRepository } from './modules/scene-device/repositories'; EmailService, ErrorMessageService, SceneDeviceRepository, + SpaceRepository, + SubspaceRepository, + SubspaceModelRepository, + SpaceModelRepository, ], imports: [ ConfigModule.forRoot({ diff --git a/libs/common/src/config/email.config.ts b/libs/common/src/config/email.config.ts index 3a5c21e..7c9b776 100644 --- a/libs/common/src/config/email.config.ts +++ b/libs/common/src/config/email.config.ts @@ -10,5 +10,14 @@ export default registerAs( SMTP_USER: process.env.SMTP_USER, SMTP_SENDER: process.env.SMTP_SENDER, SMTP_PASSWORD: process.env.SMTP_PASSWORD, + MAILTRAP_API_TOKEN: process.env.MAILTRAP_API_TOKEN, + MAILTRAP_INVITATION_TEMPLATE_UUID: + process.env.MAILTRAP_INVITATION_TEMPLATE_UUID, + MAILTRAP_DISABLE_TEMPLATE_UUID: process.env.MAILTRAP_DISABLE_TEMPLATE_UUID, + MAILTRAP_ENABLE_TEMPLATE_UUID: process.env.MAILTRAP_ENABLE_TEMPLATE_UUID, + MAILTRAP_DELETE_USER_TEMPLATE_UUID: + process.env.MAILTRAP_DELETE_USER_TEMPLATE_UUID, + MAILTRAP_EDIT_USER_TEMPLATE_UUID: + process.env.MAILTRAP_EDIT_USER_TEMPLATE_UUID, }), ); diff --git a/libs/common/src/constants/automation.enum.ts b/libs/common/src/constants/automation.enum.ts index d1503cb..1713c9a 100644 --- a/libs/common/src/constants/automation.enum.ts +++ b/libs/common/src/constants/automation.enum.ts @@ -2,6 +2,8 @@ export enum ActionExecutorEnum { DEVICE_ISSUE = 'device_issue', DELAY = 'delay', RULE_TRIGGER = 'rule_trigger', + RULE_DISABLE = 'rule_disable', + RULE_ENABLE = 'rule_enable', } export enum EntityTypeEnum { diff --git a/libs/common/src/constants/controller-route.ts b/libs/common/src/constants/controller-route.ts index eeacd0f..4cee3fe 100644 --- a/libs/common/src/constants/controller-route.ts +++ b/libs/common/src/constants/controller-route.ts @@ -1,4 +1,47 @@ export class ControllerRoute { + static PROJECT = class { + public static readonly ROUTE = 'projects'; + static ACTIONS = class { + public static readonly CREATE_PROJECT_SUMMARY = 'Create a new project'; + public static readonly CREATE_PROJECT_DESCRIPTION = + 'This endpoint allows you to create a new project by providing the required project details.'; + + public static readonly GET_PROJECT_SUMMARY = 'Retrieve project details'; + public static readonly GET_PROJECT_DESCRIPTION = + 'This endpoint retrieves the details of a project by its unique identifier (UUID).'; + + public static readonly UPDATE_PROJECT_SUMMARY = 'Update project details'; + public static readonly UPDATE_PROJECT_DESCRIPTION = + 'This endpoint updates the details of an existing project using its unique identifier (UUID).'; + + public static readonly LIST_PROJECTS_SUMMARY = 'List all projects'; + public static readonly LIST_PROJECTS_DESCRIPTION = + 'This endpoint retrieves a list of all existing projects, including their details.'; + + public static readonly DELETE_PROJECT_SUMMARY = 'Delete a project'; + public static readonly DELETE_PROJECT_DESCRIPTION = + 'This endpoint deletes an existing project by its unique identifier (UUID).'; + + public static readonly GET_USERS_BY_PROJECT_SUMMARY = + 'Get users by project'; + public static readonly GET_USERS_BY_PROJECT_DESCRIPTION = + 'This endpoint retrieves all users associated with a specific project.'; + public static readonly GET_USER_BY_UUID_IN_PROJECT_SUMMARY = + 'Get user by uuid in project'; + public static readonly GET_USER_BY_UUID_IN_PROJECT_DESCRIPTION = + 'This endpoint retrieves a user by their unique identifier (UUID) associated with a specific project.'; + }; + }; + static PROJECT_USER = class { + public static readonly ROUTE = '/projects/:projectUuid/user'; + static ACTIONS = class { + public static readonly GET_USERS_BY_PROJECT_SUMMARY = + 'Get users by project'; + public static readonly GET_USERS_BY_PROJECT_DESCRIPTION = + 'This endpoint retrieves all users associated with a specific project.'; + }; + }; + static REGION = class { public static readonly ROUTE = 'region'; static ACTIONS = class { @@ -10,7 +53,7 @@ export class ControllerRoute { }; static COMMUNITY = class { - public static readonly ROUTE = 'communities'; + public static readonly ROUTE = '/projects/:projectUuid/communities'; static ACTIONS = class { public static readonly GET_COMMUNITY_BY_ID_SUMMARY = 'Get community by community community uuid'; @@ -115,7 +158,8 @@ export class ControllerRoute { }; static SPACE = class { - public static readonly ROUTE = '/communities/:communityUuid/spaces'; + public static readonly ROUTE = + '/projects/:projectUuid/communities/:communityUuid/spaces'; static ACTIONS = class { public static readonly CREATE_SPACE_SUMMARY = 'Create a new space'; public static readonly CREATE_SPACE_DESCRIPTION = @@ -156,7 +200,7 @@ export class ControllerRoute { static SPACE_SCENE = class { public static readonly ROUTE = - '/communities/:communityUuid/spaces/:spaceUuid/scenes'; + '/projects/:projectUuid/communities/:communityUuid/spaces/:spaceUuid/scenes'; static ACTIONS = class { public static readonly GET_TAP_TO_RUN_SCENE_BY_SPACE_SUMMARY = 'Retrieve Tap-to-Run Scenes by Space'; @@ -167,7 +211,7 @@ export class ControllerRoute { static SPACE_USER = class { public static readonly ROUTE = - '/communities/:communityUuid/spaces/:spaceUuid/user'; + '/projects/:projectUuid/communities/:communityUuid/spaces/:spaceUuid/user'; static ACTIONS = class { public static readonly ASSOCIATE_SPACE_USER_SUMMARY = 'Associate a user to a space'; @@ -183,7 +227,7 @@ export class ControllerRoute { static SPACE_DEVICES = class { public static readonly ROUTE = - '/communities/:communityUuid/spaces/:spaceUuid/devices'; + '/projects/:projectUuid/communities/:communityUuid/spaces/:spaceUuid/devices'; static ACTIONS = class { public static readonly LIST_SPACE_DEVICE_SUMMARY = 'List devices in a space'; @@ -194,7 +238,7 @@ export class ControllerRoute { static SUBSPACE = class { public static readonly ROUTE = - '/communities/:communityUuid/spaces/:spaceUuid/subspaces'; + '/projects/:projectUuid/communities/:communityUuid/spaces/:spaceUuid/subspaces'; static ACTIONS = class { public static readonly CREATE_SUBSPACE_SUMMARY = 'Create Subspace'; public static readonly CREATE_SUBSPACE_DESCRIPTION = @@ -220,7 +264,7 @@ export class ControllerRoute { static SUBSPACE_DEVICE = class { public static readonly ROUTE = - '/communities/:communityUuid/spaces/:spaceUuid/subspaces/:subSpaceUuid/devices'; + '/projects/:projectUuid/communities/:communityUuid/spaces/:spaceUuid/subspaces/:subSpaceUuid/devices'; static ACTIONS = class { public static readonly LIST_SUBSPACE_DEVICE_SUMMARY = @@ -240,6 +284,32 @@ export class ControllerRoute { }; }; + static SPACE_MODEL = class { + public static readonly ROUTE = '/projects/:projectUuid/space-models'; + static ACTIONS = class { + public static readonly CREATE_SPACE_MODEL_SUMMARY = + 'Create a New Space Model'; + public static readonly CREATE_SPACE_MODEL_DESCRIPTION = + 'This endpoint allows you to create a new space model within a specified project. A space model defines the structure of spaces, including subspaces, products, and product items, and is uniquely identifiable within the project.'; + + public static readonly GET_SPACE_MODEL_SUMMARY = 'Get a New Space Model'; + public static readonly GET_SPACE_MODEL_DESCRIPTION = + 'Fetch a space model details'; + + public static readonly LIST_SPACE_MODEL_SUMMARY = 'List Space Models'; + public static readonly LIST_SPACE_MODEL_DESCRIPTION = + 'This endpoint allows you to retrieve a list of space models within a specified project. Each space model includes its structure, associated subspaces, products, and product items.'; + + public static readonly UPDATE_SPACE_MODEL_SUMMARY = 'Update Space Model'; + public static readonly UPDATE_SPACE_MODEL_DESCRIPTION = + 'This endpoint allows you to update a Space Model attributesas well as manage its associated Subspaces and Device'; + + public static readonly DELETE_SPACE_MODEL_SUMMARY = 'Delete Space Model'; + public static readonly DELETE_SPACE_MODEL_DESCRIPTION = + 'This endpoint allows you to delete a specified Space Model within a project. Deleting a Space Model disables the model and all its associated subspaces and tags, ensuring they are no longer active but remain in the system for auditing.'; + }; + }; + static PRODUCT = class { public static readonly ROUTE = 'products'; static ACTIONS = class { @@ -279,6 +349,10 @@ export class ControllerRoute { public static readonly DELETE_USER_SUMMARY = 'Delete user by UUID'; public static readonly DELETE_USER_DESCRIPTION = 'This endpoint deletes a user identified by their UUID. Accessible only by users with the Super Admin role.'; + public static readonly UPDATE_USER_WEB_AGREEMENT_SUMMARY = + 'Update user web agreement by user UUID'; + public static readonly UPDATE_USER_WEB_AGREEMENT_DESCRIPTION = + 'This endpoint updates the web agreement for a user identified by their UUID.'; }; }; static AUTHENTICATION = class { @@ -334,6 +408,25 @@ export class ControllerRoute { 'This endpoint adds a new user role to the system based on the provided role data.'; }; }; + static TERMS_AND_CONDITIONS = class { + public static readonly ROUTE = 'terms'; + + static ACTIONS = class { + public static readonly FETCH_TERMS_SUMMARY = 'Fetch Terms and Conditions'; + public static readonly FETCH_TERMS_DESCRIPTION = + 'This endpoint retrieves the terms and conditions for the application.'; + }; + }; + + static PRIVACY_POLICY = class { + public static readonly ROUTE = 'policy'; + + static ACTIONS = class { + public static readonly FETCH_POLICY_SUMMARY = 'Fetch Privacy Policy'; + public static readonly FETCH_POLICY_DESCRIPTION = + 'This endpoint retrieves the privacy policy for the application.'; + }; + }; static GROUP = class { public static readonly ROUTE = 'group'; @@ -685,4 +778,53 @@ export class ControllerRoute { 'This endpoint deletes a user’s subscription for device messages.'; }; }; + static INVITE_USER = class { + public static readonly ROUTE = 'invite-user'; + static ACTIONS = class { + public static readonly CREATE_USER_INVITATION_SUMMARY = + 'Create user invitation'; + + public static readonly CREATE_USER_INVITATION_DESCRIPTION = + 'This endpoint creates an invitation for a user to assign to role and spaces.'; + + public static readonly UPDATE_USER_INVITATION_SUMMARY = + 'Update user invitation'; + + public static readonly UPDATE_USER_INVITATION_DESCRIPTION = + 'This endpoint updates an invitation for a user to assign to role and spaces.'; + + public static readonly DISABLE_USER_INVITATION_SUMMARY = + 'Disable user invitation'; + + public static readonly DISABLE_USER_INVITATION_DESCRIPTION = + 'This endpoint disables an invitation for a user to assign to role and spaces.'; + + public static readonly DELETE_USER_INVITATION_SUMMARY = + 'Delete user invitation'; + + public static readonly DELETE_USER_INVITATION_DESCRIPTION = + 'This endpoint deletes an invitation for a user to assign to role and spaces.'; + + public static readonly ACTIVATION_CODE_SUMMARY = + 'Activate Invitation Code'; + + public static readonly ACTIVATION_CODE_DESCRIPTION = + 'This endpoint activate invitation code'; + + public static readonly CHECK_EMAIL_SUMMARY = 'Check email'; + + public static readonly CHECK_EMAIL_DESCRIPTION = + 'This endpoint checks if an email already exists and have a project in the system.'; + }; + }; + static PERMISSION = class { + public static readonly ROUTE = 'permission'; + static ACTIONS = class { + public static readonly GET_PERMISSION_BY_ROLE_SUMMARY = + 'Get permissions by role'; + + public static readonly GET_PERMISSION_BY_ROLE_DESCRIPTION = + 'This endpoint retrieves the permissions associated with a specific role.'; + }; + }; } diff --git a/libs/common/src/constants/mail-trap.ts b/libs/common/src/constants/mail-trap.ts new file mode 100644 index 0000000..6642244 --- /dev/null +++ b/libs/common/src/constants/mail-trap.ts @@ -0,0 +1,3 @@ +export const SEND_EMAIL_API_URL_PROD = 'https://send.api.mailtrap.io/api/send/'; +export const SEND_EMAIL_API_URL_DEV = + 'https://sandbox.api.mailtrap.io/api/send/2634012'; diff --git a/libs/common/src/constants/modify-action.enum.ts b/libs/common/src/constants/modify-action.enum.ts new file mode 100644 index 0000000..28d6f77 --- /dev/null +++ b/libs/common/src/constants/modify-action.enum.ts @@ -0,0 +1,5 @@ +export enum ModifyAction { + ADD = 'add', + UPDATE = 'update', + DELETE = 'delete', +} diff --git a/libs/common/src/constants/orphan-constant.ts b/libs/common/src/constants/orphan-constant.ts new file mode 100644 index 0000000..9cb31be --- /dev/null +++ b/libs/common/src/constants/orphan-constant.ts @@ -0,0 +1,4 @@ +export const ORPHAN_COMMUNITY_NAME = 'orphan-community'; +export const ORPHAN_COMMUNITY_DESCRIPTION = + 'Default community for orphan spaces'; +export const ORPHAN_SPACE_NAME = 'orphan-space'; diff --git a/libs/common/src/constants/permissions-mapping.ts b/libs/common/src/constants/permissions-mapping.ts new file mode 100644 index 0000000..91cc595 --- /dev/null +++ b/libs/common/src/constants/permissions-mapping.ts @@ -0,0 +1,53 @@ +export const PermissionMapping = { + DEVICE_MANAGEMENT: { + DEVICE: [ + 'SINGLE_CONTROL', + 'VIEW', + 'DELETE', + 'UPDATE', + 'BATCH_CONTROL', + 'LOCATION_VIEW', + 'LOCATION_UPDATE', + ], + FIRMWARE: ['CONTROL', 'VIEW'], + }, + COMMUNITY_MANAGEMENT: { + COMMUNITY: ['VIEW', 'ADD', 'UPDATE', 'DELETE'], + }, + SPACE_MANAGEMENT: { + SPACE: [ + 'VIEW', + 'ADD', + 'UPDATE', + 'DELETE', + 'MODEL_ADD', + 'MODEL_DELETE', + 'MODEL_VIEW', + 'ASSIGN_USER_TO_SPACE', + 'DELETE_USER_FROM_SPACE', + ], + SUBSPACE: [ + 'VIEW', + 'ADD', + 'UPDATE', + 'DELETE', + 'ASSIGN_DEVICE_TO_SUBSPACE', + 'DELETE_DEVICE_FROM_SUBSPACE', + ], + }, + DEVICE_WIZARD: { + DEVICE_WIZARD: ['VIEW_DEVICE_WIZARD'], + SPACE_DEVICE: ['VIEW_DEVICE_IN_SPACE', 'ASSIGN_DEVICE_TO_SPACE'], + SUBSPACE_DEVICE: ['VIEW_DEVICE_IN_SUBSPACE', 'UPDATE_DEVICE_IN_SUBSPACE'], + }, + AUTOMATION_MANAGEMENT: { + AUTOMATION: ['VIEW', 'ADD', 'UPDATE', 'DELETE', 'CONTROL'], + SCENES: ['VIEW', 'ADD', 'UPDATE', 'DELETE', 'CONTROL'], + }, + VISITOR_PASSWORD_MANAGEMENT: { + VISITOR_PASSWORD: ['VIEW', 'ADD', 'UPDATE', 'DELETE'], + }, + USER_MANAGEMENT: { + USER: ['ADD'], + }, +}; diff --git a/libs/common/src/constants/platform-type.enum.ts b/libs/common/src/constants/platform-type.enum.ts new file mode 100644 index 0000000..e8216c0 --- /dev/null +++ b/libs/common/src/constants/platform-type.enum.ts @@ -0,0 +1,4 @@ +export enum PlatformType { + WEB = 'web', + MOBILE = 'mobile', +} diff --git a/libs/common/src/constants/privacy-policy.html b/libs/common/src/constants/privacy-policy.html new file mode 100644 index 0000000..e33fabf --- /dev/null +++ b/libs/common/src/constants/privacy-policy.html @@ -0,0 +1,37 @@ +
+

Syncrow Mobile Privacy Policy

+

+ Effective Date: 26/06/2022
+ Updated: 26/06/2022 +

+

+ Syncrow and subsidiaries (“we”, “us”, “our”, “Syncrow”) are committed to + protecting your privacy. This Privacy Policy (“Policy”) describes our + practices in connection with information privacy on Personal Data we process + through your individual use of the following services, products, and related + mobile applications (collectively, the “Products”): +

+
    +
  • Syncrow Mobile Application
  • +
+

+ Before you use our Products, please carefully read through this Policy and + understand our purposes and practices of collection, processing of your + Personal Data, including how we use, store, share and transfer Personal + Data. In the Policy you will also find ways to execute your rights of + access, update, delete or protect your Personal Data. +

+

+ When you accept this Policy when you register with your Personal Data, or if + you start to use our Products and does not expressly object to the contents + of this Policy, we will consider that you fully understand and agree with + this Policy. If you have any questions regarding this Policy, please do not + hesitate to contact us via: +

+

+ For other branded mobile applications powered by Syncrow, our Clients + control all the Personal Data collected through our Products. We collect the + information under the direction of our Clients and the processing of such + information. +

+
diff --git a/libs/common/src/constants/product-type.enum.ts b/libs/common/src/constants/product-type.enum.ts index d368865..fd0cc6f 100644 --- a/libs/common/src/constants/product-type.enum.ts +++ b/libs/common/src/constants/product-type.enum.ts @@ -18,4 +18,5 @@ export enum ProductType { PC = 'PC', FOUR_S = '4S', SIX_S = '6S', + SOS = 'SOS', } diff --git a/libs/common/src/constants/role-permissions.ts b/libs/common/src/constants/role-permissions.ts new file mode 100644 index 0000000..96655f7 --- /dev/null +++ b/libs/common/src/constants/role-permissions.ts @@ -0,0 +1,165 @@ +import { RoleType } from './role.type.enum'; + +export const RolePermissions = { + [RoleType.SUPER_ADMIN]: [ + 'DEVICE_SINGLE_CONTROL', + 'DEVICE_VIEW', + 'DEVICE_DELETE', + 'DEVICE_UPDATE', + 'DEVICE_BATCH_CONTROL', + 'DEVICE_LOCATION_VIEW', + 'DEVICE_LOCATION_UPDATE', + 'COMMUNITY_VIEW', + 'COMMUNITY_ADD', + 'COMMUNITY_UPDATE', + 'COMMUNITY_DELETE', + 'FIRMWARE_CONTROL', + 'FIRMWARE_VIEW', + 'SPACE_VIEW', + 'SPACE_ADD', + 'SPACE_UPDATE', + 'SPACE_DELETE', + 'SPACE_MODEL_ADD', + 'SPACE_MODEL_VIEW', + 'SPACE_MODEL_UPDATE', + 'SPACE_MODEL_DELETE', + 'SPACE_ASSIGN_USER_TO_SPACE', + 'SPACE_DELETE_USER_FROM_SPACE', + 'SUBSPACE_VIEW', + 'SUBSPACE_ADD', + 'SUBSPACE_UPDATE', + 'SUBSPACE_DELETE', + 'SUBSPACE_ASSIGN_DEVICE_TO_SUBSPACE', + 'SUBSPACE_DELETE_DEVICE_FROM_SUBSPACE', + 'DEVICE_WIZARD_VIEW_DEVICE_WIZARD', + 'SUBSPACE_DEVICE_VIEW_DEVICE_IN_SUBSPACE', + 'SPACE_DEVICE_VIEW_DEVICE_IN_SPACE', + 'SUBSPACE_DEVICE_UPDATE_DEVICE_IN_SUBSPACE', + 'SPACE_DEVICE_ASSIGN_DEVICE_TO_SPACE', + 'AUTOMATION_VIEW', + 'AUTOMATION_ADD', + 'AUTOMATION_UPDATE', + 'AUTOMATION_DELETE', + 'AUTOMATION_CONTROL', + 'SCENES_VIEW', + 'SCENES_ADD', + 'SCENES_UPDATE', + 'SCENES_DELETE', + 'SCENES_CONTROL', + 'VISITOR_PASSWORD_VIEW', + 'VISITOR_PASSWORD_ADD', + 'VISITOR_PASSWORD_UPDATE', + 'VISITOR_PASSWORD_DELETE', + 'USER_ADD', + 'SPACE_MEMBER_ADD', + ], + [RoleType.ADMIN]: [ + 'DEVICE_SINGLE_CONTROL', + 'DEVICE_VIEW', + 'DEVICE_DELETE', + 'DEVICE_UPDATE', + 'DEVICE_BATCH_CONTROL', + 'DEVICE_LOCATION_VIEW', + 'DEVICE_LOCATION_UPDATE', + 'COMMUNITY_VIEW', + 'COMMUNITY_ADD', + 'COMMUNITY_UPDATE', + 'COMMUNITY_DELETE', + 'FIRMWARE_CONTROL', + 'FIRMWARE_VIEW', + 'SPACE_VIEW', + 'SPACE_ADD', + 'SPACE_UPDATE', + 'SPACE_DELETE', + 'SPACE_MODEL_ADD', + 'SPACE_MODEL_VIEW', + 'SPACE_MODEL_UPDATE', + 'SPACE_MODEL_DELETE', + 'SPACE_ASSIGN_USER_TO_SPACE', + 'SPACE_DELETE_USER_FROM_SPACE', + 'SUBSPACE_VIEW', + 'SUBSPACE_ADD', + 'SUBSPACE_UPDATE', + 'SUBSPACE_DELETE', + 'SUBSPACE_ASSIGN_DEVICE_TO_SUBSPACE', + 'SUBSPACE_DELETE_DEVICE_FROM_SUBSPACE', + 'DEVICE_WIZARD_VIEW_DEVICE_WIZARD', + 'SUBSPACE_DEVICE_VIEW_DEVICE_IN_SUBSPACE', + 'SPACE_DEVICE_VIEW_DEVICE_IN_SPACE', + 'SUBSPACE_DEVICE_UPDATE_DEVICE_IN_SUBSPACE', + 'SPACE_DEVICE_ASSIGN_DEVICE_TO_SPACE', + 'AUTOMATION_VIEW', + 'AUTOMATION_ADD', + 'AUTOMATION_UPDATE', + 'AUTOMATION_DELETE', + 'AUTOMATION_CONTROL', + 'SCENES_VIEW', + 'SCENES_ADD', + 'SCENES_UPDATE', + 'SCENES_DELETE', + 'SCENES_CONTROL', + 'VISITOR_PASSWORD_VIEW', + 'VISITOR_PASSWORD_ADD', + 'VISITOR_PASSWORD_UPDATE', + 'VISITOR_PASSWORD_DELETE', + 'USER_ADD', + 'SPACE_MEMBER_ADD', + ], + [RoleType.SPACE_MEMBER]: [ + 'DEVICE_SINGLE_CONTROL', + 'DEVICE_VIEW', + 'SPACE_VIEW', + 'SUBSPACE_VIEW', + 'DEVICE_WIZARD_VIEW_DEVICE_WIZARD', + 'SUBSPACE_DEVICE_VIEW_DEVICE_IN_SUBSPACE', + 'SPACE_DEVICE_VIEW_DEVICE_IN_SPACE', + 'AUTOMATION_VIEW', + 'AUTOMATION_CONTROL', + 'SCENES_VIEW', + 'SCENES_CONTROL', + ], + [RoleType.SPACE_OWNER]: [ + 'DEVICE_SINGLE_CONTROL', + 'DEVICE_VIEW', + 'DEVICE_DELETE', + 'DEVICE_UPDATE', + 'DEVICE_BATCH_CONTROL', + 'DEVICE_LOCATION_VIEW', + 'DEVICE_LOCATION_UPDATE', + 'FIRMWARE_CONTROL', + 'FIRMWARE_VIEW', + 'SPACE_VIEW', + 'SPACE_ADD', + 'SPACE_UPDATE', + 'SPACE_DELETE', + 'SPACE_ASSIGN_USER_TO_SPACE', + 'SPACE_DELETE_USER_FROM_SPACE', + 'SUBSPACE_VIEW', + 'SUBSPACE_ADD', + 'SUBSPACE_UPDATE', + 'SUBSPACE_DELETE', + 'SUBSPACE_ASSIGN_DEVICE_TO_SUBSPACE', + 'SUBSPACE_DELETE_DEVICE_FROM_SUBSPACE', + 'DEVICE_WIZARD_VIEW_DEVICE_WIZARD', + 'SUBSPACE_DEVICE_VIEW_DEVICE_IN_SUBSPACE', + 'SPACE_DEVICE_VIEW_DEVICE_IN_SPACE', + 'SUBSPACE_DEVICE_UPDATE_DEVICE_IN_SUBSPACE', + 'SPACE_DEVICE_ASSIGN_DEVICE_TO_SPACE', + 'AUTOMATION_VIEW', + 'AUTOMATION_ADD', + 'AUTOMATION_UPDATE', + 'AUTOMATION_DELETE', + 'AUTOMATION_CONTROL', + 'SCENES_VIEW', + 'SCENES_ADD', + 'SCENES_UPDATE', + 'SCENES_DELETE', + 'SCENES_CONTROL', + 'VISITOR_PASSWORD_VIEW', + 'VISITOR_PASSWORD_ADD', + 'VISITOR_PASSWORD_UPDATE', + 'VISITOR_PASSWORD_DELETE', + 'USER_ADD', + 'SPACE_MEMBER_ADD', + ], +}; diff --git a/libs/common/src/constants/role.type.enum.ts b/libs/common/src/constants/role.type.enum.ts index 3051b04..edaf2bf 100644 --- a/libs/common/src/constants/role.type.enum.ts +++ b/libs/common/src/constants/role.type.enum.ts @@ -1,4 +1,6 @@ export enum RoleType { SUPER_ADMIN = 'SUPER_ADMIN', ADMIN = 'ADMIN', + SPACE_OWNER = 'SPACE_OWNER', + SPACE_MEMBER = 'SPACE_MEMBER', } diff --git a/libs/common/src/constants/terms-and-conditions.html b/libs/common/src/constants/terms-and-conditions.html new file mode 100644 index 0000000..80bac71 --- /dev/null +++ b/libs/common/src/constants/terms-and-conditions.html @@ -0,0 +1,44 @@ +
+

User Agreement

+

Terms and Conditions

+

Last updated: {{lastUpdated}}

+

+ Please read these Terms and Conditions ("Terms", "Terms and Conditions") + carefully before using the + {{websiteUrl}} website and the {{mobileApp}} + mobile application (the "Service") operated by {{companyName}}. +

+

+ Your access to and use of the Service is conditioned on your acceptance of + and compliance with these Terms. These Terms apply to all visitors, users, + and others who access or use the Service. +

+

Content

+

+ Our Service allows you to post, link, store, share and otherwise make + available certain information, text, graphics, videos, or other material + ("Content"). You are responsible for the Content you post. +

+

Links To Other Websites

+

+ Our Service may contain links to third-party websites or services that are + not owned or controlled by {{companyName}}. +

+

+ {{companyName}} has no control over, and assumes no responsibility for, the + content, privacy policies, or practices of any third-party websites or + services. +

+

Changes

+

+ We reserve the right, at our sole discretion, to modify or replace these + Terms at any time. If a revision is material, we will try to provide at + least 30 days' notice prior to any new terms taking effect. What constitutes + a material change will be determined at our sole discretion. +

+

Contact Us

+

+ If you have any questions about these Terms, please + contact us. +

+
diff --git a/libs/common/src/constants/terms-condtions.ts b/libs/common/src/constants/terms-condtions.ts new file mode 100644 index 0000000..b0ba0af --- /dev/null +++ b/libs/common/src/constants/terms-condtions.ts @@ -0,0 +1,7 @@ +export const termsAndConditionsData = { + lastUpdated: '25/01/2025', + websiteUrl: 'https://www.Syncrow.ae', + mobileApp: 'Syncrow Mobile App', + companyName: 'Syncrow', + contactEmail: 'contact@Syncrow.ae', +}; diff --git a/libs/common/src/constants/user-status.enum.ts b/libs/common/src/constants/user-status.enum.ts new file mode 100644 index 0000000..859fb04 --- /dev/null +++ b/libs/common/src/constants/user-status.enum.ts @@ -0,0 +1,4 @@ +export enum UserStatusEnum { + ACTIVE = 'active', + INVITED = 'invited', +} diff --git a/libs/common/src/database/database.module.ts b/libs/common/src/database/database.module.ts index 8b4acf4..fae80e0 100644 --- a/libs/common/src/database/database.module.ts +++ b/libs/common/src/database/database.module.ts @@ -12,10 +12,10 @@ import { SpaceEntity, SpaceLinkEntity, SubspaceEntity, + TagEntity, } from '../modules/space/entities'; import { UserSpaceEntity } from '../modules/user/entities'; import { DeviceUserPermissionEntity } from '../modules/device/entities'; -import { UserRoleEntity } from '../modules/user/entities'; import { RoleTypeEntity } from '../modules/role-type/entities'; import { UserNotificationEntity } from '../modules/user/entities'; import { DeviceNotificationEntity } from '../modules/device/entities'; @@ -26,8 +26,18 @@ import { CommunityEntity } from '../modules/community/entities'; import { DeviceStatusLogEntity } from '../modules/device-status-log/entities'; import { SceneEntity, SceneIconEntity } from '../modules/scene/entities'; import { SceneDeviceEntity } from '../modules/scene-device/entities'; -import { SpaceProductEntity } from '../modules/space/entities/space-product.entity'; - +import { ProjectEntity } from '../modules/project/entities'; +import { + SpaceModelEntity, + SubspaceModelEntity, + TagModel, +} from '../modules/space-model/entities'; +import { + InviteUserEntity, + InviteUserSpaceEntity, +} from '../modules/Invite-user/entities'; +import { InviteSpaceEntity } from '../modules/space/entities/invite-space.entity'; +import { AutomationEntity } from '../modules/automation/entities'; @Module({ imports: [ TypeOrmModule.forRootAsync({ @@ -42,6 +52,7 @@ import { SpaceProductEntity } from '../modules/space/entities/space-product.enti password: configService.get('DB_PASSWORD'), database: configService.get('DB_NAME'), entities: [ + ProjectEntity, UserEntity, UserSessionEntity, UserOtpEntity, @@ -53,10 +64,9 @@ import { SpaceProductEntity } from '../modules/space/entities/space-product.enti SpaceEntity, SpaceLinkEntity, SubspaceEntity, - SpaceProductEntity, + TagEntity, UserSpaceEntity, DeviceUserPermissionEntity, - UserRoleEntity, RoleTypeEntity, UserNotificationEntity, DeviceNotificationEntity, @@ -67,6 +77,13 @@ import { SpaceProductEntity } from '../modules/space/entities/space-product.enti SceneEntity, SceneIconEntity, SceneDeviceEntity, + SpaceModelEntity, + SubspaceModelEntity, + TagModel, + InviteUserEntity, + InviteUserSpaceEntity, + InviteSpaceEntity, + AutomationEntity, ], namingStrategy: new SnakeNamingStrategy(), synchronize: Boolean(JSON.parse(configService.get('DB_SYNC'))), diff --git a/libs/common/src/firebase/devices-status/services/devices-status.service.ts b/libs/common/src/firebase/devices-status/services/devices-status.service.ts index 162313b..fbbc00c 100644 --- a/libs/common/src/firebase/devices-status/services/devices-status.service.ts +++ b/libs/common/src/firebase/devices-status/services/devices-status.service.ts @@ -187,19 +187,19 @@ export class DeviceStatusFirebaseService { code, value, })); - const newLog = this.deviceStatusLogRepository.create({ - deviceId: addDeviceStatusDto.deviceUuid, - deviceTuyaId: addDeviceStatusDto.deviceTuyaUuid, - productId: addDeviceStatusDto.log.productId, - log: addDeviceStatusDto.log, - code: existingData.status[0].code, - value: existingData.status[0].value, - eventId: addDeviceStatusDto.log.dataId, - eventTime: new Date( - addDeviceStatusDto.log.properties[0].time, - ).toISOString(), + const newLogs = addDeviceStatusDto.log.properties.map((property) => { + return this.deviceStatusLogRepository.create({ + deviceId: addDeviceStatusDto.deviceUuid, + deviceTuyaId: addDeviceStatusDto.deviceTuyaUuid, + productId: addDeviceStatusDto.log.productId, + log: addDeviceStatusDto.log, + code: property.code, + value: property.value, + eventId: addDeviceStatusDto.log.dataId, + eventTime: new Date(property.time).toISOString(), + }); }); - await this.deviceStatusLogRepository.save(newLog); + await this.deviceStatusLogRepository.save(newLogs); // Save the updated data to Firebase await set(dataRef, existingData); diff --git a/libs/common/src/helper/removeCircularReferences.ts b/libs/common/src/helper/removeCircularReferences.ts new file mode 100644 index 0000000..87eae74 --- /dev/null +++ b/libs/common/src/helper/removeCircularReferences.ts @@ -0,0 +1,12 @@ +export function removeCircularReferences() { + const seen = new WeakSet(); + return (key: string, value: any) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return undefined; // Skip circular reference + } + seen.add(value); + } + return value; + }; +} diff --git a/libs/common/src/integrations/tuya/services/tuya.service.ts b/libs/common/src/integrations/tuya/services/tuya.service.ts index e9c937f..67a4eff 100644 --- a/libs/common/src/integrations/tuya/services/tuya.service.ts +++ b/libs/common/src/integrations/tuya/services/tuya.service.ts @@ -6,6 +6,7 @@ import { ConvertedAction, TuyaResponseInterface, } from '../interfaces'; +import { GetDeviceDetailsFunctionsStatusInterface } from 'src/device/interfaces/get.device.interface'; @Injectable() export class TuyaService { @@ -284,4 +285,24 @@ export class TuyaService { ); } } + async getDevicesInstructionStatusTuya( + deviceUuid: string, + ): Promise { + try { + const path = `/v1.0/iot-03/devices/status`; + const response = await this.tuya.request({ + method: 'GET', + path, + query: { + device_ids: deviceUuid, + }, + }); + return response as GetDeviceDetailsFunctionsStatusInterface; + } catch (error) { + throw new HttpException( + 'Error fetching device functions status from Tuya', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } } diff --git a/libs/common/src/models/typeOrmCustom.model.ts b/libs/common/src/models/typeOrmCustom.model.ts index 0f3abbc..043b13c 100644 --- a/libs/common/src/models/typeOrmCustom.model.ts +++ b/libs/common/src/models/typeOrmCustom.model.ts @@ -1,4 +1,4 @@ -import { FindManyOptions, Repository } from 'typeorm'; +import { FindManyOptions, Repository, SelectQueryBuilder } from 'typeorm'; import { InternalServerErrorException } from '@nestjs/common'; import { BaseResponseDto } from '../dto/base.response.dto'; import { PageResponseDto } from '../dto/pagination.response.dto'; @@ -31,7 +31,7 @@ interface FindAllQueryWithDefaults extends CustomFindAllQuery { function getDefaultQueryOptions( query: Partial, ): FindManyOptions & FindAllQueryWithDefaults { - const { page, size, includeDisable, modelName, ...rest } = query; + const { page, size, includeDisable, include, modelName, ...rest } = query; // Set default if undefined or null const returnPage = page ? Number(page) : 1; @@ -48,7 +48,9 @@ function getDefaultQueryOptions( }, page: returnPage, size: returnSize, + include: include || undefined, includeDisable: returnIncludeDisable, + modelName: modelName || query.modelName, // Ensure modelName is passed through }; } @@ -68,8 +70,8 @@ export function TypeORMCustomModel(repository: Repository) { return Object.assign(repository, { findAll: async function ( query: Partial, + customQueryBuilder?: SelectQueryBuilder, ): Promise { - // Extract values from the query const { page = 1, size = 10, @@ -80,12 +82,7 @@ export function TypeORMCustomModel(repository: Repository) { select, } = getDefaultQueryOptions(query); - // Ensure modelName is set before proceeding if (!modelName) { - console.error( - 'modelName is missing after getDefaultQueryOptions:', - query, - ); throw new InternalServerErrorException( `[TypeORMCustomModel] Cannot findAll with unknown modelName`, ); @@ -94,20 +91,44 @@ export function TypeORMCustomModel(repository: Repository) { const skip = (page - 1) * size; const order = buildTypeORMSortQuery(sort); const relations = buildTypeORMIncludeQuery(modelName, include); - - // Use the where clause directly, without wrapping it under 'where' const whereClause = buildTypeORMWhereClause({ where }); - console.log('Final where clause:', whereClause); - // Ensure the whereClause is passed directly to findAndCount - const [data, count] = await repository.findAndCount({ - where: whereClause, - take: size, - skip: skip, - order: order, - select: select, - relations: relations, - }); + let data: any[] = []; + let count = 0; + + if (customQueryBuilder) { + const qb = customQueryBuilder.skip(skip).take(size); + + if (order) { + Object.keys(order).forEach((key) => { + qb.addOrderBy(key, order[key]); + }); + } + + if (whereClause) { + qb.andWhere(whereClause); // Use .andWhere instead of .where to avoid overwriting conditions + } + + if (select) { + const selectColumns = Array.isArray(select) + ? select + : Object.keys(select).map( + (key) => `${customQueryBuilder.alias}.${key}`, + ); + qb.select(selectColumns as string[]); + } + + [data, count] = await qb.getManyAndCount(); + } else { + [data, count] = await repository.findAndCount({ + where: whereClause, + take: size, + skip: skip, + order: order, + select: select, + relations: relations, + }); + } const paginationResponseDto = getPaginationResponseDto(count, page, size); const baseResponseDto: BaseResponseDto = { diff --git a/libs/common/src/modules/Invite-user/Invite-user.repository.module.ts b/libs/common/src/modules/Invite-user/Invite-user.repository.module.ts new file mode 100644 index 0000000..4cc8596 --- /dev/null +++ b/libs/common/src/modules/Invite-user/Invite-user.repository.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { InviteUserEntity, InviteUserSpaceEntity } from './entities'; + +@Module({ + providers: [], + exports: [], + controllers: [], + imports: [ + TypeOrmModule.forFeature([InviteUserEntity, InviteUserSpaceEntity]), + ], +}) +export class InviteUserRepositoryModule {} diff --git a/libs/common/src/modules/Invite-user/dtos/Invite-user.dto.ts b/libs/common/src/modules/Invite-user/dtos/Invite-user.dto.ts new file mode 100644 index 0000000..50e826c --- /dev/null +++ b/libs/common/src/modules/Invite-user/dtos/Invite-user.dto.ts @@ -0,0 +1,50 @@ +import { RoleType } from '@app/common/constants/role.type.enum'; +import { UserStatusEnum } from '@app/common/constants/user-status.enum'; +import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; + +export class InviteUserDto { + @IsString() + @IsNotEmpty() + public uuid: string; + + @IsString() + @IsNotEmpty() + public email: string; + + @IsString() + @IsNotEmpty() + public jobTitle: string; + + @IsEnum(UserStatusEnum) + @IsNotEmpty() + public status: UserStatusEnum; + + @IsString() + @IsNotEmpty() + public firstName: string; + + @IsString() + @IsNotEmpty() + public lastName: string; + + @IsEnum(RoleType) + @IsNotEmpty() + public invitedBy: RoleType; +} +export class InviteUserSpaceDto { + @IsString() + @IsNotEmpty() + public uuid: string; + + @IsString() + @IsNotEmpty() + public inviteUserUuid: string; + + @IsString() + @IsNotEmpty() + public spaceUuid: string; + + @IsString() + @IsNotEmpty() + public invitationCode: string; +} diff --git a/libs/common/src/modules/Invite-user/dtos/index.ts b/libs/common/src/modules/Invite-user/dtos/index.ts new file mode 100644 index 0000000..2385037 --- /dev/null +++ b/libs/common/src/modules/Invite-user/dtos/index.ts @@ -0,0 +1 @@ +export * from './Invite-user.dto'; diff --git a/libs/common/src/modules/Invite-user/entities/Invite-user.entity.ts b/libs/common/src/modules/Invite-user/entities/Invite-user.entity.ts new file mode 100644 index 0000000..04e5119 --- /dev/null +++ b/libs/common/src/modules/Invite-user/entities/Invite-user.entity.ts @@ -0,0 +1,126 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + OneToOne, + Unique, +} from 'typeorm'; + +import { AbstractEntity } from '../../abstract/entities/abstract.entity'; +import { RoleTypeEntity } from '../../role-type/entities'; +import { UserStatusEnum } from '@app/common/constants/user-status.enum'; +import { UserEntity } from '../../user/entities'; +import { SpaceEntity } from '../../space/entities'; +import { RoleType } from '@app/common/constants/role.type.enum'; +import { InviteUserDto, InviteUserSpaceDto } from '../dtos'; +import { ProjectEntity } from '../../project/entities'; + +@Entity({ name: 'invite-user' }) +@Unique(['email', 'project']) +export class InviteUserEntity extends AbstractEntity { + @Column({ + type: 'uuid', + default: () => 'gen_random_uuid()', + nullable: false, + }) + public uuid: string; + + @Column({ + nullable: false, + }) + email: string; + + @Column({ + nullable: true, + }) + jobTitle: string; + + @Column({ + nullable: false, + enum: Object.values(UserStatusEnum), + }) + status: string; + + @Column() + public firstName: string; + + @Column({ + nullable: false, + }) + public lastName: string; + @Column({ + nullable: true, + }) + public phoneNumber: string; + + @Column({ + nullable: false, + default: true, + }) + public isActive: boolean; + @Column({ + nullable: false, + default: true, + }) + public isEnabled: boolean; + @Column({ + nullable: false, + unique: true, + }) + public invitationCode: string; + + @Column({ + nullable: false, + enum: Object.values(RoleType), + }) + public invitedBy: string; + + @ManyToOne(() => RoleTypeEntity, (roleType) => roleType.invitedUsers, { + nullable: false, + onDelete: 'CASCADE', + }) + public roleType: RoleTypeEntity; + @OneToOne(() => UserEntity, (user) => user.inviteUser, { nullable: true }) + @JoinColumn({ name: 'user_uuid' }) + user: UserEntity; + @OneToMany( + () => InviteUserSpaceEntity, + (inviteUserSpace) => inviteUserSpace.inviteUser, + ) + spaces: InviteUserSpaceEntity[]; + + @ManyToOne(() => ProjectEntity, (project) => project.invitedUsers, { + nullable: true, + }) + @JoinColumn({ name: 'project_uuid' }) + public project: ProjectEntity; + + constructor(partial: Partial) { + super(); + Object.assign(this, partial); + } +} +@Entity({ name: 'invite-user-space' }) +@Unique(['inviteUser', 'space']) +export class InviteUserSpaceEntity extends AbstractEntity { + @Column({ + type: 'uuid', + default: () => 'gen_random_uuid()', + nullable: false, + }) + public uuid: string; + + @ManyToOne(() => InviteUserEntity, (inviteUser) => inviteUser.spaces) + @JoinColumn({ name: 'invite_user_uuid' }) + public inviteUser: InviteUserEntity; + + @ManyToOne(() => SpaceEntity, (space) => space.invitedUsers) + @JoinColumn({ name: 'space_uuid' }) + public space: SpaceEntity; + constructor(partial: Partial) { + super(); + Object.assign(this, partial); + } +} diff --git a/libs/common/src/modules/Invite-user/entities/index.ts b/libs/common/src/modules/Invite-user/entities/index.ts new file mode 100644 index 0000000..3f0da22 --- /dev/null +++ b/libs/common/src/modules/Invite-user/entities/index.ts @@ -0,0 +1 @@ +export * from './Invite-user.entity'; diff --git a/libs/common/src/modules/Invite-user/index.ts b/libs/common/src/modules/Invite-user/index.ts new file mode 100644 index 0000000..5fbc01a --- /dev/null +++ b/libs/common/src/modules/Invite-user/index.ts @@ -0,0 +1 @@ +export * from './Invite-user.repository.module'; diff --git a/libs/common/src/modules/Invite-user/repositiories/index.ts b/libs/common/src/modules/Invite-user/repositiories/index.ts new file mode 100644 index 0000000..6f86fa1 --- /dev/null +++ b/libs/common/src/modules/Invite-user/repositiories/index.ts @@ -0,0 +1 @@ +export * from './invite-user.repository'; diff --git a/libs/common/src/modules/Invite-user/repositiories/invite-user.repository.ts b/libs/common/src/modules/Invite-user/repositiories/invite-user.repository.ts new file mode 100644 index 0000000..463059a --- /dev/null +++ b/libs/common/src/modules/Invite-user/repositiories/invite-user.repository.ts @@ -0,0 +1,16 @@ +import { DataSource, Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { InviteUserEntity, InviteUserSpaceEntity } from '../entities'; + +@Injectable() +export class InviteUserRepository extends Repository { + constructor(private dataSource: DataSource) { + super(InviteUserEntity, dataSource.createEntityManager()); + } +} +@Injectable() +export class InviteUserSpaceRepository extends Repository { + constructor(private dataSource: DataSource) { + super(InviteUserSpaceEntity, dataSource.createEntityManager()); + } +} diff --git a/libs/common/src/modules/automation/automation.repository.module.ts b/libs/common/src/modules/automation/automation.repository.module.ts new file mode 100644 index 0000000..50c998e --- /dev/null +++ b/libs/common/src/modules/automation/automation.repository.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AutomationEntity } from './entities'; + +@Module({ + providers: [], + exports: [], + controllers: [], + imports: [TypeOrmModule.forFeature([AutomationEntity])], +}) +export class AutomationRepositoryModule {} diff --git a/libs/common/src/modules/automation/dtos/automation.dto.ts b/libs/common/src/modules/automation/dtos/automation.dto.ts new file mode 100644 index 0000000..8a0c495 --- /dev/null +++ b/libs/common/src/modules/automation/dtos/automation.dto.ts @@ -0,0 +1,14 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class AutomationDto { + @IsString() + @IsNotEmpty() + public uuid: string; + + @IsString() + @IsNotEmpty() + public automationTuyaUuid: string; + @IsString() + @IsNotEmpty() + public spaceUuid: string; +} diff --git a/libs/common/src/modules/automation/dtos/index.ts b/libs/common/src/modules/automation/dtos/index.ts new file mode 100644 index 0000000..4cdad58 --- /dev/null +++ b/libs/common/src/modules/automation/dtos/index.ts @@ -0,0 +1 @@ +export * from './automation.dto'; diff --git a/libs/common/src/modules/automation/entities/automation.entity.ts b/libs/common/src/modules/automation/entities/automation.entity.ts new file mode 100644 index 0000000..34f77cd --- /dev/null +++ b/libs/common/src/modules/automation/entities/automation.entity.ts @@ -0,0 +1,37 @@ +import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; +import { AutomationDto } from '../dtos'; +import { AbstractEntity } from '../../abstract/entities/abstract.entity'; +import { SpaceEntity } from '../../space/entities'; + +@Entity({ name: 'automation' }) +export class AutomationEntity extends AbstractEntity { + @Column({ + type: 'uuid', + default: () => 'gen_random_uuid()', + nullable: false, + }) + public uuid: string; + + @Column({ + nullable: false, + }) + automationTuyaUuid: string; + + @ManyToOne(() => SpaceEntity, (space) => space.scenes, { + nullable: false, + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'space_uuid' }) + space: SpaceEntity; + + @Column({ + nullable: false, + default: false, + }) + public disabled: boolean; + + constructor(partial: Partial) { + super(); + Object.assign(this, partial); + } +} diff --git a/libs/common/src/modules/automation/entities/index.ts b/libs/common/src/modules/automation/entities/index.ts new file mode 100644 index 0000000..6c6c641 --- /dev/null +++ b/libs/common/src/modules/automation/entities/index.ts @@ -0,0 +1 @@ +export * from './automation.entity'; diff --git a/libs/common/src/modules/automation/repositories/automation.repository.ts b/libs/common/src/modules/automation/repositories/automation.repository.ts new file mode 100644 index 0000000..d75dd75 --- /dev/null +++ b/libs/common/src/modules/automation/repositories/automation.repository.ts @@ -0,0 +1,10 @@ +import { DataSource, Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { AutomationEntity } from '../entities'; + +@Injectable() +export class AutomationRepository extends Repository { + constructor(private dataSource: DataSource) { + super(AutomationEntity, dataSource.createEntityManager()); + } +} diff --git a/libs/common/src/modules/automation/repositories/index.ts b/libs/common/src/modules/automation/repositories/index.ts new file mode 100644 index 0000000..de055c3 --- /dev/null +++ b/libs/common/src/modules/automation/repositories/index.ts @@ -0,0 +1 @@ +export * from './automation.repository'; diff --git a/libs/common/src/modules/community/entities/community.entity.ts b/libs/common/src/modules/community/entities/community.entity.ts index 3b6f122..a94e586 100644 --- a/libs/common/src/modules/community/entities/community.entity.ts +++ b/libs/common/src/modules/community/entities/community.entity.ts @@ -1,7 +1,8 @@ -import { Column, Entity, OneToMany, Unique } from 'typeorm'; +import { Column, Entity, ManyToOne, OneToMany, Unique } from 'typeorm'; import { AbstractEntity } from '../../abstract/entities/abstract.entity'; import { CommunityDto } from '../dtos'; import { SpaceEntity } from '../../space/entities'; +import { ProjectEntity } from '../../project/entities'; @Entity({ name: 'community' }) @Unique(['name']) @@ -31,4 +32,9 @@ export class CommunityEntity extends AbstractEntity { nullable: true, }) externalId: string; + + @ManyToOne(() => ProjectEntity, (project) => project.communities, { + nullable: false, + }) + project: ProjectEntity; } diff --git a/libs/common/src/modules/device-status-log/entities/device-status-log.entity.ts b/libs/common/src/modules/device-status-log/entities/device-status-log.entity.ts index a7a998a..b40c393 100644 --- a/libs/common/src/modules/device-status-log/entities/device-status-log.entity.ts +++ b/libs/common/src/modules/device-status-log/entities/device-status-log.entity.ts @@ -20,7 +20,7 @@ export class DeviceStatusLogEntity { }) eventFrom: SourceType; - @Column({ type: 'text' }) + @Column({ type: 'uuid' }) deviceId: string; @Column({ type: 'text' }) diff --git a/libs/common/src/modules/device/entities/device.entity.ts b/libs/common/src/modules/device/entities/device.entity.ts index 9a75950..bdd1ce5 100644 --- a/libs/common/src/modules/device/entities/device.entity.ts +++ b/libs/common/src/modules/device/entities/device.entity.ts @@ -6,10 +6,11 @@ import { Unique, Index, JoinColumn, + OneToOne, } from 'typeorm'; import { AbstractEntity } from '../../abstract/entities/abstract.entity'; import { DeviceDto, DeviceUserPermissionDto } from '../dtos/device.dto'; -import { SpaceEntity, SubspaceEntity } from '../../space/entities'; +import { SpaceEntity, SubspaceEntity, TagEntity } from '../../space/entities'; import { ProductEntity } from '../../product/entities'; import { UserEntity } from '../../user/entities'; import { DeviceNotificationDto } from '../dtos'; @@ -74,6 +75,11 @@ export class DeviceEntity extends AbstractEntity { @OneToMany(() => SceneDeviceEntity, (sceneDevice) => sceneDevice.device, {}) sceneDevices: SceneDeviceEntity[]; + @OneToOne(() => TagEntity, (tag) => tag.device, { + nullable: true, + }) + tag: TagEntity; + constructor(partial: Partial) { super(); Object.assign(this, partial); @@ -102,6 +108,7 @@ export class DeviceNotificationEntity extends AbstractEntity) { super(); Object.assign(this, partial); diff --git a/libs/common/src/modules/product/entities/product.entity.ts b/libs/common/src/modules/product/entities/product.entity.ts index 2c731a0..dd7a1e5 100644 --- a/libs/common/src/modules/product/entities/product.entity.ts +++ b/libs/common/src/modules/product/entities/product.entity.ts @@ -2,8 +2,8 @@ import { Column, Entity, OneToMany } from 'typeorm'; import { ProductDto } from '../dtos'; import { AbstractEntity } from '../../abstract/entities/abstract.entity'; import { DeviceEntity } from '../../device/entities'; -import { SpaceProductEntity } from '../../space/entities/space-product.entity'; - +import { TagModel } from '../../space-model'; +import { TagEntity } from '../../space/entities/tag.entity'; @Entity({ name: 'product' }) export class ProductEntity extends AbstractEntity { @Column({ @@ -27,8 +27,11 @@ export class ProductEntity extends AbstractEntity { }) public prodType: string; - @OneToMany(() => SpaceProductEntity, (spaceProduct) => spaceProduct.product) - spaceProducts: SpaceProductEntity[]; + @OneToMany(() => TagModel, (tag) => tag.product) + tagModels: TagModel[]; + + @OneToMany(() => TagEntity, (tag) => tag.product) + tags: TagEntity[]; @OneToMany( () => DeviceEntity, diff --git a/libs/common/src/modules/project/dtos/index.ts b/libs/common/src/modules/project/dtos/index.ts new file mode 100644 index 0000000..9d264cb --- /dev/null +++ b/libs/common/src/modules/project/dtos/index.ts @@ -0,0 +1 @@ +export * from './project.dto'; diff --git a/libs/common/src/modules/project/dtos/project.dto.ts b/libs/common/src/modules/project/dtos/project.dto.ts new file mode 100644 index 0000000..ef08e55 --- /dev/null +++ b/libs/common/src/modules/project/dtos/project.dto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class ProjectDto { + @IsString() + @IsNotEmpty() + uuid: string; + + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsOptional() + public description?: string; +} diff --git a/libs/common/src/modules/project/entities/index.ts b/libs/common/src/modules/project/entities/index.ts new file mode 100644 index 0000000..bd8d161 --- /dev/null +++ b/libs/common/src/modules/project/entities/index.ts @@ -0,0 +1 @@ +export * from './project.entity'; diff --git a/libs/common/src/modules/project/entities/project.entity.ts b/libs/common/src/modules/project/entities/project.entity.ts new file mode 100644 index 0000000..ee6a2c5 --- /dev/null +++ b/libs/common/src/modules/project/entities/project.entity.ts @@ -0,0 +1,43 @@ +import { Entity, Column, Unique, OneToMany } from 'typeorm'; +import { AbstractEntity } from '../../abstract/entities/abstract.entity'; +import { ProjectDto } from '../dtos'; +import { CommunityEntity } from '../../community/entities'; +import { SpaceModelEntity } from '../../space-model'; +import { UserEntity } from '../../user/entities'; +import { InviteUserEntity } from '../../Invite-user/entities'; + +@Entity({ name: 'project' }) +@Unique(['name']) +export class ProjectEntity extends AbstractEntity { + @Column({ + type: 'uuid', + default: () => 'gen_random_uuid()', + nullable: false, + }) + public uuid: string; + + @Column({ + nullable: false, + }) + public name: string; + + @Column({ length: 255, nullable: true }) + description: string; + + @OneToMany(() => SpaceModelEntity, (spaceModel) => spaceModel.project) + public spaceModels: SpaceModelEntity[]; + + @OneToMany(() => CommunityEntity, (community) => community.project) + communities: CommunityEntity[]; + + @OneToMany(() => UserEntity, (user) => user.project) + public users: UserEntity[]; + + @OneToMany(() => InviteUserEntity, (inviteUser) => inviteUser.project) + public invitedUsers: InviteUserEntity[]; + + constructor(partial: Partial) { + super(); + Object.assign(this, partial); + } +} diff --git a/libs/common/src/modules/project/index.ts b/libs/common/src/modules/project/index.ts new file mode 100644 index 0000000..cacdb88 --- /dev/null +++ b/libs/common/src/modules/project/index.ts @@ -0,0 +1 @@ +export * from './project.repository.module'; diff --git a/libs/common/src/modules/project/project.repository.module.ts b/libs/common/src/modules/project/project.repository.module.ts new file mode 100644 index 0000000..d067af1 --- /dev/null +++ b/libs/common/src/modules/project/project.repository.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ProjectEntity } from './entities'; + +@Module({ + providers: [], + exports: [], + controllers: [], + imports: [TypeOrmModule.forFeature([ProjectEntity])], +}) +export class ProjectRepositoryModule {} diff --git a/libs/common/src/modules/project/repositiories/index.ts b/libs/common/src/modules/project/repositiories/index.ts new file mode 100644 index 0000000..3e35c16 --- /dev/null +++ b/libs/common/src/modules/project/repositiories/index.ts @@ -0,0 +1 @@ +export * from './project.repository'; diff --git a/libs/common/src/modules/project/repositiories/project.repository.ts b/libs/common/src/modules/project/repositiories/project.repository.ts new file mode 100644 index 0000000..b4602e7 --- /dev/null +++ b/libs/common/src/modules/project/repositiories/project.repository.ts @@ -0,0 +1,10 @@ +import { DataSource, Repository } from 'typeorm'; +import { ProjectEntity } from '../entities'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ProjectRepository extends Repository { + constructor(private dataSource: DataSource) { + super(ProjectEntity, dataSource.createEntityManager()); + } +} diff --git a/libs/common/src/modules/role-type/entities/role.type.entity.ts b/libs/common/src/modules/role-type/entities/role.type.entity.ts index 10f30bd..b7289a3 100644 --- a/libs/common/src/modules/role-type/entities/role.type.entity.ts +++ b/libs/common/src/modules/role-type/entities/role.type.entity.ts @@ -2,7 +2,8 @@ import { Column, Entity, OneToMany, Unique } from 'typeorm'; import { AbstractEntity } from '../../abstract/entities/abstract.entity'; import { RoleTypeDto } from '../dtos/role.type.dto'; import { RoleType } from '@app/common/constants/role.type.enum'; -import { UserRoleEntity } from '../../user/entities'; +import { UserEntity } from '../../user/entities'; +import { InviteUserEntity } from '../../Invite-user/entities'; @Entity({ name: 'role-type' }) @Unique(['type']) @@ -12,10 +13,14 @@ export class RoleTypeEntity extends AbstractEntity { enum: Object.values(RoleType), }) type: string; - @OneToMany(() => UserRoleEntity, (role) => role.roleType, { + @OneToMany(() => UserEntity, (inviteUser) => inviteUser.roleType, { nullable: true, }) - roles: UserRoleEntity[]; + users: UserEntity[]; + @OneToMany(() => InviteUserEntity, (inviteUser) => inviteUser.roleType, { + nullable: true, + }) + invitedUsers: InviteUserEntity[]; constructor(partial: Partial) { super(); Object.assign(this, partial); diff --git a/libs/common/src/modules/scene-device/entities/scene-device.entity.ts b/libs/common/src/modules/scene-device/entities/scene-device.entity.ts index 9814fdf..a5f1fb5 100644 --- a/libs/common/src/modules/scene-device/entities/scene-device.entity.ts +++ b/libs/common/src/modules/scene-device/entities/scene-device.entity.ts @@ -44,6 +44,12 @@ export class SceneDeviceEntity extends AbstractEntity { @JoinColumn({ name: 'scene_uuid' }) scene: SceneEntity; + @Column({ + nullable: false, + default: false, + }) + public disabled: boolean; + constructor(partial: Partial) { super(); Object.assign(this, partial); diff --git a/libs/common/src/modules/scene/entities/scene.entity.ts b/libs/common/src/modules/scene/entities/scene.entity.ts index 5daa690..86b1beb 100644 --- a/libs/common/src/modules/scene/entities/scene.entity.ts +++ b/libs/common/src/modules/scene/entities/scene.entity.ts @@ -59,6 +59,12 @@ export class SceneEntity extends AbstractEntity { @JoinColumn({ name: 'space_uuid' }) space: SpaceEntity; + @Column({ + nullable: false, + default: false, + }) + public disabled: boolean; + @ManyToOne(() => SceneIconEntity, (icon) => icon.scenesIconEntity, { nullable: false, }) diff --git a/libs/common/src/modules/space-model/dtos/index.ts b/libs/common/src/modules/space-model/dtos/index.ts new file mode 100644 index 0000000..242564d --- /dev/null +++ b/libs/common/src/modules/space-model/dtos/index.ts @@ -0,0 +1,2 @@ +export * from './subspace-model'; +export * from './space-model.dto'; diff --git a/libs/common/src/modules/space-model/dtos/space-model.dto.ts b/libs/common/src/modules/space-model/dtos/space-model.dto.ts new file mode 100644 index 0000000..f4d7cbf --- /dev/null +++ b/libs/common/src/modules/space-model/dtos/space-model.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsNotEmpty } from 'class-validator'; + +export class SpaceModelDto { + @IsString() + @IsNotEmpty() + public uuid: string; + + @IsString() + @IsNotEmpty() + public spaceModelName: string; + + @IsString() + @IsNotEmpty() + projectUuid: string; +} diff --git a/libs/common/src/modules/space-model/dtos/subspace-model/index.ts b/libs/common/src/modules/space-model/dtos/subspace-model/index.ts new file mode 100644 index 0000000..fb0ac5a --- /dev/null +++ b/libs/common/src/modules/space-model/dtos/subspace-model/index.ts @@ -0,0 +1 @@ +export * from './subspace-model.dto'; diff --git a/libs/common/src/modules/space-model/dtos/subspace-model/subspace-model.dto.ts b/libs/common/src/modules/space-model/dtos/subspace-model/subspace-model.dto.ts new file mode 100644 index 0000000..8c9a64c --- /dev/null +++ b/libs/common/src/modules/space-model/dtos/subspace-model/subspace-model.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsNotEmpty } from 'class-validator'; + +export class SubSpaceModelDto { + @IsString() + @IsNotEmpty() + public uuid: string; + + @IsString() + @IsNotEmpty() + public subSpaceModelName: string; + + @IsString() + @IsNotEmpty() + spaceModelUuid: string; +} diff --git a/libs/common/src/modules/space-model/dtos/tag-model.dto.ts b/libs/common/src/modules/space-model/dtos/tag-model.dto.ts new file mode 100644 index 0000000..f98f160 --- /dev/null +++ b/libs/common/src/modules/space-model/dtos/tag-model.dto.ts @@ -0,0 +1,21 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class TagModelDto { + @IsString() + @IsNotEmpty() + public uuid: string; + + @IsString() + @IsNotEmpty() + public name: string; + + @IsString() + @IsNotEmpty() + public productUuid: string; + + @IsString() + spaceModelUuid: string; + + @IsString() + subspaceModelUuid: string; +} diff --git a/libs/common/src/modules/space-model/entities/index.ts b/libs/common/src/modules/space-model/entities/index.ts new file mode 100644 index 0000000..f4fffbd --- /dev/null +++ b/libs/common/src/modules/space-model/entities/index.ts @@ -0,0 +1,3 @@ +export * from './space-model.entity'; +export * from './subspace-model'; +export * from './tag-model.entity'; diff --git a/libs/common/src/modules/space-model/entities/space-model.entity.ts b/libs/common/src/modules/space-model/entities/space-model.entity.ts new file mode 100644 index 0000000..648e90d --- /dev/null +++ b/libs/common/src/modules/space-model/entities/space-model.entity.ts @@ -0,0 +1,58 @@ +import { Entity, Column, OneToMany, ManyToOne, JoinColumn } from 'typeorm'; +import { AbstractEntity } from '../../abstract/entities/abstract.entity'; +import { SpaceModelDto } from '../dtos'; +import { SubspaceModelEntity } from './subspace-model'; +import { ProjectEntity } from '../../project/entities'; +import { SpaceEntity } from '../../space/entities'; +import { TagModel } from './tag-model.entity'; + +@Entity({ name: 'space-model' }) +export class SpaceModelEntity extends AbstractEntity { + @Column({ + type: 'uuid', + default: () => 'gen_random_uuid()', + nullable: false, + }) + public uuid: string; + + @Column({ + nullable: false, + }) + public modelName: string; + + @Column({ + nullable: false, + default: false, + }) + public disabled: boolean; + + @ManyToOne(() => ProjectEntity, (project) => project.spaceModels, { + nullable: false, + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'project_uuid' }) + public project: ProjectEntity; + + @OneToMany( + () => SubspaceModelEntity, + (subspaceModel) => subspaceModel.spaceModel, + { + cascade: true, + nullable: true, + }, + ) + public subspaceModels: SubspaceModelEntity[]; + + @OneToMany(() => SpaceEntity, (space) => space.spaceModel, { + cascade: true, + }) + public spaces: SpaceEntity[]; + + @OneToMany(() => TagModel, (tag) => tag.spaceModel) + tags: TagModel[]; + + constructor(partial: Partial) { + super(); + Object.assign(this, partial); + } +} diff --git a/libs/common/src/modules/space-model/entities/subspace-model/index.ts b/libs/common/src/modules/space-model/entities/subspace-model/index.ts new file mode 100644 index 0000000..262490e --- /dev/null +++ b/libs/common/src/modules/space-model/entities/subspace-model/index.ts @@ -0,0 +1,2 @@ +export * from './subspace-model.entity'; + \ No newline at end of file diff --git a/libs/common/src/modules/space-model/entities/subspace-model/subspace-model.entity.ts b/libs/common/src/modules/space-model/entities/subspace-model/subspace-model.entity.ts new file mode 100644 index 0000000..f5f6377 --- /dev/null +++ b/libs/common/src/modules/space-model/entities/subspace-model/subspace-model.entity.ts @@ -0,0 +1,45 @@ +import { AbstractEntity } from '@app/common/modules/abstract/entities/abstract.entity'; +import { Column, Entity, ManyToOne, OneToMany, Unique } from 'typeorm'; +import { SubSpaceModelDto } from '../../dtos'; +import { SpaceModelEntity } from '../space-model.entity'; +import { SubspaceEntity } from '@app/common/modules/space/entities'; +import { TagModel } from '../tag-model.entity'; + +@Entity({ name: 'subspace-model' }) +export class SubspaceModelEntity extends AbstractEntity { + @Column({ + type: 'uuid', + default: () => 'gen_random_uuid()', + nullable: false, + }) + public uuid: string; + + @Column({ + nullable: false, + }) + public subspaceName: string; + + @ManyToOne( + () => SpaceModelEntity, + (spaceModel) => spaceModel.subspaceModels, + { + nullable: false, + onDelete: 'CASCADE', + }, + ) + public spaceModel: SpaceModelEntity; + + @OneToMany(() => SubspaceEntity, (subspace) => subspace.subSpaceModel, { + cascade: true, + }) + public subspaceModel: SubspaceEntity[]; + + @Column({ + nullable: false, + default: false, + }) + public disabled: boolean; + + @OneToMany(() => TagModel, (tag) => tag.subspaceModel) + tags: TagModel[]; +} diff --git a/libs/common/src/modules/space-model/entities/tag-model.entity.ts b/libs/common/src/modules/space-model/entities/tag-model.entity.ts new file mode 100644 index 0000000..e2a70ee --- /dev/null +++ b/libs/common/src/modules/space-model/entities/tag-model.entity.ts @@ -0,0 +1,38 @@ +import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { AbstractEntity } from '../../abstract/entities/abstract.entity'; +import { TagModelDto } from '../dtos/tag-model.dto'; +import { SpaceModelEntity } from './space-model.entity'; +import { SubspaceModelEntity } from './subspace-model'; +import { ProductEntity } from '../../product/entities'; +import { TagEntity } from '../../space/entities/tag.entity'; + +@Entity({ name: 'tag_model' }) +export class TagModel extends AbstractEntity { + @Column({ type: 'varchar', length: 255 }) + tag: string; + + @ManyToOne(() => ProductEntity, (product) => product.tagModels, { + nullable: false, + }) + @JoinColumn({ name: 'product_id' }) + product: ProductEntity; + + @ManyToOne(() => SpaceModelEntity, (space) => space.tags, { nullable: true }) + @JoinColumn({ name: 'space_model_id' }) + spaceModel: SpaceModelEntity; + + @ManyToOne(() => SubspaceModelEntity, (subspace) => subspace.tags, { + nullable: true, + }) + @JoinColumn({ name: 'subspace_model_id' }) + subspaceModel: SubspaceModelEntity; + + @Column({ + nullable: false, + default: false, + }) + public disabled: boolean; + + @OneToMany(() => TagEntity, (tag) => tag.model) + tags: TagEntity[]; +} diff --git a/libs/common/src/modules/space-model/index.ts b/libs/common/src/modules/space-model/index.ts new file mode 100644 index 0000000..9d32775 --- /dev/null +++ b/libs/common/src/modules/space-model/index.ts @@ -0,0 +1,3 @@ +export * from './space-model.repository.module'; +export * from './entities'; +export * from './repositories'; diff --git a/libs/common/src/modules/space-model/repositories/index.ts b/libs/common/src/modules/space-model/repositories/index.ts new file mode 100644 index 0000000..d8fcff4 --- /dev/null +++ b/libs/common/src/modules/space-model/repositories/index.ts @@ -0,0 +1 @@ +export * from './space-model.repository'; diff --git a/libs/common/src/modules/space-model/repositories/space-model.repository.ts b/libs/common/src/modules/space-model/repositories/space-model.repository.ts new file mode 100644 index 0000000..4af0d57 --- /dev/null +++ b/libs/common/src/modules/space-model/repositories/space-model.repository.ts @@ -0,0 +1,23 @@ +import { DataSource, Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { SpaceModelEntity, SubspaceModelEntity, TagModel } from '../entities'; + +@Injectable() +export class SpaceModelRepository extends Repository { + constructor(private dataSource: DataSource) { + super(SpaceModelEntity, dataSource.createEntityManager()); + } +} +@Injectable() +export class SubspaceModelRepository extends Repository { + constructor(private dataSource: DataSource) { + super(SubspaceModelEntity, dataSource.createEntityManager()); + } +} + +@Injectable() +export class TagModelRepository extends Repository { + constructor(private dataSource: DataSource) { + super(TagModel, dataSource.createEntityManager()); + } +} diff --git a/libs/common/src/modules/space-model/space-model.repository.module.ts b/libs/common/src/modules/space-model/space-model.repository.module.ts new file mode 100644 index 0000000..9a35d88 --- /dev/null +++ b/libs/common/src/modules/space-model/space-model.repository.module.ts @@ -0,0 +1,13 @@ +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SpaceModelEntity, SubspaceModelEntity, TagModel } from './entities'; +import { Module } from '@nestjs/common'; + +@Module({ + providers: [], + exports: [], + controllers: [], + imports: [ + TypeOrmModule.forFeature([SpaceModelEntity, SubspaceModelEntity, TagModel]), + ], +}) +export class SpaceModelRepositoryModule {} diff --git a/libs/common/src/modules/space/dtos/index.ts b/libs/common/src/modules/space/dtos/index.ts index fcc0fdd..c511f8c 100644 --- a/libs/common/src/modules/space/dtos/index.ts +++ b/libs/common/src/modules/space/dtos/index.ts @@ -1,2 +1,3 @@ export * from './space.dto'; export * from './subspace.dto'; +export * from './tag.dto'; diff --git a/libs/common/src/modules/space/dtos/tag.dto.ts b/libs/common/src/modules/space/dtos/tag.dto.ts new file mode 100644 index 0000000..d988c62 --- /dev/null +++ b/libs/common/src/modules/space/dtos/tag.dto.ts @@ -0,0 +1,21 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class TagDto { + @IsString() + @IsNotEmpty() + public uuid: string; + + @IsString() + @IsNotEmpty() + public name: string; + + @IsString() + @IsNotEmpty() + public productUuid: string; + + @IsString() + spaceUuid: string; + + @IsString() + subspaceUuid: string; +} diff --git a/libs/common/src/modules/space/entities/index.ts b/libs/common/src/modules/space/entities/index.ts index f720cf4..5a514e6 100644 --- a/libs/common/src/modules/space/entities/index.ts +++ b/libs/common/src/modules/space/entities/index.ts @@ -1,3 +1,4 @@ export * from './space.entity'; -export * from './subspace.entity'; +export * from './subspace'; export * from './space-link.entity'; +export * from './tag.entity'; diff --git a/libs/common/src/modules/space/entities/invite-space.entity.ts b/libs/common/src/modules/space/entities/invite-space.entity.ts new file mode 100644 index 0000000..72737ca --- /dev/null +++ b/libs/common/src/modules/space/entities/invite-space.entity.ts @@ -0,0 +1,35 @@ +import { Column, Entity, ManyToOne, Unique } from 'typeorm'; +import { UserSpaceDto } from '../../user/dtos'; +import { AbstractEntity } from '../../abstract/entities/abstract.entity'; +import { SpaceEntity } from './space.entity'; + +@Entity({ name: 'invite-space' }) +@Unique(['invitationCode']) +export class InviteSpaceEntity extends AbstractEntity { + @Column({ + type: 'uuid', + default: () => 'gen_random_uuid()', // Use gen_random_uuid() for default value + nullable: false, + }) + public uuid: string; + + @ManyToOne(() => SpaceEntity, (space) => space.userSpaces, { + nullable: false, + }) + space: SpaceEntity; + + @Column({ + nullable: true, + }) + public invitationCode: string; + + @Column({ + nullable: false, + default: true, + }) + public isActive: boolean; + constructor(partial: Partial) { + super(); + Object.assign(this, partial); + } +} diff --git a/libs/common/src/modules/space/entities/space-link.entity.ts b/libs/common/src/modules/space/entities/space-link.entity.ts index a62ce4f..da11eb7 100644 --- a/libs/common/src/modules/space/entities/space-link.entity.ts +++ b/libs/common/src/modules/space/entities/space-link.entity.ts @@ -13,6 +13,12 @@ export class SpaceLinkEntity extends AbstractEntity { @JoinColumn({ name: 'end_space_id' }) public endSpace: SpaceEntity; + @Column({ + nullable: false, + default: false, + }) + public disabled: boolean; + @Column({ nullable: false, enum: Object.values(Direction), diff --git a/libs/common/src/modules/space/entities/space-product.entity.ts b/libs/common/src/modules/space/entities/space-product.entity.ts deleted file mode 100644 index 92ad411..0000000 --- a/libs/common/src/modules/space/entities/space-product.entity.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Column, Entity, ManyToOne, JoinColumn } from 'typeorm'; -import { SpaceEntity } from './space.entity'; -import { AbstractEntity } from '../../abstract/entities/abstract.entity'; -import { ProductEntity } from '../../product/entities'; - -@Entity({ name: 'space-product' }) -export class SpaceProductEntity extends AbstractEntity { - @ManyToOne(() => SpaceEntity, (space) => space.spaceProducts, { - nullable: false, - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'space_uuid' }) - space: SpaceEntity; - - @ManyToOne(() => ProductEntity, (product) => product.spaceProducts, { - nullable: false, - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'product_uuid' }) - product: ProductEntity; - - @Column({ - nullable: false, - type: 'int', - }) - productCount: number; - - constructor(partial: Partial) { - super(); - Object.assign(this, partial); - } -} diff --git a/libs/common/src/modules/space/entities/space.entity.ts b/libs/common/src/modules/space/entities/space.entity.ts index 103f0a2..a0b908d 100644 --- a/libs/common/src/modules/space/entities/space.entity.ts +++ b/libs/common/src/modules/space/entities/space.entity.ts @@ -1,23 +1,17 @@ -import { - Column, - Entity, - JoinColumn, - ManyToOne, - OneToMany, - Unique, -} from 'typeorm'; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { SpaceDto } from '../dtos'; import { AbstractEntity } from '../../abstract/entities/abstract.entity'; import { UserSpaceEntity } from '../../user/entities'; import { DeviceEntity } from '../../device/entities'; import { CommunityEntity } from '../../community/entities'; -import { SubspaceEntity } from './subspace.entity'; +import { SubspaceEntity } from './subspace'; import { SpaceLinkEntity } from './space-link.entity'; -import { SpaceProductEntity } from './space-product.entity'; import { SceneEntity } from '../../scene/entities'; +import { SpaceModelEntity } from '../../space-model'; +import { InviteUserSpaceEntity } from '../../Invite-user/entities'; +import { TagEntity } from './tag.entity'; @Entity({ name: 'space' }) -@Unique(['invitationCode']) export class SpaceEntity extends AbstractEntity { @Column({ type: 'uuid', @@ -42,10 +36,6 @@ export class SpaceEntity extends AbstractEntity { @JoinColumn({ name: 'community_id' }) community: CommunityEntity; - @Column({ - nullable: true, - }) - public invitationCode: string; @ManyToOne(() => SpaceEntity, (space) => space.children, { nullable: true }) parent: SpaceEntity; @@ -58,6 +48,12 @@ export class SpaceEntity extends AbstractEntity { @OneToMany(() => UserSpaceEntity, (userSpace) => userSpace.space) userSpaces: UserSpaceEntity[]; + @Column({ + nullable: false, + default: false, + }) + public disabled: boolean; + @OneToMany(() => SubspaceEntity, (subspace) => subspace.space, { nullable: true, }) @@ -92,12 +88,23 @@ export class SpaceEntity extends AbstractEntity { }) public icon: string; - @OneToMany(() => SpaceProductEntity, (spaceProduct) => spaceProduct.space) - spaceProducts: SpaceProductEntity[]; - @OneToMany(() => SceneEntity, (scene) => scene.space) scenes: SceneEntity[]; + @ManyToOne(() => SpaceModelEntity, (spaceModel) => spaceModel.spaces, { + nullable: true, + }) + spaceModel?: SpaceModelEntity; + + @OneToMany( + () => InviteUserSpaceEntity, + (inviteUserSpace) => inviteUserSpace.space, + ) + invitedUsers: InviteUserSpaceEntity[]; + + @OneToMany(() => TagEntity, (tag) => tag.space) + tags: TagEntity[]; + constructor(partial: Partial) { super(); Object.assign(this, partial); diff --git a/libs/common/src/modules/space/entities/subspace.entity.ts b/libs/common/src/modules/space/entities/subspace.entity.ts deleted file mode 100644 index ab38b00..0000000 --- a/libs/common/src/modules/space/entities/subspace.entity.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; -import { AbstractEntity } from '../../abstract/entities/abstract.entity'; -import { DeviceEntity } from '../../device/entities'; -import { SpaceEntity } from './space.entity'; -import { SubspaceDto } from '../dtos'; - -@Entity({ name: 'subspace' }) -export class SubspaceEntity extends AbstractEntity { - @Column({ - type: 'uuid', - default: () => 'gen_random_uuid()', - nullable: false, - }) - public uuid: string; - - @Column({ - nullable: false, - }) - public subspaceName: string; - - @ManyToOne(() => SpaceEntity, (space) => space.subspaces, { - nullable: false, - onDelete: 'CASCADE', - }) - @JoinColumn({ name: 'space_id' }) - space: SpaceEntity; - - @OneToMany(() => DeviceEntity, (device) => device.subspace, { - nullable: true, - }) - devices: DeviceEntity[]; - - constructor(partial: Partial) { - super(); - Object.assign(this, partial); - } -} diff --git a/libs/common/src/modules/space/entities/subspace/index.ts b/libs/common/src/modules/space/entities/subspace/index.ts new file mode 100644 index 0000000..be13961 --- /dev/null +++ b/libs/common/src/modules/space/entities/subspace/index.ts @@ -0,0 +1 @@ +export * from './subspace.entity'; diff --git a/libs/common/src/modules/space/entities/subspace/subspace.entity.ts b/libs/common/src/modules/space/entities/subspace/subspace.entity.ts new file mode 100644 index 0000000..c7247cc --- /dev/null +++ b/libs/common/src/modules/space/entities/subspace/subspace.entity.ts @@ -0,0 +1,52 @@ +import { AbstractEntity } from '@app/common/modules/abstract/entities/abstract.entity'; +import { DeviceEntity } from '@app/common/modules/device/entities'; +import { SubspaceModelEntity } from '@app/common/modules/space-model'; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { SubspaceDto } from '../../dtos'; +import { SpaceEntity } from '../space.entity'; +import { TagEntity } from '../tag.entity'; + +@Entity({ name: 'subspace' }) +export class SubspaceEntity extends AbstractEntity { + @Column({ + type: 'uuid', + default: () => 'gen_random_uuid()', + nullable: false, + }) + public uuid: string; + + @Column({ + nullable: false, + }) + public subspaceName: string; + + @ManyToOne(() => SpaceEntity, (space) => space.subspaces, { + nullable: false, + }) + @JoinColumn({ name: 'space_uuid' }) + space: SpaceEntity; + + @Column({ + nullable: false, + default: false, + }) + public disabled: boolean; + + @OneToMany(() => DeviceEntity, (device) => device.subspace, { + nullable: true, + }) + devices: DeviceEntity[]; + + @ManyToOne(() => SubspaceModelEntity, (subspace) => subspace.subspaceModel, { + nullable: true, + }) + subSpaceModel?: SubspaceModelEntity; + + @OneToMany(() => TagEntity, (tag) => tag.subspace) + tags: TagEntity[]; + + constructor(partial: Partial) { + super(); + Object.assign(this, partial); + } +} diff --git a/libs/common/src/modules/space/entities/tag.entity.ts b/libs/common/src/modules/space/entities/tag.entity.ts new file mode 100644 index 0000000..7340caf --- /dev/null +++ b/libs/common/src/modules/space/entities/tag.entity.ts @@ -0,0 +1,45 @@ +import { Entity, Column, ManyToOne, JoinColumn, OneToOne } from 'typeorm'; +import { AbstractEntity } from '../../abstract/entities/abstract.entity'; +import { ProductEntity } from '../../product/entities'; +import { TagDto } from '../dtos'; +import { TagModel } from '../../space-model/entities/tag-model.entity'; +import { SpaceEntity } from './space.entity'; +import { SubspaceEntity } from './subspace'; +import { DeviceEntity } from '../../device/entities'; + +@Entity({ name: 'tag' }) +export class TagEntity extends AbstractEntity { + @Column({ type: 'varchar', length: 255 }) + tag: string; + + @ManyToOne(() => TagModel, (model) => model.tags, { + nullable: true, + }) + model: TagModel; + + @ManyToOne(() => ProductEntity, (product) => product.tags, { + nullable: false, + }) + product: ProductEntity; + + @ManyToOne(() => SpaceEntity, (space) => space.tags, { nullable: true }) + space: SpaceEntity; + + @ManyToOne(() => SubspaceEntity, (subspace) => subspace.tags, { + nullable: true, + }) + @JoinColumn({ name: 'subspace_id' }) + subspace: SubspaceEntity; + + @Column({ + nullable: false, + default: false, + }) + public disabled: boolean; + + @OneToOne(() => DeviceEntity, (device) => device.tag, { + nullable: true, + }) + @JoinColumn({ name: 'device_id' }) + device: DeviceEntity; +} diff --git a/libs/common/src/modules/space/index.ts b/libs/common/src/modules/space/index.ts new file mode 100644 index 0000000..b797801 --- /dev/null +++ b/libs/common/src/modules/space/index.ts @@ -0,0 +1,4 @@ +export * from './dtos'; +export * from './entities'; +export * from './repositories'; +export * from './space.repository.module'; diff --git a/libs/common/src/modules/space/repositories/space.repository.ts b/libs/common/src/modules/space/repositories/space.repository.ts index 43ce45e..9bc2fee 100644 --- a/libs/common/src/modules/space/repositories/space.repository.ts +++ b/libs/common/src/modules/space/repositories/space.repository.ts @@ -1,7 +1,7 @@ import { DataSource, Repository } from 'typeorm'; import { Injectable } from '@nestjs/common'; -import { SpaceProductEntity } from '../entities/space-product.entity'; -import { SpaceEntity, SpaceLinkEntity, SubspaceEntity } from '../entities'; +import { SpaceEntity, SpaceLinkEntity, TagEntity } from '../entities'; +import { InviteSpaceEntity } from '../entities/invite-space.entity'; @Injectable() export class SpaceRepository extends Repository { @@ -9,12 +9,6 @@ export class SpaceRepository extends Repository { super(SpaceEntity, dataSource.createEntityManager()); } } -@Injectable() -export class SubspaceRepository extends Repository { - constructor(private dataSource: DataSource) { - super(SubspaceEntity, dataSource.createEntityManager()); - } -} @Injectable() export class SpaceLinkRepository extends Repository { @@ -22,9 +16,17 @@ export class SpaceLinkRepository extends Repository { super(SpaceLinkEntity, dataSource.createEntityManager()); } } + @Injectable() -export class SpaceProductRepository extends Repository { +export class TagRepository extends Repository { constructor(private dataSource: DataSource) { - super(SpaceProductEntity, dataSource.createEntityManager()); + super(TagEntity, dataSource.createEntityManager()); + } +} + +@Injectable() +export class InviteSpaceRepository extends Repository { + constructor(private dataSource: DataSource) { + super(InviteSpaceEntity, dataSource.createEntityManager()); } } diff --git a/libs/common/src/modules/space/repositories/subspace.repository.ts b/libs/common/src/modules/space/repositories/subspace.repository.ts new file mode 100644 index 0000000..5897510 --- /dev/null +++ b/libs/common/src/modules/space/repositories/subspace.repository.ts @@ -0,0 +1,10 @@ +import { DataSource, Repository } from 'typeorm'; +import { SubspaceEntity } from '../entities'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class SubspaceRepository extends Repository { + constructor(private dataSource: DataSource) { + super(SubspaceEntity, dataSource.createEntityManager()); + } +} diff --git a/libs/common/src/modules/space/space.repository.module.ts b/libs/common/src/modules/space/space.repository.module.ts index 90916c2..a475a9a 100644 --- a/libs/common/src/modules/space/space.repository.module.ts +++ b/libs/common/src/modules/space/space.repository.module.ts @@ -1,11 +1,19 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { SpaceEntity, SubspaceEntity } from './entities'; +import { SpaceEntity, SubspaceEntity, TagEntity } from './entities'; +import { InviteSpaceEntity } from './entities/invite-space.entity'; @Module({ providers: [], exports: [], controllers: [], - imports: [TypeOrmModule.forFeature([SpaceEntity, SubspaceEntity])], + imports: [ + TypeOrmModule.forFeature([ + SpaceEntity, + SubspaceEntity, + TagEntity, + InviteSpaceEntity, + ]), + ], }) export class SpaceRepositoryModule {} diff --git a/libs/common/src/modules/user/dtos/user.dto.ts b/libs/common/src/modules/user/dtos/user.dto.ts index 0a4bda2..01c0c24 100644 --- a/libs/common/src/modules/user/dtos/user.dto.ts +++ b/libs/common/src/modules/user/dtos/user.dto.ts @@ -58,20 +58,6 @@ export class UserOtpDto { public expiryTime: string; } -export class UserRoleDto { - @IsString() - @IsNotEmpty() - public uuid: string; - - @IsString() - @IsNotEmpty() - public userUuid: string; - - @IsString() - @IsNotEmpty() - public roleTypeUuid: string; -} - export class UserSpaceDto { @IsString() @IsNotEmpty() diff --git a/libs/common/src/modules/user/entities/user.entity.ts b/libs/common/src/modules/user/entities/user.entity.ts index c9a11e6..023864a 100644 --- a/libs/common/src/modules/user/entities/user.entity.ts +++ b/libs/common/src/modules/user/entities/user.entity.ts @@ -2,15 +2,16 @@ import { Column, DeleteDateColumn, Entity, + JoinColumn, ManyToOne, OneToMany, + OneToOne, Unique, } from 'typeorm'; import { UserDto, UserNotificationDto, UserOtpDto, - UserRoleDto, UserSpaceDto, } from '../dtos'; import { AbstractEntity } from '../../abstract/entities/abstract.entity'; @@ -26,6 +27,8 @@ import { OtpType } from '../../../../src/constants/otp-type.enum'; import { RoleTypeEntity } from '../../role-type/entities'; import { SpaceEntity } from '../../space/entities'; import { VisitorPasswordEntity } from '../../visitor-password/entities'; +import { InviteUserEntity } from '../../Invite-user/entities'; +import { ProjectEntity } from '../../project/entities'; @Entity({ name: 'user' }) export class UserEntity extends AbstractEntity { @@ -79,6 +82,18 @@ export class UserEntity extends AbstractEntity { }) public isActive: boolean; + @Column({ default: false }) + hasAcceptedWebAgreement: boolean; + + @Column({ default: false }) + hasAcceptedAppAgreement: boolean; + + @Column({ type: 'timestamp', nullable: true }) + webAgreementAcceptedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + appAgreementAcceptedAt: Date; + @OneToMany(() => UserSpaceEntity, (userSpace) => userSpace.user) userSpaces: UserSpaceEntity[]; @@ -100,10 +115,7 @@ export class UserEntity extends AbstractEntity { (deviceUserNotification) => deviceUserNotification.user, ) deviceUserNotification: DeviceNotificationEntity[]; - @OneToMany(() => UserRoleEntity, (role) => role.user, { - nullable: true, - }) - roles: UserRoleEntity[]; + @ManyToOne(() => RegionEntity, (region) => region.users, { nullable: true }) region: RegionEntity; @ManyToOne(() => TimeZoneEntity, (timezone) => timezone.users, { @@ -116,6 +128,21 @@ export class UserEntity extends AbstractEntity { ) public visitorPasswords: VisitorPasswordEntity[]; + @ManyToOne(() => RoleTypeEntity, (roleType) => roleType.users, { + nullable: false, + }) + public roleType: RoleTypeEntity; + @OneToOne(() => InviteUserEntity, (inviteUser) => inviteUser.user, { + nullable: true, + }) + @JoinColumn({ name: 'invite_user_uuid' }) + inviteUser: InviteUserEntity; + + @ManyToOne(() => ProjectEntity, (project) => project.users, { + nullable: true, + }) + @JoinColumn({ name: 'project_uuid' }) + public project: ProjectEntity; constructor(partial: Partial) { super(); Object.assign(this, partial); @@ -125,7 +152,7 @@ export class UserEntity extends AbstractEntity { @Entity({ name: 'user-notification' }) @Unique(['user', 'subscriptionUuid']) export class UserNotificationEntity extends AbstractEntity { - @ManyToOne(() => UserEntity, (user) => user.roles, { + @ManyToOne(() => UserEntity, (user) => user.roleType, { nullable: false, }) user: UserEntity; @@ -178,25 +205,6 @@ export class UserOtpEntity extends AbstractEntity { } } -@Entity({ name: 'user-role' }) -@Unique(['user', 'roleType']) -export class UserRoleEntity extends AbstractEntity { - @ManyToOne(() => UserEntity, (user) => user.roles, { - nullable: false, - }) - user: UserEntity; - - @ManyToOne(() => RoleTypeEntity, (roleType) => roleType.roles, { - nullable: false, - }) - roleType: RoleTypeEntity; - - constructor(partial: Partial) { - super(); - Object.assign(this, partial); - } -} - @Entity({ name: 'user-space' }) @Unique(['user', 'space']) export class UserSpaceEntity extends AbstractEntity { diff --git a/libs/common/src/modules/user/repositories/user.repository.ts b/libs/common/src/modules/user/repositories/user.repository.ts index ffc1aa1..d2303f9 100644 --- a/libs/common/src/modules/user/repositories/user.repository.ts +++ b/libs/common/src/modules/user/repositories/user.repository.ts @@ -4,7 +4,6 @@ import { UserEntity, UserNotificationEntity, UserOtpEntity, - UserRoleEntity, UserSpaceEntity, } from '../entities/'; @@ -29,13 +28,6 @@ export class UserOtpRepository extends Repository { } } -@Injectable() -export class UserRoleRepository extends Repository { - constructor(private dataSource: DataSource) { - super(UserRoleEntity, dataSource.createEntityManager()); - } -} - @Injectable() export class UserSpaceRepository extends Repository { constructor(private dataSource: DataSource) { diff --git a/libs/common/src/modules/user/user.repository.module.ts b/libs/common/src/modules/user/user.repository.module.ts index 11cefe0..e7f7d3e 100644 --- a/libs/common/src/modules/user/user.repository.module.ts +++ b/libs/common/src/modules/user/user.repository.module.ts @@ -4,7 +4,6 @@ import { UserEntity, UserNotificationEntity, UserOtpEntity, - UserRoleEntity, UserSpaceEntity, } from './entities'; @@ -17,7 +16,6 @@ import { UserEntity, UserNotificationEntity, UserOtpEntity, - UserRoleEntity, UserSpaceEntity, ]), ], diff --git a/libs/common/src/seed/seeder.module.ts b/libs/common/src/seed/seeder.module.ts index eeb245b..6de5585 100644 --- a/libs/common/src/seed/seeder.module.ts +++ b/libs/common/src/seed/seeder.module.ts @@ -10,7 +10,6 @@ import { RoleTypeSeeder } from './services/role.type.seeder'; import { SpaceRepositoryModule } from '../modules/space/space.repository.module'; import { SuperAdminSeeder } from './services/supper.admin.seeder'; import { UserRepository } from '../modules/user/repositories'; -import { UserRoleRepository } from '../modules/user/repositories'; import { UserRepositoryModule } from '../modules/user/user.repository.module'; import { RegionSeeder } from './services/regions.seeder'; import { RegionRepository } from '../modules/region/repositories'; @@ -28,7 +27,6 @@ import { SceneIconRepository } from '../modules/scene/repositories'; RoleTypeRepository, SuperAdminSeeder, UserRepository, - UserRoleRepository, RegionSeeder, RegionRepository, TimeZoneSeeder, diff --git a/libs/common/src/seed/services/role.type.seeder.ts b/libs/common/src/seed/services/role.type.seeder.ts index 5f7a4b2..1294666 100644 --- a/libs/common/src/seed/services/role.type.seeder.ts +++ b/libs/common/src/seed/services/role.type.seeder.ts @@ -19,7 +19,12 @@ export class RoleTypeSeeder { if (!roleTypeNames.includes(RoleType.ADMIN)) { missingRoleTypes.push(RoleType.ADMIN); } - + if (!roleTypeNames.includes(RoleType.SPACE_OWNER)) { + missingRoleTypes.push(RoleType.SPACE_OWNER); + } + if (!roleTypeNames.includes(RoleType.SPACE_MEMBER)) { + missingRoleTypes.push(RoleType.SPACE_MEMBER); + } if (missingRoleTypes.length > 0) { await this.addRoleTypeData(missingRoleTypes); } diff --git a/libs/common/src/seed/services/supper.admin.seeder.ts b/libs/common/src/seed/services/supper.admin.seeder.ts index 9cfdfee..5f7ef21 100644 --- a/libs/common/src/seed/services/supper.admin.seeder.ts +++ b/libs/common/src/seed/services/supper.admin.seeder.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { UserRepository } from '@app/common/modules/user/repositories'; import { RoleType } from '@app/common/constants/role.type.enum'; -import { UserRoleRepository } from '@app/common/modules/user/repositories'; import { RoleTypeRepository } from '@app/common/modules/role-type/repositories'; import { ConfigService } from '@nestjs/config'; import { HelperHashService } from '../../helper/services'; @@ -11,19 +10,23 @@ export class SuperAdminSeeder { constructor( private readonly configService: ConfigService, private readonly userRepository: UserRepository, - private readonly userRoleRepository: UserRoleRepository, private readonly roleTypeRepository: RoleTypeRepository, private readonly helperHashService: HelperHashService, ) {} async createSuperAdminIfNotFound(): Promise { try { - const superAdminData = await this.userRoleRepository.find({ - where: { roleType: { type: RoleType.SUPER_ADMIN } }, + const superAdmin = await this.userRepository.findOne({ + where: { + roleType: { type: RoleType.SUPER_ADMIN }, + email: this.configService.get( + 'super-admin.SUPER_ADMIN_EMAIL', + ), + }, relations: ['roleType'], }); - if (superAdminData.length <= 0) { + if (!superAdmin) { // Create the super admin user if not found console.log('Creating super admin user...'); @@ -48,20 +51,16 @@ export class SuperAdminSeeder { salt, ); try { - const user = await this.userRepository.save({ + const defaultUserRoleUuid = await this.getRoleUuidByRoleType( + RoleType.SUPER_ADMIN, + ); + await this.userRepository.save({ email: this.configService.get('super-admin.SUPER_ADMIN_EMAIL'), password: hashedPassword, firstName: 'Super', lastName: 'Admin', isUserVerified: true, isActive: true, - }); - const defaultUserRoleUuid = await this.getRoleUuidByRoleType( - RoleType.SUPER_ADMIN, - ); - - await this.userRoleRepository.save({ - user: { uuid: user.uuid }, roleType: { uuid: defaultUserRoleUuid }, }); } catch (err) { diff --git a/libs/common/src/util/buildTypeORMIncludeQuery.ts b/libs/common/src/util/buildTypeORMIncludeQuery.ts index c2d401e..a8d4e20 100644 --- a/libs/common/src/util/buildTypeORMIncludeQuery.ts +++ b/libs/common/src/util/buildTypeORMIncludeQuery.ts @@ -16,6 +16,20 @@ const mappingInclude: { [key: string]: any } = { subspace: { subspace: true, }, + project: { + project: true, + }, + 'space-model': { + subspaceModels: 'subspace-model', + tags: 'tag_model', + }, + 'subspace-model': { + tags: 'tag_model', + }, + tag_model: { + tag_model: true, + product: true, + }, }; export function buildTypeORMIncludeQuery( @@ -27,17 +41,33 @@ export function buildTypeORMIncludeQuery( const fieldsToInclude: string[] = includeParam.split(','); fieldsToInclude.forEach((field: string) => { - if (mappingInclude[field]) { - relations.push(field); // Push mapped field - } else { - console.warn( - `Field ${field} not found in mappingInclude for ${modelName}`, - ); + const nestedFields = field.split('.'); + let currentModelName = modelName; + let isValid = true; + + nestedFields.forEach((nestedField, index) => { + const currentMapping = mappingInclude[currentModelName]; + if (currentMapping?.[nestedField]) { + currentModelName = + typeof currentMapping[nestedField] === 'string' + ? currentMapping[nestedField] + : currentModelName; + } else { + console.warn( + `Field "${nestedFields.slice(0, index + 1).join('.')}" not found in mappingInclude for model "${currentModelName}"`, + ); + isValid = false; + return; + } + }); + + if (isValid) { + relations.push(field); } }); - return relations; + return relations.length ? relations : undefined; } - return undefined; // If no includes, return undefined + return undefined; } diff --git a/libs/common/src/util/email.service.ts b/libs/common/src/util/email.service.ts index f3a389d..c6e09ec 100644 --- a/libs/common/src/util/email.service.ts +++ b/libs/common/src/util/email.service.ts @@ -1,6 +1,11 @@ -import { Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as nodemailer from 'nodemailer'; +import axios from 'axios'; +import { + SEND_EMAIL_API_URL_DEV, + SEND_EMAIL_API_URL_PROD, +} from '../constants/mail-trap'; @Injectable() export class EmailService { @@ -35,4 +40,180 @@ export class EmailService { await transporter.sendMail(mailOptions); } + async sendEmailWithInvitationTemplate( + email: string, + emailInvitationData: any, + ): Promise { + const isProduction = process.env.NODE_ENV === 'production'; + const API_TOKEN = this.configService.get( + 'email-config.MAILTRAP_API_TOKEN', + ); + const API_URL = isProduction + ? SEND_EMAIL_API_URL_PROD + : SEND_EMAIL_API_URL_DEV; + const TEMPLATE_UUID = this.configService.get( + 'email-config.MAILTRAP_INVITATION_TEMPLATE_UUID', + ); + + const emailData = { + from: { + email: this.smtpConfig.sender, + }, + to: [ + { + email: email, + }, + ], + template_uuid: TEMPLATE_UUID, + template_variables: emailInvitationData, + }; + + try { + await axios.post(API_URL, emailData, { + headers: { + Authorization: `Bearer ${API_TOKEN}`, + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + throw new HttpException( + error.response?.data?.message || + 'Error sending email using Mailtrap template', + error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async sendEmailWithTemplate( + email: string, + name: string, + isEnable: boolean, + isDelete: boolean, + ): Promise { + const isProduction = process.env.NODE_ENV === 'production'; + const API_TOKEN = this.configService.get( + 'email-config.MAILTRAP_API_TOKEN', + ); + const API_URL = isProduction + ? SEND_EMAIL_API_URL_PROD + : SEND_EMAIL_API_URL_DEV; + + // Determine the template UUID based on the arguments + const templateUuid = isDelete + ? this.configService.get( + 'email-config.MAILTRAP_DELETE_USER_TEMPLATE_UUID', + ) + : this.configService.get( + isEnable + ? 'email-config.MAILTRAP_ENABLE_TEMPLATE_UUID' + : 'email-config.MAILTRAP_DISABLE_TEMPLATE_UUID', + ); + + const emailData = { + from: { + email: this.smtpConfig.sender, + }, + to: [ + { + email, + }, + ], + template_uuid: templateUuid, + template_variables: { + name, + }, + }; + + try { + await axios.post(API_URL, emailData, { + headers: { + Authorization: `Bearer ${API_TOKEN}`, + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + throw new HttpException( + error.response?.data?.message || + 'Error sending email using Mailtrap template', + error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async sendEditUserEmailWithTemplate( + email: string, + emailEditData: any, + ): Promise { + const isProduction = process.env.NODE_ENV === 'production'; + const API_TOKEN = this.configService.get( + 'email-config.MAILTRAP_API_TOKEN', + ); + const API_URL = isProduction + ? SEND_EMAIL_API_URL_PROD + : SEND_EMAIL_API_URL_DEV; + const TEMPLATE_UUID = this.configService.get( + 'email-config.MAILTRAP_EDIT_USER_TEMPLATE_UUID', + ); + + const emailData = { + from: { + email: this.smtpConfig.sender, + }, + to: [ + { + email: email, + }, + ], + template_uuid: TEMPLATE_UUID, + template_variables: emailEditData, + }; + + try { + await axios.post(API_URL, emailData, { + headers: { + Authorization: `Bearer ${API_TOKEN}`, + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + throw new HttpException( + error.response?.data?.message || + 'Error sending email using Mailtrap template', + error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + generateUserChangesEmailBody( + addedSpaceNames: string[], + removedSpaceNames: string[], + oldRole: string, + newRole: string, + oldName: string, + newName: string, + ) { + const addedSpaceNamesChanged = + addedSpaceNames.length > 0 + ? `Access to the following spaces were added: ${addedSpaceNames.join(', ')}` + : ''; + + const removedSpaceNamesChanged = + removedSpaceNames.length > 0 + ? `Access to the following spaces were deleted: ${removedSpaceNames.join(', ')}` + : ''; + + const roleChanged = + oldRole !== newRole + ? `Your user role has been changed from [${oldRole}] to [${newRole}]` + : ''; + + const nameChanged = + oldName !== newName + ? `The name associated with your account has changed from [${oldName}] to [${newName}]` + : ''; + + return { + addedSpaceNamesChanged, + removedSpaceNamesChanged, + roleChanged, + nameChanged, + }; + } } diff --git a/package-lock.json b/package-lock.json index 9a92384..a1d7cca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.0.0", + "@nestjs/cqrs": "^10.2.8", "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", @@ -55,6 +56,7 @@ "concurrently": "^8.2.2", "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", + "eslint-plugin-import": "^2.31.0", "eslint-plugin-prettier": "^5.0.0", "jest": "^29.5.0", "prettier": "^3.0.0", @@ -2462,6 +2464,34 @@ } } }, + "node_modules/@nestjs/cqrs": { + "version": "10.2.8", + "resolved": "https://registry.npmjs.org/@nestjs/cqrs/-/cqrs-10.2.8.tgz", + "integrity": "sha512-nes4J9duwogme6CzNg8uhF5WVbSKYnNotjhHP+3kJxe6RTzcvJDZN10KpROjWJALEVO5fNmKnkGMecoOfqYzYA==", + "license": "MIT", + "dependencies": { + "uuid": "11.0.2" + }, + "peerDependencies": { + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.2.0" + } + }, + "node_modules/@nestjs/cqrs/node_modules/uuid": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.2.tgz", + "integrity": "sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/@nestjs/jwt": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", @@ -2775,6 +2805,13 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3028,6 +3065,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", @@ -3736,11 +3780,49 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array-timsort": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", @@ -3756,6 +3838,87 @@ "node": ">=8" } }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", + "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -3783,6 +3946,22 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -4209,15 +4388,45 @@ } }, "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -4820,6 +5029,60 @@ "node": ">=0.10" } }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -4902,6 +5165,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", @@ -4914,6 +5178,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5027,6 +5309,20 @@ "node": ">=12" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5107,13 +5403,74 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "node_modules/es-abstract": { + "version": "1.23.6", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.6.tgz", + "integrity": "sha512-Ifco6n3yj2tMZDWNLyloZrytt9lqqlwvS83P3HtaETR0NUOYnIULGGHpktqYGObGy+8wc1okO25p8TjemhImvA==", + "dev": true, + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4" + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.7", + "get-intrinsic": "^1.2.6", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.1.0", + "math-intrinsics": "^1.0.0", + "object-inspect": "^1.13.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.3", + "safe-array-concat": "^1.1.3", + "safe-regex-test": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.3", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.16" }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -5132,6 +5489,61 @@ "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", "dev": true }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/escalade": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", @@ -5224,6 +5636,183 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", + "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", + "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.8", + "array.prototype.findlastindex": "^1.2.5", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.0", + "hasown": "^2.0.2", + "is-core-module": "^2.15.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.8", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-import/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint-plugin-import/node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, "node_modules/eslint-plugin-prettier": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", @@ -5929,6 +6518,16 @@ } } }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.1.3" + } + }, "node_modules/foreground-child": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", @@ -6080,6 +6679,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.7.tgz", + "integrity": "sha512-2g4x+HqTJKM9zcJqBSpjoRmdcPFtJM60J3xJisTQSXBWka5XqyBN/2tNUgma1mztTXyDuUsEtYe5qcs7xYzYQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gaxios": { "version": "6.7.1", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", @@ -6127,15 +6756,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", + "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "dunder-proto": "^1.0.0", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -6165,6 +6800,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", @@ -6227,6 +6880,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -6286,11 +6956,12 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6383,6 +7054,16 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6404,6 +7085,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, "dependencies": { "es-define-property": "^1.0.0" }, @@ -6412,9 +7094,14 @@ } }, "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -6423,9 +7110,26 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, "engines": { "node": ">= 0.4" }, @@ -6434,9 +7138,10 @@ } }, "node_modules/hasown": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", - "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -6660,6 +7365,21 @@ "node": ">=12.0.0" } }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/interpret": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", @@ -6728,12 +7448,62 @@ "node": ">= 0.10" } }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -6746,13 +7516,82 @@ "node": ">=8" } }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "node_modules/is-boolean-object": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.1.tgz", + "integrity": "sha512-l9qO6eFlUETHtuihLcYOaLKByJ1f+N4kthcU9YjHy3N+B3hWv0y/2Nd0mu/7lTFnRQHTrSdXF50HQ3bl5fEnng==", "dev": true, + "license": "MIT", "dependencies": { - "hasown": "^2.0.0" + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.0.tgz", + "integrity": "sha512-urTSINYfAYgcbLb0yDQ6egFm6h3Mo1DcF9EkyXSRjjzdHbsulg01qhwWuXdOoUBuTkbQ80KDboXa0vFJ+BDH+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6767,6 +7606,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -6784,6 +7639,22 @@ "node": ">=6" } }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -6805,6 +7676,32 @@ "node": ">=8" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -6814,6 +7711,23 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-path-inside": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", @@ -6823,6 +7737,54 @@ "node": ">=8" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -6834,6 +7796,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -6851,6 +7864,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.0.tgz", + "integrity": "sha512-SXM8Nwyys6nT5WP6pltOwKytLV7FqQ4UiibxVmW+EIosHcmCqkkjViTb5SNssDlkCiEYRP1/pdWUKVvZBmsR2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -8011,6 +9070,15 @@ "tmpl": "1.0.5" } }, + "node_modules/math-intrinsics": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.0.0.tgz", + "integrity": "sha512-4MqMiKP90ybymYvsut0CH2g4XWbfLtmlCkXmtmdcDCxNB+mQcu1w/1+L/VD7vi/PSv7X2JYV7SCcR+jiPXnQtA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -8486,9 +9554,94 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", + "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8995,6 +10148,16 @@ "node": ">=4" } }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -9329,12 +10492,54 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.1.tgz", "integrity": "sha512-i5lLI6iw9AU3Uu4szRNPPEkomnkjRTaVt9hy/bn5g/oSzekBSMeLZblcjP74AW0vBabqERLLIrz+gR8QYR54Tw==" }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.8.tgz", + "integrity": "sha512-B5dj6usc5dkk8uFliwjwDHM8To5/QwdKz9JcBZ8Ic4G1f0YmeeJTtE/ZTdgRFPAfxZFiUaPhZ1Jcs4qeagItGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "dunder-proto": "^1.0.0", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "gopd": "^1.2.0", + "which-builtin-type": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "dev": true }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", + "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", @@ -9639,6 +10844,33 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -9658,6 +10890,24 @@ } ] }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -9807,16 +11057,34 @@ } }, "node_modules/set-function-length": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", - "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", "dependencies": { - "define-data-property": "^1.1.2", + "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.3", + "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.1" + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -9927,14 +11195,69 @@ } }, "node_modules/side-channel": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.5.tgz", - "integrity": "sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.6", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -10180,6 +11503,65 @@ "node": ">=8" } }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -10809,6 +12191,84 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.3.tgz", + "integrity": "sha512-GsvTyUHTriq6o/bHcTd0vM7OQ9JEdlvluu9YISaA7+KzDzPaIzEeDFNkTfhdE3MYcNhNi0vq/LlegYgIs5yPAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -10980,6 +12440,25 @@ "node": ">=8" } }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici": { "version": "5.28.4", "resolved": "https://registry.npmjs.org/undici/-/undici-5.28.4.tgz", @@ -11300,6 +12779,100 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.16.tgz", + "integrity": "sha512-g+N+GAWiRj66DngFwHvISJd+ITsyphZvD1vChfVg6cEdnzy53GzB3oy0fUNlvhz7H7+MiqhYr26qxQShCpKTTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", diff --git a/package.json b/package.json index d3c90e0..6598f15 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.2.0", "@nestjs/core": "^10.0.0", + "@nestjs/cqrs": "^10.2.8", "@nestjs/jwt": "^10.2.0", "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", @@ -66,6 +67,7 @@ "concurrently": "^8.2.2", "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", + "eslint-plugin-import": "^2.31.0", "eslint-plugin-prettier": "^5.0.0", "jest": "^29.5.0", "prettier": "^3.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index 609147c..f941837 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,7 +7,6 @@ import { GroupModule } from './group/group.module'; import { DeviceModule } from './device/device.module'; import { UserDevicePermissionModule } from './user-device-permission/user-device-permission.module'; import { CommunityModule } from './community/community.module'; -import { RoleModule } from './role/role.module'; import { SeederModule } from '@app/common/seed/seeder.module'; import { UserNotificationModule } from './user-notification/user-notification.module'; import { DeviceMessagesSubscriptionModule } from './device-messages/device-messages.module'; @@ -22,6 +21,13 @@ import { VisitorPasswordModule } from './vistor-password/visitor-password.module import { ScheduleModule } from './schedule/schedule.module'; import { SpaceModule } from './space/space.module'; import { ProductModule } from './product'; +import { ProjectModule } from './project'; +import { SpaceModelModule } from './space-model'; +import { InviteUserModule } from './invite-user/invite-user.module'; +import { PermissionModule } from './permission/permission.module'; +import { RoleModule } from './role/role.module'; +import { TermsConditionsModule } from './terms-conditions/terms-conditions.module'; +import { PrivacyPolicyModule } from './privacy-policy/privacy-policy.module'; @Module({ imports: [ ConfigModule.forRoot({ @@ -29,11 +35,11 @@ import { ProductModule } from './product'; }), AuthenticationModule, UserModule, - RoleModule, + InviteUserModule, CommunityModule, SpaceModule, - + SpaceModelModule, GroupModule, DeviceModule, DeviceMessagesSubscriptionModule, @@ -48,6 +54,11 @@ import { ProductModule } from './product'; VisitorPasswordModule, ScheduleModule, ProductModule, + ProjectModule, + PermissionModule, + RoleModule, + TermsConditionsModule, + PrivacyPolicyModule, ], providers: [ { diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 66d335d..8aaef36 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -6,11 +6,9 @@ import { UserAuthController } from './controllers'; import { UserAuthService } from './services'; import { UserRepository } from '@app/common/modules/user/repositories'; import { UserSessionRepository } from '@app/common/modules/session/repositories/session.repository'; -import { - UserRoleRepository, - UserOtpRepository, -} from '@app/common/modules/user/repositories'; +import { UserOtpRepository } from '@app/common/modules/user/repositories'; import { RoleTypeRepository } from '@app/common/modules/role-type/repositories'; +import { RoleService } from 'src/role/services'; @Module({ imports: [ConfigModule, UserRepositoryModule, CommonModule], @@ -20,8 +18,8 @@ import { RoleTypeRepository } from '@app/common/modules/role-type/repositories'; UserRepository, UserSessionRepository, UserOtpRepository, - UserRoleRepository, RoleTypeRepository, + RoleService, ], exports: [UserAuthService], }) diff --git a/src/auth/dtos/user-auth.dto.ts b/src/auth/dtos/user-auth.dto.ts index dad1e07..d2f2e8a 100644 --- a/src/auth/dtos/user-auth.dto.ts +++ b/src/auth/dtos/user-auth.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { + IsBoolean, + IsEmail, + IsNotEmpty, + IsOptional, + IsString, +} from 'class-validator'; import { IsPasswordStrong } from 'src/validators/password.validator'; export class UserSignUpDto { @@ -39,7 +45,19 @@ export class UserSignUpDto { @IsNotEmpty() public lastName: string; + @ApiProperty({ + description: 'regionUuid', + required: false, + }) @IsString() @IsOptional() public regionUuid?: string; + + @ApiProperty({ + description: 'hasAcceptedAppAgreement', + required: true, + }) + @IsBoolean() + @IsNotEmpty() + public hasAcceptedAppAgreement: boolean; } diff --git a/src/auth/dtos/user-login.dto.ts b/src/auth/dtos/user-login.dto.ts index 198ae12..6af12aa 100644 --- a/src/auth/dtos/user-login.dto.ts +++ b/src/auth/dtos/user-login.dto.ts @@ -1,5 +1,6 @@ +import { PlatformType } from '@app/common/constants/platform-type.enum'; import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsOptional, IsString } from 'class-validator'; +import { IsEmail, IsEnum, IsOptional, IsString } from 'class-validator'; export class UserLoginDto { @ApiProperty() @@ -20,4 +21,9 @@ export class UserLoginDto { @IsOptional() @IsString() googleCode?: string; + + @ApiProperty() + @IsOptional() + @IsEnum(PlatformType) + platform?: PlatformType; } diff --git a/src/auth/services/user-auth.service.ts b/src/auth/services/user-auth.service.ts index 80be3c2..6e7780c 100644 --- a/src/auth/services/user-auth.service.ts +++ b/src/auth/services/user-auth.service.ts @@ -18,6 +18,8 @@ import * as argon2 from 'argon2'; import { differenceInSeconds } from '@app/common/helper/differenceInSeconds'; import { LessThan, MoreThan } from 'typeorm'; import { ConfigService } from '@nestjs/config'; +import { RoleService } from 'src/role/services'; +import { RoleType } from '@app/common/constants/role.type.enum'; @Injectable() export class UserAuthService { @@ -28,11 +30,15 @@ export class UserAuthService { private readonly helperHashService: HelperHashService, private readonly authService: AuthService, private readonly emailService: EmailService, + private readonly roleService: RoleService, private readonly configService: ConfigService, ) {} async signUp(userSignUpDto: UserSignUpDto): Promise { const findUser = await this.findUser(userSignUpDto.email); + if (!findUser.isActive) { + throw new BadRequestException('User is not active'); + } if (findUser) { throw new BadRequestException('User already registered with given email'); } @@ -43,10 +49,19 @@ export class UserAuthService { ); try { - const { regionUuid, ...rest } = userSignUpDto; + const { regionUuid, hasAcceptedAppAgreement, ...rest } = userSignUpDto; + if (!hasAcceptedAppAgreement) { + throw new BadRequestException('Please accept the terms and conditions'); + } + const spaceMemberRole = await this.roleService.findRoleByType( + RoleType.SPACE_MEMBER, + ); const user = await this.userRepository.save({ ...rest, + appAgreementAcceptedAt: new Date(), + hasAcceptedAppAgreement, password: hashedPassword, + roleType: { uuid: spaceMemberRole.uuid }, region: regionUuid ? { uuid: regionUuid, @@ -58,7 +73,7 @@ export class UserAuthService { return user; } catch (error) { - throw new BadRequestException('Failed to register user'); + throw new BadRequestException(error.message || 'Failed to register user'); } } @@ -109,6 +124,7 @@ export class UserAuthService { firstName: googleUserData['given_name'], lastName: googleUserData['family_name'], password: googleUserData['email'], + hasAcceptedAppAgreement: true, }); } data.email = googleUserData['email']; @@ -119,6 +135,7 @@ export class UserAuthService { data.email, data.password, data.regionUuid, + data.platform, ); } const session = await Promise.all([ @@ -134,18 +151,19 @@ export class UserAuthService { isLoggedOut: false, }), ]); + const res = await this.authService.login({ email: user.email, userId: user.uuid, uuid: user.uuid, - roles: user?.roles?.map((role) => { - return { uuid: role.uuid, type: role.roleType.type }; - }), + role: user.roleType, + hasAcceptedWebAgreement: user.hasAcceptedWebAgreement, + hasAcceptedAppAgreement: user.hasAcceptedAppAgreement, sessionId: session[1].uuid, }); return res; } catch (error) { - throw new BadRequestException('Invalid credentials'); + throw new BadRequestException(error.message || 'Invalid credentials'); } } diff --git a/src/automation/automation.module.ts b/src/automation/automation.module.ts index 0a05f0e..810c8ec 100644 --- a/src/automation/automation.module.ts +++ b/src/automation/automation.module.ts @@ -15,6 +15,7 @@ import { SceneRepository, } from '@app/common/modules/scene/repositories'; import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; +import { AutomationRepository } from '@app/common/modules/automation/repositories'; @Module({ imports: [ConfigModule, SpaceRepositoryModule, DeviceStatusFirebaseModule], @@ -30,6 +31,7 @@ import { SceneDeviceRepository } from '@app/common/modules/scene-device/reposito SceneIconRepository, SceneRepository, SceneDeviceRepository, + AutomationRepository, ], exports: [AutomationService], }) diff --git a/src/automation/controllers/automation.controller.ts b/src/automation/controllers/automation.controller.ts index 8d9d1ab..864d551 100644 --- a/src/automation/controllers/automation.controller.ts +++ b/src/automation/controllers/automation.controller.ts @@ -16,10 +16,11 @@ import { UpdateAutomationDto, UpdateAutomationStatusDto, } from '../dtos/automation.dto'; -import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; import { AutomationParamDto, SpaceParamDto } from '../dtos'; import { ControllerRoute } from '@app/common/constants/controller-route'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { Permissions } from 'src/decorators/permissions.decorator'; @ApiTags('Automation Module') @Controller({ @@ -30,7 +31,8 @@ export class AutomationController { constructor(private readonly automationService: AutomationService) {} @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('AUTOMATION_ADD') @Post() @ApiOperation({ summary: ControllerRoute.AUTOMATION.ACTIONS.ADD_AUTOMATION_SUMMARY, @@ -48,7 +50,8 @@ export class AutomationController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('AUTOMATION_VIEW') @Get(':spaceUuid') @ApiOperation({ summary: ControllerRoute.AUTOMATION.ACTIONS.GET_AUTOMATION_BY_SPACE_SUMMARY, @@ -63,7 +66,8 @@ export class AutomationController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('AUTOMATION_VIEW') @Get('details/:automationUuid') @ApiOperation({ summary: ControllerRoute.AUTOMATION.ACTIONS.GET_AUTOMATION_DETAILS_SUMMARY, @@ -78,7 +82,8 @@ export class AutomationController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('AUTOMATION_DELETE') @Delete(':automationUuid') @ApiOperation({ summary: ControllerRoute.AUTOMATION.ACTIONS.DELETE_AUTOMATION_SUMMARY, @@ -94,7 +99,8 @@ export class AutomationController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('AUTOMATION_UPDATE') @Put(':automationUuid') @ApiOperation({ summary: ControllerRoute.AUTOMATION.ACTIONS.UPDATE_AUTOMATION_SUMMARY, @@ -118,7 +124,8 @@ export class AutomationController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('AUTOMATION_CONTROL') @Put('status/:automationUuid') @ApiOperation({ summary: diff --git a/src/automation/interface/automation.interface.ts b/src/automation/interface/automation.interface.ts index 01523a0..146ec56 100644 --- a/src/automation/interface/automation.interface.ts +++ b/src/automation/interface/automation.interface.ts @@ -60,4 +60,5 @@ export interface AddAutomationParams { effectiveTime: EffectiveTime; decisionExpr: string; spaceTuyaId: string; + spaceUuid: string; } diff --git a/src/automation/services/automation.service.ts b/src/automation/services/automation.service.ts index d28b944..8999533 100644 --- a/src/automation/services/automation.service.ts +++ b/src/automation/services/automation.service.ts @@ -21,7 +21,6 @@ import { AutomationDetailsResult, AutomationResponseData, Condition, - GetAutomationBySpaceInterface, } from '../interface/automation.interface'; import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter'; import { @@ -35,6 +34,11 @@ 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'; @Injectable() export class AutomationService { @@ -46,6 +50,7 @@ export class AutomationService { private readonly tuyaService: TuyaService, private readonly sceneDeviceRepository: SceneDeviceRepository, private readonly sceneRepository: SceneRepository, + private readonly automationRepository: AutomationRepository, ) { const accessKey = this.configService.get('auth-config.ACCESS_KEY'); const secretKey = this.configService.get('auth-config.SECRET_KEY'); @@ -57,7 +62,9 @@ export class AutomationService { }); } - async addAutomation(addAutomationDto: AddAutomationDto) { + async addAutomation( + addAutomationDto: AddAutomationDto, + ): Promise { try { const { automationName, @@ -65,30 +72,37 @@ export class AutomationService { decisionExpr, actions, conditions, + spaceUuid, } = addAutomationDto; - const space = await this.getSpaceByUuid(addAutomationDto.spaceUuid); - const response = await this.add({ + const space = await this.getSpaceByUuid(spaceUuid); + const automation = await this.add({ automationName, effectiveTime, decisionExpr, actions, conditions, spaceTuyaId: space.spaceTuyaUuid, + spaceUuid, + }); + return new SuccessResponseDto({ + message: `Successfully created new automation with uuid ${automation.uuid}`, + data: automation, + statusCode: HttpStatus.CREATED, }); - return response; } catch (err) { - if (err instanceof BadRequestException) { - throw err; - } else { - throw new HttpException( - err.message || 'Automation not found', - err.status || HttpStatus.NOT_FOUND, - ); - } + console.error( + `Error in creating automation for space UUID ${addAutomationDto.spaceUuid}:`, + err.message, + ); + throw err instanceof HttpException + ? err + : new HttpException( + 'Failed to create automation', + HttpStatus.INTERNAL_SERVER_ERROR, + ); } } - - async add(params: AddAutomationParams) { + async createAutomationExternalService(params: AddAutomationParams) { try { const formattedActions = await this.prepareActions(params.actions); const formattedCondition = await this.prepareConditions( @@ -104,14 +118,54 @@ export class AutomationService { formattedActions, ); - return { - id: response?.result.id, - }; - } catch (error) { - throw new HttpException( - error.message || 'Failed to add automation', - error.status || HttpStatus.INTERNAL_SERVER_ERROR, - ); + 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) { + try { + const response = await this.createAutomationExternalService(params); + + 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, + ); + } } } @@ -144,43 +198,97 @@ export class AutomationService { async getAutomationBySpace(spaceUuid: string) { try { - const space = await this.getSpaceByUuid(spaceUuid); - if (!space.spaceTuyaUuid) { - throw new BadRequestException(`Invalid space UUID ${spaceUuid}`); - } - - const path = `/v2.0/cloud/scene/rule?space_id=${space.spaceTuyaUuid}&type=automation`; - const response: GetAutomationBySpaceInterface = await this.tuya.request({ - method: 'GET', - path, + // Fetch automation data from the repository + const automationData = await this.automationRepository.find({ + where: { + space: { uuid: spaceUuid }, + disabled: false, + }, + relations: ['space'], }); - if (!response.success) { - throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); - } + // Safe fetch function to handle individual automation fetching + const safeFetch = async (automation: any) => { + try { + const automationDetails = (await this.tuyaService.getSceneRule( + automation.automationTuyaUuid, + )) as { result?: { name?: string; status?: string } }; // Explicit type assertion + + if ( + !automationDetails?.result?.name || + automationDetails.result.name.startsWith(AUTO_PREFIX) + ) { + return null; // Skip invalid or auto-generated automations + } - return response.result.list - .filter((item) => item.name && !item.name.startsWith(AUTO_PREFIX)) - .map((item) => { return { - id: item.id, - name: item.name, - status: item.status, + uuid: automation.uuid, + name: automationDetails.result.name, + status: automationDetails.result.status, type: AUTOMATION_TYPE, }; - }); + } catch (error) { + console.warn( + `Skipping automation with UUID: ${automation.uuid} due to error. ${error.message}`, + ); + return null; + } + }; + + // Process automations using safeFetch + const automations = await Promise.all(automationData.map(safeFetch)); + + return automations.filter(Boolean); // Remove null values } catch (err) { + console.error('Error retrieving automations:', err); if (err instanceof BadRequestException) { - throw err; // Re-throw BadRequestException + throw err; + } + throw new HttpException( + err.message || 'Automation not found', + err.status || HttpStatus.NOT_FOUND, + ); + } + } + async findAutomationBySpace(spaceUuid: string) { + try { + await this.getSpaceByUuid(spaceUuid); + + const automationData = await this.automationRepository.find({ + where: { + space: { uuid: spaceUuid }, + disabled: false, + }, + relations: ['space'], + }); + + const automations = await Promise.all( + automationData.map(async (automation) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { actions, ...automationDetails } = + await this.getAutomation(automation); + + return automationDetails; + }), + ); + + return automations; + } catch (err) { + console.error( + `Error fetching Tap-to-Run scenes for space UUID ${spaceUuid}:`, + err.message, + ); + + if (err instanceof HttpException) { + throw err; } else { throw new HttpException( - err.message || 'Automation not found', - err.status || HttpStatus.NOT_FOUND, + 'An error occurred while retrieving scenes', + HttpStatus.INTERNAL_SERVER_ERROR, ); } } } - async getTapToRunSceneDetailsTuya( sceneUuid: string, ): Promise { @@ -213,13 +321,34 @@ export class AutomationService { } } } - async getAutomationDetails(automationUuid: string, withSpaceId = false) { + async getAutomationDetails(automationUuid: string) { try { - const path = `/v2.0/cloud/scene/rule/${automationUuid}`; - const response = await this.tuya.request({ - method: 'GET', - path, - }); + const automation = await this.findAutomation(automationUuid); + + const automationDetails = await this.getAutomation(automation); + + return automationDetails; + } catch (error) { + console.error( + `Error fetching automation details for automationUuid ${automationUuid}:`, + error, + ); + + if (error instanceof HttpException) { + throw error; + } else { + throw new HttpException( + 'An error occurred while retrieving automation details', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + } + async getAutomation(automation: AutomationEntity) { + try { + const response = await this.tuyaService.getSceneRule( + automation.automationTuyaUuid, + ); if (!response.success) { throw new HttpException(response.msg, HttpStatus.BAD_REQUEST); @@ -261,6 +390,11 @@ export class AutomationService { action.entityId = scene.uuid; action.iconUuid = scene.sceneIcon.uuid; action.icon = scene.sceneIcon.icon; + } else if (sceneDetails.type === ActionTypeEnum.AUTOMATION) { + const automation = await this.automationRepository.findOne({ + where: { automationTuyaUuid: action.entityId }, + }); + action.entityId = automation.uuid; } action.name = sceneDetails.name; action.type = sceneDetails.type; @@ -291,91 +425,95 @@ export class AutomationService { responseData.effectiveTime || {}; return { - id: responseData.id, + uuid: automation.uuid, name: responseData.name, status: responseData.status, type: 'automation', ...(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { spaceId, runningMode, ...rest } = responseData; + const { spaceId, id, runningMode, ...rest } = responseData; return rest; })(), actions, conditions, effectiveTime: effectiveTimeWithoutTimeZoneId, // Use modified effectiveTime - ...(withSpaceId && { spaceId: responseData.spaceId }), }; } catch (err) { if (err instanceof BadRequestException) { - throw err; // Re-throw BadRequestException + throw err; } else { - throw new HttpException('Automation not found', HttpStatus.NOT_FOUND); + throw new HttpException( + `An error occurred while retrieving automation details for ${automation.uuid}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } } } + async findAutomation(sceneUuid: string): Promise { + const automation = await this.automationRepository.findOne({ + where: { uuid: sceneUuid }, + relations: ['space'], + }); + + if (!automation) { + throw new HttpException( + `Invalid automation with id ${sceneUuid}`, + HttpStatus.NOT_FOUND, + ); + } + return automation; + } async deleteAutomation(param: AutomationParamDto) { + const { automationUuid } = param; + try { - const { automationUuid } = param; - - const automation = await this.getAutomationDetails(automationUuid, true); - - if (!automation && !automation.spaceId) { - throw new HttpException( - `Invalid automationid ${automationUuid}`, - HttpStatus.BAD_REQUEST, - ); - } + const automationData = await this.findAutomation(automationUuid); + const space = await this.getSpaceByUuid(automationData.space.uuid); + await this.delete(automationData.automationTuyaUuid, space.spaceTuyaUuid); const existingSceneDevice = await this.sceneDeviceRepository.findOne({ - where: { automationTuyaUuid: automationUuid }, + where: { automationTuyaUuid: automationData.automationTuyaUuid }, }); if (existingSceneDevice) { await this.sceneDeviceRepository.delete({ - automationTuyaUuid: automationUuid, + automationTuyaUuid: automationData.automationTuyaUuid, }); } - - const response = this.tuyaService.deleteAutomation( - automation.spaceId, - automationUuid, + await this.automationRepository.update( + { + uuid: automationUuid, + }, + { disabled: true }, ); - return response; + 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', + err.message || `Automation not found for id ${param.automationUuid}`, err.status || HttpStatus.NOT_FOUND, ); } } } - - async delete(tuyaSpaceId: string, automationUuid: string) { + async delete(tuyaAutomationId: string, tuyaSpaceId: string) { try { - const existingSceneDevice = await this.sceneDeviceRepository.findOne({ - where: { automationTuyaUuid: automationUuid }, - }); - - if (existingSceneDevice) { - await this.sceneDeviceRepository.delete({ - automationTuyaUuid: automationUuid, - }); - } - const response = await this.tuyaService.deleteAutomation( + const response = (await this.tuyaService.deleteSceneRule( + tuyaAutomationId, tuyaSpaceId, - automationUuid, - ); + )) as DeleteTapToRunSceneInterface; return response; - } catch (err) { - if (err instanceof HttpException) { - throw err; + } catch (error) { + if (error instanceof HttpException) { + throw error; } else { throw new HttpException( - err.message || 'Automation not found', - err.status || HttpStatus.NOT_FOUND, + 'Failed to delete automation rule in Tuya', + HttpStatus.INTERNAL_SERVER_ERROR, ); } } @@ -429,17 +567,13 @@ export class AutomationService { automationUuid: string, ) { try { - const automation = await this.getAutomationDetails(automationUuid, true); - if (!automation.spaceId) { - throw new HttpException( - "Automation doesn't exist", - HttpStatus.NOT_FOUND, - ); - } + const automation = await this.findAutomation(automationUuid); + const space = await this.getSpaceByUuid(automation.space.uuid); + const updateTuyaAutomationResponse = await this.updateAutomationExternalService( - automation.spaceId, - automation.id, + space.spaceTuyaUuid, + automation.automationTuyaUuid, updateAutomationDto, ); @@ -449,6 +583,16 @@ export class AutomationService { HttpStatus.BAD_GATEWAY, ); } + const updatedScene = await this.automationRepository.update( + { uuid: automationUuid }, + { + space: { uuid: automation.space.uuid }, + }, + ); + return new SuccessResponseDto({ + data: updatedScene, + message: `Automation with ID ${automationUuid} updated successfully`, + }); } catch (err) { if (err instanceof BadRequestException) { throw err; // Re-throw BadRequestException @@ -466,6 +610,7 @@ export class AutomationService { ) { const { isEnable, spaceUuid } = updateAutomationStatusDto; try { + const automation = await this.findAutomation(automationUuid); const space = await this.getSpaceByUuid(spaceUuid); if (!space.spaceTuyaUuid) { throw new HttpException( @@ -476,7 +621,7 @@ export class AutomationService { const response = await this.tuyaService.updateAutomationState( space.spaceTuyaUuid, - automationUuid, + automation.automationTuyaUuid, isEnable, ); @@ -521,6 +666,14 @@ export class AutomationService { action.entity_id = scene.sceneTuyaUuid; } } + } else if ( + action.action_executor === ActionExecutorEnum.RULE_DISABLE || + action.action_executor === ActionExecutorEnum.RULE_ENABLE + ) { + if (action.action_type === ActionTypeEnum.AUTOMATION) { + const automation = await this.findAutomation(action.entity_id); + action.entity_id = automation.automationTuyaUuid; + } } }), ); diff --git a/src/common/filters/http-exception/http-exception.filter.ts b/src/common/filters/http-exception/http-exception.filter.ts index c587769..40f6bb1 100644 --- a/src/common/filters/http-exception/http-exception.filter.ts +++ b/src/common/filters/http-exception/http-exception.filter.ts @@ -5,34 +5,55 @@ import { HttpException, HttpStatus, } from '@nestjs/common'; -import { Response } from 'express'; +import { Request, Response } from 'express'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { - catch(exception: unknown, host: ArgumentsHost) { + catch(exception: unknown, host: ArgumentsHost): void { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); - const status = - exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; - const message = - exception instanceof HttpException - ? exception.getResponse() - : 'Internal server error'; + const status = this.getStatus(exception); + const errorMessage = this.getErrorMessage(exception); + const formattedStatus = this.formatStatus(status); const errorResponse = { statusCode: status, timestamp: new Date().toISOString(), path: request.url, - error: message, + error: + typeof errorMessage === 'string' + ? { + message: errorMessage, + error: formattedStatus, + statusCode: status, + } + : errorMessage, }; - // Optionally log the exception - console.error(`Error occurred:`, exception); - + console.error('Error occurred:', exception); response.status(status).json(errorResponse); } + + private getStatus(exception: unknown): HttpStatus { + return exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + } + + private getErrorMessage(exception: unknown): string | object { + return exception instanceof HttpException + ? exception.getResponse() + : 'Internal server error'; + } + + private formatStatus(status: HttpStatus): string { + return HttpStatus[status] + .toLowerCase() + .replace('_', ' ') + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } } diff --git a/src/community/community.module.ts b/src/community/community.module.ts index 106d9d1..a87ef85 100644 --- a/src/community/community.module.ts +++ b/src/community/community.module.ts @@ -9,6 +9,7 @@ import { UserRepositoryModule } from '@app/common/modules/user/user.repository.m import { SpacePermissionService } from '@app/common/helper/services'; import { CommunityRepository } from '@app/common/modules/community/repositories'; import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; +import { ProjectRepository } from '@app/common/modules/project/repositiories'; @Module({ imports: [ConfigModule, SpaceRepositoryModule, UserRepositoryModule], @@ -20,6 +21,7 @@ import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service TuyaService, CommunityRepository, SpacePermissionService, + ProjectRepository, ], exports: [CommunityService, SpacePermissionService], }) diff --git a/src/community/controllers/community.controller.ts b/src/community/controllers/community.controller.ts index 86bad74..e823989 100644 --- a/src/community/controllers/community.controller.ts +++ b/src/community/controllers/community.controller.ts @@ -15,10 +15,12 @@ import { AddCommunityDto } from '../dtos/add.community.dto'; import { GetCommunityParams } from '../dtos/get.community.dto'; import { UpdateCommunityNameDto } from '../dtos/update.community.dto'; // import { CheckUserCommunityGuard } from 'src/guards/user.community.guard'; -import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { ControllerRoute } from '@app/common/constants/controller-route'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { PaginationRequestGetListDto } from '@app/common/dto/pagination.request.dto'; +import { ProjectParam } from '../dtos'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { Permissions } from 'src/decorators/permissions.decorator'; @ApiTags('Community Module') @Controller({ @@ -29,20 +31,23 @@ export class CommunityController { constructor(private readonly communityService: CommunityService) {} @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('COMMUNITY_ADD') @Post() @ApiOperation({ summary: ControllerRoute.COMMUNITY.ACTIONS.CREATE_COMMUNITY_SUMMARY, description: ControllerRoute.COMMUNITY.ACTIONS.CREATE_COMMUNITY_DESCRIPTION, }) async createCommunity( + @Param() param: ProjectParam, @Body() addCommunityDto: AddCommunityDto, ): Promise { - return await this.communityService.createCommunity(addCommunityDto); + return await this.communityService.createCommunity(param, addCommunityDto); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('COMMUNITY_VIEW') @ApiOperation({ summary: ControllerRoute.COMMUNITY.ACTIONS.GET_COMMUNITY_BY_ID_SUMMARY, description: @@ -52,49 +57,50 @@ export class CommunityController { async getCommunityByUuid( @Param() params: GetCommunityParams, ): Promise { - return await this.communityService.getCommunityById(params.communityUuid); + return await this.communityService.getCommunityById(params); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('COMMUNITY_VIEW') @ApiOperation({ summary: ControllerRoute.COMMUNITY.ACTIONS.LIST_COMMUNITY_SUMMARY, description: ControllerRoute.COMMUNITY.ACTIONS.LIST_COMMUNITY_DESCRIPTION, }) @Get() async getCommunities( + @Param() param: ProjectParam, @Query() query: PaginationRequestGetListDto, ): Promise { - return this.communityService.getCommunities(query); + return this.communityService.getCommunities(param, query); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('COMMUNITY_UPDATE') @ApiOperation({ summary: ControllerRoute.COMMUNITY.ACTIONS.UPDATE_COMMUNITY_SUMMARY, description: ControllerRoute.COMMUNITY.ACTIONS.UPDATE_COMMUNITY_DESCRIPTION, }) @Put('/:communityUuid') async updateCommunity( - @Param() param: GetCommunityParams, + @Param() params: GetCommunityParams, @Body() updateCommunityDto: UpdateCommunityNameDto, ) { - return this.communityService.updateCommunity( - param.communityUuid, - updateCommunityDto, - ); + return this.communityService.updateCommunity(params, updateCommunityDto); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('COMMUNITY_DELETE') @Delete('/:communityUuid') @ApiOperation({ summary: ControllerRoute.COMMUNITY.ACTIONS.DELETE_COMMUNITY_SUMMARY, description: ControllerRoute.COMMUNITY.ACTIONS.DELETE_COMMUNITY_DESCRIPTION, }) async deleteCommunity( - @Param() param: GetCommunityParams, + @Param() params: GetCommunityParams, ): Promise { - return this.communityService.deleteCommunity(param.communityUuid); + return this.communityService.deleteCommunity(params); } } diff --git a/src/community/dtos/get.community.dto.ts b/src/community/dtos/get.community.dto.ts index fe2cf46..d6da53a 100644 --- a/src/community/dtos/get.community.dto.ts +++ b/src/community/dtos/get.community.dto.ts @@ -10,6 +10,7 @@ import { IsUUID, Min, } from 'class-validator'; +import { ProjectParam } from './project.param.dto'; export class GetCommunityDto { @ApiProperty({ @@ -21,7 +22,7 @@ export class GetCommunityDto { public communityUuid: string; } -export class GetCommunityParams { +export class GetCommunityParams extends ProjectParam { @ApiProperty({ description: 'Community id of the specific community', required: true, diff --git a/src/community/dtos/index.ts b/src/community/dtos/index.ts index 7119b23..34d8bbb 100644 --- a/src/community/dtos/index.ts +++ b/src/community/dtos/index.ts @@ -1 +1,3 @@ export * from './add.community.dto'; +export * from './project.param.dto'; +export * from './get.community.dto'; diff --git a/src/community/dtos/project.param.dto.ts b/src/community/dtos/project.param.dto.ts new file mode 100644 index 0000000..8bb2929 --- /dev/null +++ b/src/community/dtos/project.param.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class ProjectParam { + @ApiProperty({ + description: 'UUID of the project this community belongs to', + example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', + }) + @IsUUID() + projectUuid: string; +} diff --git a/src/community/services/community.service.ts b/src/community/services/community.service.ts index e833416..7bfc9bc 100644 --- a/src/community/services/community.service.ts +++ b/src/community/services/community.service.ts @@ -1,5 +1,5 @@ import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; -import { AddCommunityDto } from '../dtos'; +import { AddCommunityDto, GetCommunityParams, ProjectParam } from '../dtos'; import { UpdateCommunityNameDto } from '../dtos/update.community.dto'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { @@ -11,31 +11,33 @@ import { CommunityRepository } from '@app/common/modules/community/repositories' import { CommunityDto } from '@app/common/modules/community/dtos'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; +import { ProjectRepository } from '@app/common/modules/project/repositiories'; +import { ORPHAN_COMMUNITY_NAME } from '@app/common/constants/orphan-constant'; +import { Not } from 'typeorm'; @Injectable() export class CommunityService { constructor( private readonly communityRepository: CommunityRepository, + private readonly projectRepository: ProjectRepository, private readonly tuyaService: TuyaService, ) {} - async createCommunity(dto: AddCommunityDto): Promise { + async createCommunity( + param: ProjectParam, + dto: AddCommunityDto, + ): Promise { const { name, description } = dto; - const existingCommunity = await this.communityRepository.findOneBy({ - name, - }); - if (existingCommunity) { - throw new HttpException( - `A community with the name '${name}' already exists.`, - HttpStatus.BAD_REQUEST, - ); - } + const project = await this.validateProject(param.projectUuid); + + await this.validateName(name); // Create the new community entity const community = this.communityRepository.create({ name: name, description: description, + project: project, }); // Save the community to the database @@ -54,7 +56,11 @@ export class CommunityService { } } - async getCommunityById(communityUuid: string): Promise { + async getCommunityById(params: GetCommunityParams): Promise { + const { communityUuid, projectUuid } = params; + + await this.validateProject(projectUuid); + const community = await this.communityRepository.findOneBy({ uuid: communityUuid, }); @@ -75,25 +81,44 @@ export class CommunityService { } async getCommunities( + param: ProjectParam, pageable: Partial, ): Promise { - pageable.modelName = 'community'; + try { + const project = await this.validateProject(param.projectUuid); - const customModel = TypeORMCustomModel(this.communityRepository); + pageable.modelName = 'community'; + pageable.where = { + project: { uuid: param.projectUuid }, + name: Not(`${ORPHAN_COMMUNITY_NAME}-${project.name}`), + }; - const { baseResponseDto, paginationResponseDto } = - await customModel.findAll(pageable); + const customModel = TypeORMCustomModel(this.communityRepository); - return new PageResponse( - baseResponseDto, - paginationResponseDto, - ); + const { baseResponseDto, paginationResponseDto } = + await customModel.findAll(pageable); + + return new PageResponse( + baseResponseDto, + paginationResponseDto, + ); + } catch (error) { + // Generic error handling + throw new HttpException( + error.message || 'An error occurred while fetching communities.', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } } async updateCommunity( - communityUuid: string, + params: GetCommunityParams, updateCommunityDto: UpdateCommunityNameDto, ): Promise { + const { communityUuid, projectUuid } = params; + + const project = await this.validateProject(projectUuid); + const community = await this.communityRepository.findOne({ where: { uuid: communityUuid }, }); @@ -106,8 +131,16 @@ export class CommunityService { ); } + if (community.name === `${ORPHAN_COMMUNITY_NAME}-${project.name}`) { + throw new HttpException( + `Community with ID ${communityUuid} cannot be updated`, + HttpStatus.BAD_REQUEST, + ); + } + try { const { name } = updateCommunityDto; + if (name != community.name) await this.validateName(name); community.name = name; @@ -123,12 +156,19 @@ export class CommunityService { throw err; // If it's an HttpException, rethrow it } else { // Throw a generic 404 error if anything else goes wrong - throw new HttpException('Community not found', HttpStatus.NOT_FOUND); + throw new HttpException( + `An Internal exception has been occured ${err}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); } } } - async deleteCommunity(communityUuid: string): Promise { + async deleteCommunity(params: GetCommunityParams): Promise { + const { communityUuid, projectUuid } = params; + + const project = await this.validateProject(projectUuid); + const community = await this.communityRepository.findOne({ where: { uuid: communityUuid }, }); @@ -140,6 +180,14 @@ export class CommunityService { HttpStatus.NOT_FOUND, ); } + + if (community.name === `${ORPHAN_COMMUNITY_NAME}-${project.name}`) { + throw new HttpException( + `Community with ID ${communityUuid} cannot be deleted`, + HttpStatus.BAD_REQUEST, + ); + } + try { await this.communityRepository.remove(community); @@ -169,4 +217,29 @@ export class CommunityService { ); } } + + private async validateProject(uuid: string) { + const project = await this.projectRepository.findOne({ + where: { uuid }, + }); + if (!project) { + throw new HttpException( + `A project with the uuid '${uuid}' doesn't exists.`, + HttpStatus.BAD_REQUEST, + ); + } + return project; + } + + private async validateName(name: string) { + const existingCommunity = await this.communityRepository.findOneBy({ + name, + }); + if (existingCommunity) { + throw new HttpException( + `A community with the name '${name}' already exists.`, + HttpStatus.BAD_REQUEST, + ); + } + } } diff --git a/src/decorators/permissions.decorator.ts b/src/decorators/permissions.decorator.ts new file mode 100644 index 0000000..ad21bf4 --- /dev/null +++ b/src/decorators/permissions.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const Permissions = (...permissions: string[]) => + SetMetadata('permissions', permissions); diff --git a/src/device/controllers/device.controller.ts b/src/device/controllers/device.controller.ts index a49b1f7..1ec8c6c 100644 --- a/src/device/controllers/device.controller.ts +++ b/src/device/controllers/device.controller.ts @@ -28,15 +28,15 @@ import { GetSceneFourSceneDeviceDto, } from '../dtos/control.device.dto'; import { CheckRoomGuard } from 'src/guards/room.guard'; -import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { CheckDeviceGuard } from 'src/guards/device.guard'; -import { SuperAdminRoleGuard } from 'src/guards/super.admin.role.guard'; import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; import { CheckFourAndSixSceneDeviceTypeGuard } from 'src/guards/scene.device.type.guard'; import { ControllerRoute } from '@app/common/constants/controller-route'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { DeviceSceneParamDto } from '../dtos/device.param.dto'; import { DeleteSceneFromSceneDeviceDto } from '../dtos/delete.device.dto'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { Permissions } from 'src/decorators/permissions.decorator'; @ApiTags('Device Module') @Controller({ @@ -46,7 +46,8 @@ import { DeleteSceneFromSceneDeviceDto } from '../dtos/delete.device.dto'; export class DeviceController { constructor(private readonly deviceService: DeviceService) {} @ApiBearerAuth() - @UseGuards(SuperAdminRoleGuard, CheckDeviceGuard) + @UseGuards(PermissionsGuard, CheckDeviceGuard) + @Permissions('SPACE_DEVICE_ASSIGN_DEVICE_TO_SPACE') @Post() @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.ADD_DEVICE_TO_USER_SUMMARY, @@ -63,7 +64,8 @@ export class DeviceController { }; } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_VIEW') @Get('user/:userUuid') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.GET_DEVICES_BY_USER_SUMMARY, @@ -74,7 +76,8 @@ export class DeviceController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SPACE_DEVICE_VIEW_DEVICE_IN_SPACE') @Get('space/:spaceUuid') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.GET_DEVICES_BY_SPACE_UUID_SUMMARY, @@ -85,7 +88,8 @@ export class DeviceController { return await this.deviceService.getDevicesBySpaceUuid(spaceUuid); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard, CheckRoomGuard) + @UseGuards(PermissionsGuard, CheckRoomGuard) + @Permissions('SUBSPACE_DEVICE_UPDATE_DEVICE_IN_SUBSPACE') @Put('space') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.UPDATE_DEVICE_IN_ROOM_SUMMARY, @@ -108,7 +112,8 @@ export class DeviceController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_VIEW') @Get(':deviceUuid') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.GET_DEVICE_DETAILS_SUMMARY, @@ -125,7 +130,8 @@ export class DeviceController { ); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_UPDATE') @Put(':deviceUuid') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.UPDATE_DEVICE_SUMMARY, @@ -149,7 +155,8 @@ export class DeviceController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_VIEW') @Get(':deviceUuid/functions') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.GET_DEVICE_INSTRUCTION_SUMMARY, @@ -162,7 +169,8 @@ export class DeviceController { return await this.deviceService.getDeviceInstructionByDeviceId(deviceUuid); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_VIEW') @Get(':deviceUuid/functions/status') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.GET_DEVICE_STATUS_SUMMARY, @@ -173,7 +181,8 @@ export class DeviceController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_SINGLE_CONTROL') @Post(':deviceUuid/control') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.CONTROL_DEVICE_SUMMARY, @@ -186,7 +195,8 @@ export class DeviceController { return await this.deviceService.controlDevice(controlDeviceDto, deviceUuid); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('FIRMWARE_CONTROL') @Post(':deviceUuid/firmware/:firmwareVersion') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.UPDATE_DEVICE_FIRMWARE_SUMMARY, @@ -203,7 +213,8 @@ export class DeviceController { ); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_VIEW') @Get('gateway/:gatewayUuid/devices') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.GET_DEVICES_IN_GATEWAY_SUMMARY, @@ -214,7 +225,8 @@ export class DeviceController { return await this.deviceService.getDevicesInGateway(gatewayUuid); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_VIEW') @Get() @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.GET_ALL_DEVICES_SUMMARY, @@ -225,7 +237,8 @@ export class DeviceController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_VIEW') @Get('report-logs/:deviceUuid') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.GET_DEVICE_LOGS_SUMMARY, @@ -238,7 +251,8 @@ export class DeviceController { return await this.deviceService.getDeviceLogs(deviceUuid, query); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_BATCH_CONTROL') @Post('control/batch') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.BATCH_CONTROL_DEVICES_SUMMARY, @@ -251,7 +265,8 @@ export class DeviceController { return await this.deviceService.batchControlDevices(batchControlDevicesDto); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_BATCH_CONTROL') @Get('status/batch') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.BATCH_STATUS_DEVICES_SUMMARY, @@ -264,7 +279,8 @@ export class DeviceController { return await this.deviceService.batchStatusDevices(batchStatusDevicesDto); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_DELETE') @Post('factory/reset/:deviceUuid') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.BATCH_FACTORY_RESET_DEVICES_SUMMARY, @@ -279,7 +295,8 @@ export class DeviceController { ); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_VIEW') @Get(':powerClampUuid/power-clamp/status') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.GET_POWER_CLAMP_STATUS_SUMMARY, @@ -294,7 +311,8 @@ export class DeviceController { ); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard, CheckFourAndSixSceneDeviceTypeGuard) + @UseGuards(PermissionsGuard, CheckFourAndSixSceneDeviceTypeGuard) + @Permissions('DEVICE_SINGLE_CONTROL') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.ADD_SCENE_TO_DEVICE_SUMMARY, description: ControllerRoute.DEVICE.ACTIONS.ADD_SCENE_TO_DEVICE_DESCRIPTION, @@ -317,7 +335,8 @@ export class DeviceController { }; } @ApiBearerAuth() - @UseGuards(JwtAuthGuard, CheckFourAndSixSceneDeviceTypeGuard) + @UseGuards(PermissionsGuard, CheckFourAndSixSceneDeviceTypeGuard) + @Permissions('DEVICE_VIEW') @Get(':deviceUuid/scenes') @ApiOperation({ summary: ControllerRoute.DEVICE.ACTIONS.GET_SCENES_BY_DEVICE_SUMMARY, @@ -334,7 +353,8 @@ export class DeviceController { ); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_DELETE') @Delete(':deviceUuid/scenes') @ApiOperation({ summary: diff --git a/src/device/device.module.ts b/src/device/device.module.ts index 39b1be6..2a372cc 100644 --- a/src/device/device.module.ts +++ b/src/device/device.module.ts @@ -19,6 +19,7 @@ import { SceneRepository, } from '@app/common/modules/scene/repositories'; import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; +import { AutomationRepository } from '@app/common/modules/automation/repositories'; @Module({ imports: [ ConfigModule, @@ -41,6 +42,7 @@ import { SceneDeviceRepository } from '@app/common/modules/scene-device/reposito SceneIconRepository, SceneRepository, SceneDeviceRepository, + AutomationRepository, ], exports: [DeviceService], }) diff --git a/src/device/services/device.service.ts b/src/device/services/device.service.ts index add62c9..45cdafd 100644 --- a/src/device/services/device.service.ts +++ b/src/device/services/device.service.ts @@ -41,7 +41,7 @@ import { import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter'; import { DeviceRepository } from '@app/common/modules/device/repositories'; import { PermissionType } from '@app/common/constants/permission-type.enum'; -import { In } from 'typeorm'; +import { In, QueryRunner } from 'typeorm'; import { ProductType } from '@app/common/constants/product-type.enum'; import { SpaceRepository } from '@app/common/modules/space/repositories'; import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service'; @@ -59,6 +59,7 @@ import { DeviceSceneParamDto } from '../dtos/device.param.dto'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { DeleteSceneFromSceneDeviceDto } from '../dtos/delete.device.dto'; +import { DeviceEntity } from '@app/common/modules/device/entities'; @Injectable() export class DeviceService { @@ -83,6 +84,7 @@ export class DeviceService { secretKey, }); } + async getDeviceByDeviceUuid( deviceUuid: string, withProductDevice: boolean = true, @@ -98,6 +100,29 @@ export class DeviceService { relations, }); } + async deleteDevice( + devices: DeviceEntity[], + orphanSpace: SpaceEntity, + queryRunner: QueryRunner, + ): Promise { + try { + const deviceIds = devices.map((device) => device.uuid); + + if (deviceIds.length > 0) { + await queryRunner.manager + .createQueryBuilder() + .update(DeviceEntity) + .set({ spaceDevice: orphanSpace }) + .whereInIds(deviceIds) + .execute(); + } + } catch (error) { + throw new HttpException( + `Failed to update devices to orphan space: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } async getDeviceByDeviceTuyaUuid(deviceTuyaUuid: string) { return await this.deviceRepository.findOne({ @@ -935,6 +960,7 @@ export class DeviceService { 'productDevice', 'permission', 'permission.permissionType', + 'subspace', ], }); @@ -993,12 +1019,14 @@ export class DeviceService { uuid: space.uuid, spaceName: space.spaceName, })), + productUuid: device.productDevice.uuid, productType: device.productDevice.prodType, community: { uuid: device.spaceDevice.community.uuid, name: device.spaceDevice.community.name, }, + subspace: device.subspace, // permissionType: device.permission[0].permissionType.type, ...(await this.getDeviceDetailsByDeviceIdTuya( device.deviceTuyaUuid, diff --git a/src/door-lock/door.lock.module.ts b/src/door-lock/door.lock.module.ts index 9bbc0d5..3cf563b 100644 --- a/src/door-lock/door.lock.module.ts +++ b/src/door-lock/door.lock.module.ts @@ -18,6 +18,7 @@ import { SceneRepository, } from '@app/common/modules/scene/repositories'; import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; +import { AutomationRepository } from '@app/common/modules/automation/repositories'; @Module({ imports: [ConfigModule, DeviceRepositoryModule], controllers: [DoorLockController], @@ -36,6 +37,7 @@ import { SceneDeviceRepository } from '@app/common/modules/scene-device/reposito SceneIconRepository, SceneRepository, SceneDeviceRepository, + AutomationRepository, ], exports: [DoorLockService], }) diff --git a/src/group/controllers/group.controller.ts b/src/group/controllers/group.controller.ts index a5d7cd7..a183cad 100644 --- a/src/group/controllers/group.controller.ts +++ b/src/group/controllers/group.controller.ts @@ -1,9 +1,10 @@ import { GroupService } from '../services/group.service'; import { Controller, Get, UseGuards, Param, Req } from '@nestjs/common'; import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; -import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; import { ControllerRoute } from '@app/common/constants/controller-route'; // Assuming this is where the routes are defined +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { Permissions } from 'src/decorators/permissions.decorator'; @ApiTags('Group Module') @Controller({ @@ -14,7 +15,8 @@ export class GroupController { constructor(private readonly groupService: GroupService) {} @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_WIZARD_VIEW_DEVICE_WIZARD') @Get(':spaceUuid') @ApiOperation({ summary: ControllerRoute.GROUP.ACTIONS.GET_GROUPS_BY_SPACE_UUID_SUMMARY, @@ -26,7 +28,8 @@ export class GroupController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('DEVICE_WIZARD_VIEW_DEVICE_WIZARD') @Get(':spaceUuid/devices/:groupName') @ApiOperation({ summary: diff --git a/src/guards/admin.role.guard.ts b/src/guards/admin.role.guard.ts index f7d64a0..db41d20 100644 --- a/src/guards/admin.role.guard.ts +++ b/src/guards/admin.role.guard.ts @@ -7,11 +7,12 @@ export class AdminRoleGuard extends AuthGuard('jwt') { if (err || !user) { throw err || new UnauthorizedException(); } else { - const isAdmin = user.roles.some( - (role) => - role.type === RoleType.SUPER_ADMIN || role.type === RoleType.ADMIN, - ); - if (!isAdmin) { + if ( + !( + user.role.type === RoleType.ADMIN || + user.role.type === RoleType.SUPER_ADMIN + ) + ) { throw new BadRequestException('Only admin role can access this route'); } } diff --git a/src/guards/community.permission.guard.ts b/src/guards/community.permission.guard.ts index 10d9c5e..12d87a8 100644 --- a/src/guards/community.permission.guard.ts +++ b/src/guards/community.permission.guard.ts @@ -20,10 +20,10 @@ export class CommunityPermissionGuard implements CanActivate { if ( user && - user.roles && - user.roles.some( - (role) => - role.type === RoleType.ADMIN || role.type === RoleType.SUPER_ADMIN, + user.role && + !( + user.role.type === RoleType.ADMIN || + user.role.type === RoleType.SUPER_ADMIN ) ) { return true; diff --git a/src/guards/permissions.guard.ts b/src/guards/permissions.guard.ts new file mode 100644 index 0000000..d50ff24 --- /dev/null +++ b/src/guards/permissions.guard.ts @@ -0,0 +1,67 @@ +import { + Injectable, + ExecutionContext, + UnauthorizedException, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { RolePermissions } from '@app/common/constants/role-permissions'; +import { RoleType } from '@app/common/constants/role.type.enum'; + +@Injectable() +export class PermissionsGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + async canActivate(context: ExecutionContext): Promise { + // First, run the AuthGuard logic to validate the JWT + const isAuthenticated = await super.canActivate(context); + if (!isAuthenticated) { + return false; + } + + // Authorization logic + const requiredPermissions = this.reflector.get( + 'permissions', + context.getHandler(), + ); + + if (!requiredPermissions) { + return true; // Allow if no permissions are specified + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; // User is now available after AuthGuard + + const userRole = user?.role?.type as RoleType; + if (!userRole || !RolePermissions[userRole]) { + throw new UnauthorizedException({ + message: `Only ${this.getAllowedRoles(requiredPermissions)} role(s) can access this route.`, + }); + } + + const userPermissions = RolePermissions[userRole]; + const hasRequiredPermissions = requiredPermissions.every((perm) => + userPermissions.includes(perm), + ); + + if (!hasRequiredPermissions) { + throw new UnauthorizedException({ + message: `Only ${this.getAllowedRoles(requiredPermissions)} role(s) can access this route.`, + }); + } + + return true; + } + + private getAllowedRoles(requiredPermissions: string[]): string { + const allowedRoles = Object.entries(RolePermissions) + .filter(([, permissions]) => + requiredPermissions.every((perm) => permissions.includes(perm)), + ) + .map(([role]) => role); + + return allowedRoles.join(', '); + } +} diff --git a/src/guards/super.admin.role.guard.ts b/src/guards/super.admin.role.guard.ts index ef93a75..b183d78 100644 --- a/src/guards/super.admin.role.guard.ts +++ b/src/guards/super.admin.role.guard.ts @@ -7,10 +7,7 @@ export class SuperAdminRoleGuard extends AuthGuard('jwt') { if (err || !user) { throw err || new UnauthorizedException(); } else { - const isSuperAdmin = user.roles.some( - (role) => role.type === RoleType.SUPER_ADMIN, - ); - if (!isSuperAdmin) { + if (!(user.role.type === RoleType.SUPER_ADMIN)) { throw new BadRequestException( 'Only super admin role can access this route', ); diff --git a/src/invite-user/controllers/index.ts b/src/invite-user/controllers/index.ts new file mode 100644 index 0000000..045b43b --- /dev/null +++ b/src/invite-user/controllers/index.ts @@ -0,0 +1 @@ +export * from './invite-user.controller'; diff --git a/src/invite-user/controllers/invite-user.controller.ts b/src/invite-user/controllers/invite-user.controller.ts new file mode 100644 index 0000000..1a5ac35 --- /dev/null +++ b/src/invite-user/controllers/invite-user.controller.ts @@ -0,0 +1,129 @@ +import { InviteUserService } from '../services/invite-user.service'; +import { + Body, + Controller, + Delete, + Param, + Post, + Put, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { AddUserInvitationDto } from '../dtos/add.invite-user.dto'; +import { ControllerRoute } from '@app/common/constants/controller-route'; +import { BaseResponseDto } from '@app/common/dto/base.response.dto'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { Permissions } from 'src/decorators/permissions.decorator'; +import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; +import { CheckEmailDto } from '../dtos/check-email.dto'; +import { ActivateCodeDto } from '../dtos/active-code.dto'; +import { + DisableUserInvitationDto, + UpdateUserInvitationDto, +} from '../dtos/update.invite-user.dto'; + +@ApiTags('Invite User Module') +@Controller({ + version: '1', + path: ControllerRoute.INVITE_USER.ROUTE, +}) +export class InviteUserController { + constructor(private readonly inviteUserService: InviteUserService) {} + + @ApiBearerAuth() + @UseGuards(PermissionsGuard) + @Permissions('USER_ADD') + @Post() + @ApiOperation({ + summary: ControllerRoute.INVITE_USER.ACTIONS.CREATE_USER_INVITATION_SUMMARY, + description: + ControllerRoute.INVITE_USER.ACTIONS.CREATE_USER_INVITATION_DESCRIPTION, + }) + async createUserInvitation( + @Body() addUserInvitationDto: AddUserInvitationDto, + @Req() request: any, + ): Promise { + const user = request.user; + return await this.inviteUserService.createUserInvitation( + addUserInvitationDto, + user.role.type, + ); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Post('check-email') + @ApiOperation({ + summary: ControllerRoute.INVITE_USER.ACTIONS.CHECK_EMAIL_SUMMARY, + description: ControllerRoute.INVITE_USER.ACTIONS.CHECK_EMAIL_DESCRIPTION, + }) + async checkEmailAndProject( + @Body() addUserInvitationDto: CheckEmailDto, + ): Promise { + return await this.inviteUserService.checkEmailAndProject( + addUserInvitationDto, + ); + } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Post('activation') + @ApiOperation({ + summary: ControllerRoute.INVITE_USER.ACTIONS.ACTIVATION_CODE_SUMMARY, + description: + ControllerRoute.INVITE_USER.ACTIONS.ACTIVATION_CODE_DESCRIPTION, + }) + async activationCodeController( + @Body() activateCodeDto: ActivateCodeDto, + ): Promise { + return await this.inviteUserService.activationCode(activateCodeDto); + } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Put(':invitedUserUuid') + @ApiOperation({ + summary: ControllerRoute.INVITE_USER.ACTIONS.UPDATE_USER_INVITATION_SUMMARY, + description: + ControllerRoute.INVITE_USER.ACTIONS.UPDATE_USER_INVITATION_DESCRIPTION, + }) + async updateUserInvitation( + @Param('invitedUserUuid') invitedUserUuid: string, + @Body() updateUserInvitationDto: UpdateUserInvitationDto, + ): Promise { + return await this.inviteUserService.updateUserInvitation( + updateUserInvitationDto, + invitedUserUuid, + ); + } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Put(':invitedUserUuid/disable') + @ApiOperation({ + summary: + ControllerRoute.INVITE_USER.ACTIONS.DISABLE_USER_INVITATION_SUMMARY, + description: + ControllerRoute.INVITE_USER.ACTIONS.DISABLE_USER_INVITATION_DESCRIPTION, + }) + async disableUserInvitation( + @Param('invitedUserUuid') invitedUserUuid: string, + @Body() disableUserInvitationDto: DisableUserInvitationDto, + ): Promise { + return await this.inviteUserService.disableUserInvitation( + disableUserInvitationDto, + invitedUserUuid, + ); + } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Delete(':invitedUserUuid') + @ApiOperation({ + summary: ControllerRoute.INVITE_USER.ACTIONS.DELETE_USER_INVITATION_SUMMARY, + description: + ControllerRoute.INVITE_USER.ACTIONS.DELETE_USER_INVITATION_DESCRIPTION, + }) + async deleteUserInvitation( + @Param('invitedUserUuid') invitedUserUuid: string, + ): Promise { + return await this.inviteUserService.deleteUserInvitation(invitedUserUuid); + } +} diff --git a/src/invite-user/dtos/active-code.dto.ts b/src/invite-user/dtos/active-code.dto.ts new file mode 100644 index 0000000..ea852aa --- /dev/null +++ b/src/invite-user/dtos/active-code.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty } from 'class-validator'; + +export class ActivateCodeDto { + @ApiProperty({ + description: 'The activation code of the user', + example: '7CvRcA', + required: true, + }) + @IsString() + @IsNotEmpty() + activationCode: string; + + @ApiProperty({ + description: 'The UUID of the user', + example: 'd7a44e8a-32d5-4f39-ae2e-013f1245aead', + required: true, + }) + @IsString() + @IsNotEmpty() + userUuid: string; +} diff --git a/src/invite-user/dtos/add.invite-user.dto.ts b/src/invite-user/dtos/add.invite-user.dto.ts new file mode 100644 index 0000000..0d9acbc --- /dev/null +++ b/src/invite-user/dtos/add.invite-user.dto.ts @@ -0,0 +1,83 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayMinSize, + IsArray, + IsNotEmpty, + IsOptional, + IsString, +} from 'class-validator'; + +export class AddUserInvitationDto { + @ApiProperty({ + description: 'The first name of the user', + example: 'John', + required: true, + }) + @IsString() + @IsNotEmpty() + public firstName: string; + + @ApiProperty({ + description: 'The last name of the user', + example: 'Doe', + required: true, + }) + @IsString() + @IsNotEmpty() + public lastName: string; + + @ApiProperty({ + description: 'The email of the user', + example: 'OqM9A@example.com', + required: true, + }) + @IsString() + @IsNotEmpty() + public email: string; + + @ApiProperty({ + description: 'The job title of the user', + example: 'Software Engineer', + required: false, + }) + @IsString() + @IsOptional() + public jobTitle?: string; + + @ApiProperty({ + description: 'The phone number of the user', + example: '+1234567890', + required: false, + }) + @IsString() + @IsOptional() + public phoneNumber?: string; + + @ApiProperty({ + description: 'The role uuid of the user', + example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', + required: true, + }) + @IsString() + @IsNotEmpty() + public roleUuid: string; + @ApiProperty({ + description: 'The project uuid of the user', + example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', + required: true, + }) + @IsString() + @IsNotEmpty() + public projectUuid: string; + @ApiProperty({ + description: 'The array of space UUIDs (at least one required)', + example: ['b5f3c9d2-58b7-4377-b3f7-60acb711d5d9'], + required: true, + }) + @IsArray() + @ArrayMinSize(1) + public spaceUuids: string[]; + constructor(dto: Partial) { + Object.assign(this, dto); + } +} diff --git a/src/invite-user/dtos/check-email.dto.ts b/src/invite-user/dtos/check-email.dto.ts new file mode 100644 index 0000000..79bcd70 --- /dev/null +++ b/src/invite-user/dtos/check-email.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsNotEmpty } from 'class-validator'; + +export class CheckEmailDto { + @ApiProperty({ + description: 'The email of the user', + example: 'OqM9A@example.com', + required: true, + }) + @IsEmail() + @IsNotEmpty() + email: string; + constructor(dto: Partial) { + Object.assign(this, dto); + } +} diff --git a/src/invite-user/dtos/index.ts b/src/invite-user/dtos/index.ts new file mode 100644 index 0000000..6cdec2a --- /dev/null +++ b/src/invite-user/dtos/index.ts @@ -0,0 +1 @@ +export * from './add.invite-user.dto'; diff --git a/src/invite-user/dtos/update.invite-user.dto.ts b/src/invite-user/dtos/update.invite-user.dto.ts new file mode 100644 index 0000000..6890ed1 --- /dev/null +++ b/src/invite-user/dtos/update.invite-user.dto.ts @@ -0,0 +1,96 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsNotEmpty, + IsOptional, + IsString, +} from 'class-validator'; + +export class UpdateUserInvitationDto { + @ApiProperty({ + description: 'The first name of the user', + example: 'John', + required: true, + }) + @IsString() + @IsNotEmpty() + public firstName: string; + + @ApiProperty({ + description: 'The last name of the user', + example: 'Doe', + required: true, + }) + @IsString() + @IsNotEmpty() + public lastName: string; + + @ApiProperty({ + description: 'The job title of the user', + example: 'Software Engineer', + required: false, + }) + @IsString() + @IsOptional() + public jobTitle?: string; + + @ApiProperty({ + description: 'The phone number of the user', + example: '+1234567890', + required: false, + }) + @IsString() + @IsOptional() + public phoneNumber?: string; + + @ApiProperty({ + description: 'The role uuid of the user', + example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', + required: true, + }) + @IsString() + @IsNotEmpty() + public roleUuid: string; + @ApiProperty({ + description: 'The project uuid of the user', + example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', + required: true, + }) + @IsString() + @IsNotEmpty() + public projectUuid: string; + @ApiProperty({ + description: 'The array of space UUIDs (at least one required)', + example: ['b5f3c9d2-58b7-4377-b3f7-60acb711d5d9'], + required: true, + }) + @IsArray() + @ArrayMinSize(1) + public spaceUuids: string[]; + constructor(dto: Partial) { + Object.assign(this, dto); + } +} +export class DisableUserInvitationDto { + @ApiProperty({ + description: 'The disable status of the user', + example: 'true', + required: true, + }) + @IsBoolean() + @IsNotEmpty() + public disable: boolean; + @ApiProperty({ + description: 'The project uuid of the user', + example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', + required: true, + }) + @IsString() + @IsNotEmpty() + public projectUuid: string; + constructor(dto: Partial) { + Object.assign(this, dto); + } +} diff --git a/src/invite-user/invite-user.module.ts b/src/invite-user/invite-user.module.ts new file mode 100644 index 0000000..7efbe2f --- /dev/null +++ b/src/invite-user/invite-user.module.ts @@ -0,0 +1,70 @@ +import { Module } from '@nestjs/common'; +import { InviteUserService } from './services/invite-user.service'; +import { InviteUserController } from './controllers/invite-user.controller'; +import { ConfigModule } from '@nestjs/config'; + +import { + UserRepository, + UserSpaceRepository, +} from '@app/common/modules/user/repositories'; +import { InviteUserRepositoryModule } from '@app/common/modules/Invite-user/Invite-user.repository.module'; +import { + InviteUserRepository, + InviteUserSpaceRepository, +} from '@app/common/modules/Invite-user/repositiories'; +import { EmailService } from '@app/common/util/email.service'; +import { SpaceUserService, ValidationService } from 'src/space/services'; +import { CommunityService } from 'src/community/services'; +import { + InviteSpaceRepository, + SpaceRepository, +} from '@app/common/modules/space'; +import { SpaceModelRepository } from '@app/common/modules/space-model'; +import { CommunityRepository } from '@app/common/modules/community/repositories'; +import { ProjectRepository } from '@app/common/modules/project/repositiories'; +import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; +import { UserService, UserSpaceService } from 'src/users/services'; +import { UserDevicePermissionService } from 'src/user-device-permission/services'; +import { + DeviceRepository, + DeviceUserPermissionRepository, +} from '@app/common/modules/device/repositories'; +import { PermissionTypeRepository } from '@app/common/modules/permission/repositories'; +import { ProjectUserService } from 'src/project/services/project-user.service'; +import { RoleTypeRepository } from '@app/common/modules/role-type/repositories'; +import { RegionRepository } from '@app/common/modules/region/repositories'; +import { TimeZoneRepository } from '@app/common/modules/timezone/repositories'; + +@Module({ + imports: [ConfigModule, InviteUserRepositoryModule], + controllers: [InviteUserController], + providers: [ + InviteUserService, + InviteUserRepository, + UserRepository, + EmailService, + InviteUserSpaceRepository, + SpaceUserService, + ValidationService, + UserSpaceRepository, + DeviceRepository, + CommunityService, + SpaceRepository, + SpaceModelRepository, + CommunityRepository, + ProjectRepository, + TuyaService, + UserSpaceService, + UserDevicePermissionService, + DeviceUserPermissionRepository, + PermissionTypeRepository, + ProjectUserService, + RoleTypeRepository, + InviteSpaceRepository, + UserService, + RegionRepository, + TimeZoneRepository, + ], + exports: [InviteUserService], +}) +export class InviteUserModule {} diff --git a/src/invite-user/services/index.ts b/src/invite-user/services/index.ts new file mode 100644 index 0000000..024e5a5 --- /dev/null +++ b/src/invite-user/services/index.ts @@ -0,0 +1 @@ +export * from './invite-user.service'; diff --git a/src/invite-user/services/invite-user.service.ts b/src/invite-user/services/invite-user.service.ts new file mode 100644 index 0000000..e466248 --- /dev/null +++ b/src/invite-user/services/invite-user.service.ts @@ -0,0 +1,819 @@ +import { + Injectable, + HttpException, + HttpStatus, + BadRequestException, +} from '@nestjs/common'; +import { AddUserInvitationDto } from '../dtos'; +import { BaseResponseDto } from '@app/common/dto/base.response.dto'; +import { UserStatusEnum } from '@app/common/constants/user-status.enum'; +import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; +import { generateRandomString } from '@app/common/helper/randomString'; +import { EntityManager, In, IsNull, Not, QueryRunner } from 'typeorm'; +import { DataSource } from 'typeorm'; +import { UserEntity } from '@app/common/modules/user/entities'; +import { RoleType } from '@app/common/constants/role.type.enum'; +import { + InviteUserRepository, + InviteUserSpaceRepository, +} from '@app/common/modules/Invite-user/repositiories'; +import { CheckEmailDto } from '../dtos/check-email.dto'; +import { UserRepository } from '@app/common/modules/user/repositories'; +import { EmailService } from '@app/common/util/email.service'; +import { SpaceEntity, SpaceRepository } from '@app/common/modules/space'; +import { ActivateCodeDto } from '../dtos/active-code.dto'; +import { UserSpaceService } from 'src/users/services'; +import { SpaceUserService } from 'src/space/services'; +import { + DisableUserInvitationDto, + UpdateUserInvitationDto, +} from '../dtos/update.invite-user.dto'; +import { RoleTypeRepository } from '@app/common/modules/role-type/repositories'; +import { InviteUserEntity } from '@app/common/modules/Invite-user/entities'; + +@Injectable() +export class InviteUserService { + constructor( + private readonly inviteUserRepository: InviteUserRepository, + private readonly userRepository: UserRepository, + private readonly emailService: EmailService, + private readonly inviteUserSpaceRepository: InviteUserSpaceRepository, + private readonly userSpaceService: UserSpaceService, + private readonly spaceUserService: SpaceUserService, + private readonly spaceRepository: SpaceRepository, + private readonly roleTypeRepository: RoleTypeRepository, + private readonly dataSource: DataSource, + ) {} + + async createUserInvitation( + dto: AddUserInvitationDto, + roleType: RoleType, + ): Promise { + const { + firstName, + lastName, + email, + jobTitle, + phoneNumber, + roleUuid, + spaceUuids, + projectUuid, + } = dto; + + const invitationCode = generateRandomString(6); + const queryRunner = this.dataSource.createQueryRunner(); + + await queryRunner.startTransaction(); + + try { + const userRepo = queryRunner.manager.getRepository(UserEntity); + await this.checkEmailAndProject({ email }); + + const existingUser = await userRepo.findOne({ + where: { + email, + project: Not(IsNull()), + }, + }); + + if (existingUser) { + throw new HttpException( + 'User already has a project', + HttpStatus.BAD_REQUEST, + ); + } + + // Validate spaces + const validSpaces = await this.validateSpaces( + spaceUuids, + queryRunner.manager, + ); + + // Create invitation + const inviteUser = this.inviteUserRepository.create({ + firstName, + lastName, + email, + jobTitle, + phoneNumber, + roleType: { uuid: roleUuid }, + status: UserStatusEnum.INVITED, + invitationCode, + invitedBy: roleType, + project: { uuid: projectUuid }, + }); + + const invitedUser = await queryRunner.manager.save(inviteUser); + + // Link user to spaces + const spacePromises = validSpaces.map(async (space) => { + const inviteUserSpace = this.inviteUserSpaceRepository.create({ + inviteUser: { uuid: invitedUser.uuid }, + space: { uuid: space.uuid }, + }); + return queryRunner.manager.save(inviteUserSpace); + }); + + await Promise.all(spacePromises); + + // Send invitation email + const spaceNames = validSpaces.map((space) => space.spaceName).join(', '); + await this.emailService.sendEmailWithInvitationTemplate(email, { + name: firstName, + invitationCode, + role: roleType, + spacesList: spaceNames, + }); + + await queryRunner.commitTransaction(); + + return new SuccessResponseDto({ + statusCode: HttpStatus.CREATED, + success: true, + data: { + invitationCode: invitedUser.invitationCode, + }, + message: 'User invited successfully', + }); + } catch (error) { + await queryRunner.rollbackTransaction(); + if (error instanceof HttpException) { + throw error; + } + console.error('Error creating user invitation:', error); + throw new HttpException( + error.message || 'An unexpected error occurred while inviting the user', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } finally { + await queryRunner.release(); + } + } + private async validateSpaces( + spaceUuids: string[], + entityManager: EntityManager, + ): Promise { + const spaceRepo = entityManager.getRepository(SpaceEntity); + + const validSpaces = await spaceRepo.find({ + where: { uuid: In(spaceUuids) }, + }); + + if (validSpaces.length !== spaceUuids.length) { + const validSpaceUuids = validSpaces.map((space) => space.uuid); + const invalidSpaceUuids = spaceUuids.filter( + (uuid) => !validSpaceUuids.includes(uuid), + ); + throw new HttpException( + `Invalid space UUIDs: ${invalidSpaceUuids.join(', ')}`, + HttpStatus.BAD_REQUEST, + ); + } + + return validSpaces; + } + + async checkEmailAndProject(dto: CheckEmailDto): Promise { + const { email } = dto; + + try { + const user = await this.userRepository.findOne({ + where: { email }, + relations: ['project'], + }); + this.validateUserOrInvite(user, 'User'); + const invitedUser = await this.inviteUserRepository.findOne({ + where: { email }, + relations: ['project'], + }); + this.validateUserOrInvite(invitedUser, 'Invited User'); + + return new SuccessResponseDto({ + statusCode: HttpStatus.OK, + success: true, + message: 'Valid email', + }); + } catch (error) { + console.error('Error checking email and project:', error); + throw new HttpException( + error.message || + 'An unexpected error occurred while checking the email', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private validateUserOrInvite(user: any, userType: string): void { + if (user) { + if (!user.isActive) { + throw new HttpException( + `${userType} is deleted`, + HttpStatus.BAD_REQUEST, + ); + } + if (user.project) { + throw new HttpException( + `${userType} already has a project`, + HttpStatus.BAD_REQUEST, + ); + } + } + } + + async activationCode(dto: ActivateCodeDto): Promise { + const { activationCode, userUuid } = dto; + + try { + const user = await this.getUser(userUuid); + + const invitedUser = await this.inviteUserRepository.findOne({ + where: { + email: user.email, + status: UserStatusEnum.INVITED, + isActive: true, + }, + relations: ['project', 'spaces.space.community', 'roleType'], + }); + + if (invitedUser) { + if (invitedUser.invitationCode !== activationCode) { + throw new HttpException( + 'Invalid activation code', + HttpStatus.BAD_REQUEST, + ); + } + + // Handle invited user with valid activation code + await this.handleInvitedUser(user, invitedUser); + } else { + // Handle case for non-invited user + await this.handleNonInvitedUser(activationCode, userUuid); + } + return new SuccessResponseDto({ + statusCode: HttpStatus.OK, + success: true, + message: 'The code has been successfully activated', + }); + } catch (error) { + console.error('Error activating the code:', error); + throw new HttpException( + error.message || + 'An unexpected error occurred while activating the code', + error.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async getUser(userUuid: string): Promise { + const user = await this.userRepository.findOne({ + where: { uuid: userUuid, isActive: true, isUserVerified: true }, + }); + + if (!user) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + return user; + } + + private async handleNonInvitedUser( + activationCode: string, + userUuid: string, + ): Promise { + await this.userSpaceService.verifyCodeAndAddUserSpace( + { inviteCode: activationCode }, + userUuid, + ); + } + + private async handleInvitedUser( + user: UserEntity, + invitedUser: InviteUserEntity, + ): Promise { + for (const invitedSpace of invitedUser.spaces) { + try { + const deviceUUIDs = await this.userSpaceService.getDeviceUUIDsForSpace( + invitedSpace.space.uuid, + ); + + await this.userSpaceService.addUserPermissionsToDevices( + user.uuid, + deviceUUIDs, + ); + + await this.spaceUserService.associateUserToSpace({ + communityUuid: invitedSpace.space.community.uuid, + spaceUuid: invitedSpace.space.uuid, + userUuid: user.uuid, + projectUuid: invitedUser.project.uuid, + }); + } catch (spaceError) { + console.error( + `Error processing space ${invitedSpace.space.uuid}:`, + spaceError, + ); + continue; // Skip to the next space + } + } + + // Update invited user and associated user data + await this.inviteUserRepository.update( + { uuid: invitedUser.uuid }, + { status: UserStatusEnum.ACTIVE, user: { uuid: user.uuid } }, + ); + await this.userRepository.update( + { uuid: user.uuid }, + { + project: { uuid: invitedUser.project.uuid }, + roleType: { uuid: invitedUser.roleType.uuid }, + }, + ); + } + + async updateUserInvitation( + dto: UpdateUserInvitationDto, + invitedUserUuid: string, + ): Promise { + const { projectUuid, spaceUuids } = dto; + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.startTransaction(); + + try { + // Fetch the user's existing data in the project + const userOldData = await this.inviteUserRepository.findOne({ + where: { uuid: invitedUserUuid, project: { uuid: projectUuid } }, + relations: ['project', 'spaces.space', 'roleType'], + }); + + if (!userOldData) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + + // Perform update actions if status is 'INVITED' + if (userOldData.status === UserStatusEnum.INVITED) { + await this.updateWhenUserIsInvite(queryRunner, dto, invitedUserUuid); + } else if (userOldData.status === UserStatusEnum.ACTIVE) { + await this.updateWhenUserIsInvite(queryRunner, dto, invitedUserUuid); + await this.updateWhenUserIsActive(queryRunner, dto, invitedUserUuid); + } + // Extract existing space UUIDs + const oldSpaceUuids = userOldData.spaces.map((space) => space.space.uuid); + + // Compare spaces + const addedSpaces = spaceUuids.filter( + (uuid) => !oldSpaceUuids.includes(uuid), + ); + const removedSpaces = oldSpaceUuids.filter( + (uuid) => !spaceUuids.includes(uuid), + ); + + // Fetch the space names for added and removed spaces + const spaceRepo = queryRunner.manager.getRepository(SpaceEntity); + const addedSpacesDetails = await spaceRepo.find({ + where: { + uuid: In(addedSpaces), + }, + }); + + const removedSpacesDetails = await spaceRepo.find({ + where: { + uuid: In(removedSpaces), + }, + }); + + // Extract the names of the added and removed spaces + const addedSpaceNames = addedSpacesDetails.map( + (space) => space.spaceName, + ); + const removedSpaceNames = removedSpacesDetails.map( + (space) => space.spaceName, + ); + + // Check for role and name change + const oldRole = userOldData.roleType.type; + const newRole = await this.getRoleTypeByUuid(dto.roleUuid); + const oldFullName = `${userOldData.firstName} ${userOldData.lastName}`; + const newFullName = `${dto.firstName} ${dto.lastName}`; + + // Generate email body + const emailMessage = this.emailService.generateUserChangesEmailBody( + addedSpaceNames, + removedSpaceNames, + oldRole, + newRole, + oldFullName, + newFullName, + ); + await this.emailService.sendEditUserEmailWithTemplate(userOldData.email, { + name: dto.firstName || userOldData.firstName, + ...emailMessage, + }); + + // Proceed with other updates (e.g., roles, names, etc.) + await queryRunner.commitTransaction(); + + return new SuccessResponseDto({ + statusCode: HttpStatus.OK, + success: true, + message: 'User invitation updated successfully', + }); + } catch (error) { + await queryRunner.rollbackTransaction(); + + // Throw an appropriate HTTP exception + throw error instanceof HttpException + ? error + : new HttpException( + 'An unexpected error occurred while updating the user', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } finally { + await queryRunner.release(); + } + } + private async getRoleTypeByUuid(roleUuid: string) { + const role = await this.roleTypeRepository.findOne({ + where: { uuid: roleUuid }, + }); + + return role.type; + } + + private async updateWhenUserIsInvite( + queryRunner: QueryRunner, + dto: UpdateUserInvitationDto, + invitedUserUuid: string, + ): Promise { + const { firstName, lastName, jobTitle, phoneNumber, roleUuid, spaceUuids } = + dto; + + // Update user invitation details + await queryRunner.manager.update( + this.inviteUserRepository.target, + { uuid: invitedUserUuid }, + { + firstName, + lastName, + jobTitle, + phoneNumber, + roleType: { uuid: roleUuid }, + }, + ); + + // Remove old space associations + await queryRunner.manager.delete(this.inviteUserSpaceRepository.target, { + inviteUser: { uuid: invitedUserUuid }, + }); + + // Save new space associations + const spaceData = spaceUuids.map((spaceUuid) => ({ + inviteUser: { uuid: invitedUserUuid }, + space: { uuid: spaceUuid }, + })); + await queryRunner.manager.save( + this.inviteUserSpaceRepository.target, + spaceData, + ); + } + private async updateWhenUserIsActive( + queryRunner: QueryRunner, + dto: UpdateUserInvitationDto, + invitedUserUuid: string, + ): Promise { + const { + firstName, + lastName, + jobTitle, + phoneNumber, + roleUuid, + spaceUuids, + projectUuid, + } = dto; + + const userData = await this.inviteUserRepository.findOne({ + where: { uuid: invitedUserUuid }, + relations: ['user.userSpaces.space', 'user.userSpaces.space.community'], + }); + + if (!userData) { + throw new HttpException( + `User with invitedUserUuid ${invitedUserUuid} not found`, + HttpStatus.NOT_FOUND, + ); + } + + // Update user details + await queryRunner.manager.update( + this.inviteUserRepository.target, + { uuid: invitedUserUuid }, + { + firstName, + lastName, + jobTitle, + phoneNumber, + roleType: { uuid: roleUuid }, + }, + ); + await this.userRepository.update( + { uuid: userData.user.uuid }, + { + roleType: { uuid: roleUuid }, + }, + ); + // Disassociate the user from all current spaces + const disassociatePromises = userData.user.userSpaces.map((userSpace) => + this.spaceUserService + .disassociateUserFromSpace({ + communityUuid: userSpace.space.community.uuid, + spaceUuid: userSpace.space.uuid, + userUuid: userData.user.uuid, + projectUuid, + }) + .catch((error) => { + console.error( + `Failed to disassociate user from space ${userSpace.space.uuid}:`, + error, + ); + throw error; + }), + ); + + await Promise.allSettled(disassociatePromises); + + // Process new spaces + const associatePromises = spaceUuids.map(async (spaceUuid) => { + try { + // Fetch space details + const spaceDetails = await this.getSpaceByUuid(spaceUuid); + + // Fetch device UUIDs for the space + const deviceUUIDs = + await this.userSpaceService.getDeviceUUIDsForSpace(spaceUuid); + + // Grant permissions to the user for all devices in the space + await this.userSpaceService.addUserPermissionsToDevices( + userData.user.uuid, + deviceUUIDs, + ); + + // Associate the user with the new space + await this.spaceUserService.associateUserToSpace({ + communityUuid: spaceDetails.communityUuid, + spaceUuid: spaceUuid, + userUuid: userData.user.uuid, + projectUuid, + }); + } catch (error) { + console.error(`Failed to process space ${spaceUuid}:`, error); + throw error; + } + }); + + await Promise.all(associatePromises); + } + + async getSpaceByUuid(spaceUuid: string) { + try { + const space = await this.spaceRepository.findOne({ + where: { + uuid: spaceUuid, + }, + 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, + communityUuid: space.community.uuid, + }; + } catch (err) { + if (err instanceof BadRequestException) { + throw err; // Re-throw BadRequestException + } else { + throw new HttpException('Space not found', HttpStatus.NOT_FOUND); + } + } + } + async disableUserInvitation( + dto: DisableUserInvitationDto, + invitedUserUuid: string, + ): Promise { + const { disable, projectUuid } = dto; + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.startTransaction(); + + try { + const userData = await this.inviteUserRepository.findOne({ + where: { uuid: invitedUserUuid, project: { uuid: projectUuid } }, + relations: ['roleType', 'spaces.space'], + }); + + if (!userData) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + + if (userData.status === UserStatusEnum.INVITED) { + await this.updateUserStatus(invitedUserUuid, projectUuid, !disable); + } else if (userData.status === UserStatusEnum.ACTIVE) { + const invitedUserData = await this.inviteUserRepository.findOne({ + where: { uuid: invitedUserUuid }, + relations: [ + 'user.userSpaces.space', + 'user.userSpaces.space.community', + ], + }); + + if (!invitedUserData.user) { + throw new HttpException( + 'User account not found', + HttpStatus.NOT_FOUND, + ); + } + + if (disable) { + await this.disassociateUserFromSpaces( + invitedUserData.user, + projectUuid, + ); + await this.updateUserStatus(invitedUserUuid, projectUuid, !disable); + } else if (!disable) { + await this.associateUserToSpaces( + invitedUserData.user, + userData, + projectUuid, + invitedUserUuid, + !disable, + ); + } + } else { + throw new HttpException( + 'Invalid user status or action', + HttpStatus.BAD_REQUEST, + ); + } + await this.emailService.sendEmailWithTemplate( + userData.email, + userData.firstName, + disable, + false, + ); + await queryRunner.commitTransaction(); + + return new SuccessResponseDto({ + statusCode: HttpStatus.OK, + success: true, + message: 'User invitation status updated successfully', + }); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + + private async updateUserStatus( + invitedUserUuid: string, + projectUuid: string, + isEnabled: boolean, + ) { + await this.inviteUserRepository.update( + { uuid: invitedUserUuid, project: { uuid: projectUuid } }, + { isEnabled }, + ); + } + + private async disassociateUserFromSpaces(user: any, projectUuid: string) { + const disassociatePromises = user.userSpaces.map((userSpace) => + this.spaceUserService.disassociateUserFromSpace({ + communityUuid: userSpace.space.community.uuid, + spaceUuid: userSpace.space.uuid, + userUuid: user.uuid, + projectUuid, + }), + ); + + const results = await Promise.allSettled(disassociatePromises); + + results.forEach((result, index) => { + if (result.status === 'rejected') { + console.error( + `Failed to disassociate user from space ${user.userSpaces[index].space.uuid}:`, + result.reason, + ); + } + }); + } + private async associateUserToSpaces( + user: any, + userData: any, + projectUuid: string, + invitedUserUuid: string, + disable: boolean, + ) { + const spaceUuids = userData.spaces.map((space) => space.space.uuid); + + const associatePromises = spaceUuids.map(async (spaceUuid) => { + try { + const spaceDetails = await this.getSpaceByUuid(spaceUuid); + + const deviceUUIDs = + await this.userSpaceService.getDeviceUUIDsForSpace(spaceUuid); + await this.userSpaceService.addUserPermissionsToDevices( + user.uuid, + deviceUUIDs, + ); + + await this.spaceUserService.associateUserToSpace({ + communityUuid: spaceDetails.communityUuid, + spaceUuid, + userUuid: user.uuid, + projectUuid, + }); + + await this.updateUserStatus(invitedUserUuid, projectUuid, disable); + } catch (error) { + console.error(`Failed to associate user to space ${spaceUuid}:`, error); + } + }); + + await Promise.allSettled(associatePromises); + } + + async deleteUserInvitation( + invitedUserUuid: string, + ): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.startTransaction(); + + try { + const userData = await this.inviteUserRepository.findOne({ + where: { uuid: invitedUserUuid }, + relations: ['roleType', 'spaces.space', 'project'], + }); + + if (!userData) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + + if (userData.status === UserStatusEnum.INVITED) { + await this.inviteUserRepository.update( + { uuid: invitedUserUuid }, + { isActive: false }, + ); + } else if (userData.status === UserStatusEnum.ACTIVE) { + const invitedUserData = await this.inviteUserRepository.findOne({ + where: { uuid: invitedUserUuid }, + relations: [ + 'user.userSpaces.space', + 'user.userSpaces.space.community', + ], + }); + + if (!invitedUserData.user) { + throw new HttpException( + 'User account not found', + HttpStatus.NOT_FOUND, + ); + } + + await this.disassociateUserFromSpaces( + invitedUserData.user, + userData.project.uuid, + ); + await this.inviteUserRepository.update( + { uuid: invitedUserUuid }, + { isActive: false }, + ); + await this.userRepository.update( + { uuid: invitedUserData.user.uuid }, + { isActive: false }, + ); + } + await this.emailService.sendEmailWithTemplate( + userData.email, + userData.firstName, + false, + true, + ); + await queryRunner.commitTransaction(); + + return new SuccessResponseDto({ + statusCode: HttpStatus.OK, + success: true, + message: 'User invitation deleted successfully', + }); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } +} diff --git a/src/permission/controllers/index.ts b/src/permission/controllers/index.ts new file mode 100644 index 0000000..e78a8bd --- /dev/null +++ b/src/permission/controllers/index.ts @@ -0,0 +1 @@ +export * from './permission.controller'; diff --git a/src/permission/controllers/permission.controller.ts b/src/permission/controllers/permission.controller.ts new file mode 100644 index 0000000..dfa0c6b --- /dev/null +++ b/src/permission/controllers/permission.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ControllerRoute } from '@app/common/constants/controller-route'; +import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; +import { PermissionService } from '../services'; +import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; + +@ApiTags('Permission Module') +@Controller({ + version: EnableDisableStatusEnum.ENABLED, + path: ControllerRoute.PERMISSION.ROUTE, +}) +export class PermissionController { + constructor(private readonly permissionService: PermissionService) {} + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get(':roleUuid') + @ApiOperation({ + summary: ControllerRoute.PERMISSION.ACTIONS.GET_PERMISSION_BY_ROLE_SUMMARY, + description: + ControllerRoute.PERMISSION.ACTIONS.GET_PERMISSION_BY_ROLE_DESCRIPTION, + }) + async getPermissionsByRole(@Param('roleUuid') roleUuid: string) { + return await this.permissionService.getPermissionsByRole(roleUuid); + } +} diff --git a/src/permission/permission.module.ts b/src/permission/permission.module.ts new file mode 100644 index 0000000..8c74871 --- /dev/null +++ b/src/permission/permission.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { CommonModule } from '@app/common'; +import { PermissionController } from './controllers'; +import { PermissionService } from './services'; +import { RoleTypeRepository } from '@app/common/modules/role-type/repositories'; + +@Module({ + imports: [ConfigModule, CommonModule], + controllers: [PermissionController], + providers: [PermissionService, RoleTypeRepository], + exports: [PermissionService], +}) +export class PermissionModule {} diff --git a/src/permission/services/index.ts b/src/permission/services/index.ts new file mode 100644 index 0000000..89f3aec --- /dev/null +++ b/src/permission/services/index.ts @@ -0,0 +1 @@ +export * from './permission.service'; diff --git a/src/permission/services/permission.service.ts b/src/permission/services/permission.service.ts new file mode 100644 index 0000000..bd43da9 --- /dev/null +++ b/src/permission/services/permission.service.ts @@ -0,0 +1,52 @@ +import { PermissionMapping } from '@app/common/constants/permissions-mapping'; +import { RolePermissions } from '@app/common/constants/role-permissions'; +import { RoleType } from '@app/common/constants/role.type.enum'; +import { RoleTypeRepository } from '@app/common/modules/role-type/repositories'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; + +@Injectable() +export class PermissionService { + constructor(private readonly roleTypeRepository: RoleTypeRepository) {} + + async getPermissionsByRole(roleUuid: string) { + try { + const role = await this.roleTypeRepository.findOne({ + where: { + uuid: roleUuid, + }, + }); + + if (!role) { + throw new HttpException('Role not found', HttpStatus.NOT_FOUND); + } + + const permissions = this.mapPermissions(role.type.toString() as RoleType); + return permissions; + } catch (err) { + throw new HttpException( + err.message || 'Internal Server Error', + err.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + mapPermissions(role: RoleType): any[] { + const rolePermissions = RolePermissions[role]; // Permissions for the role + + const mappedPermissions = Object.entries(PermissionMapping).map( + ([title, subOptions]) => ({ + title, + subOptions: Object.entries(subOptions).map( + ([subTitle, permissions]) => ({ + title: `MANAGE_${subTitle}`, // Prepend "MANAGE_" to subTitle + subOptions: permissions.map((permission) => ({ + title: permission, + isChecked: rolePermissions.includes(`${subTitle}_${permission}`), // Check if the role has the permission + })), + }), + ), + }), + ); + + return mappedPermissions; + } +} diff --git a/src/privacy-policy/controllers/index.ts b/src/privacy-policy/controllers/index.ts new file mode 100644 index 0000000..1ef7536 --- /dev/null +++ b/src/privacy-policy/controllers/index.ts @@ -0,0 +1 @@ +export * from './privacy-policy.controller'; diff --git a/src/privacy-policy/controllers/privacy-policy.controller.ts b/src/privacy-policy/controllers/privacy-policy.controller.ts new file mode 100644 index 0000000..621a63c --- /dev/null +++ b/src/privacy-policy/controllers/privacy-policy.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, HttpStatus } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; +import { ControllerRoute } from '@app/common/constants/controller-route'; // Assuming this is where the routes are defined +import { PrivacyPolicyService } from '../services'; + +@ApiTags('Privacy Policy Module') +@Controller({ + version: EnableDisableStatusEnum.ENABLED, + path: ControllerRoute.PRIVACY_POLICY.ROUTE, +}) +export class PrivacyPolicyController { + constructor(private readonly privacyPolicyService: PrivacyPolicyService) {} + + @Get() + @ApiOperation({ + summary: ControllerRoute.PRIVACY_POLICY.ACTIONS.FETCH_POLICY_SUMMARY, + description: + ControllerRoute.PRIVACY_POLICY.ACTIONS.FETCH_POLICY_DESCRIPTION, + }) + async fetchPrivacyPolicy() { + const htmlContent = await this.privacyPolicyService.fetchPrivacyPolicy(); + return { + statusCode: HttpStatus.OK, + message: 'Privacy policy fetched successfully', + data: htmlContent, + }; + } +} diff --git a/src/privacy-policy/privacy-policy.module.ts b/src/privacy-policy/privacy-policy.module.ts new file mode 100644 index 0000000..75da19c --- /dev/null +++ b/src/privacy-policy/privacy-policy.module.ts @@ -0,0 +1,12 @@ +import { DeviceRepositoryModule } from '@app/common/modules/device'; +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { PrivacyPolicyController } from './controllers'; +import { PrivacyPolicyService } from './services'; + +@Module({ + imports: [ConfigModule, DeviceRepositoryModule], + controllers: [PrivacyPolicyController], + providers: [PrivacyPolicyService], +}) +export class PrivacyPolicyModule {} diff --git a/src/privacy-policy/services/index.ts b/src/privacy-policy/services/index.ts new file mode 100644 index 0000000..9866974 --- /dev/null +++ b/src/privacy-policy/services/index.ts @@ -0,0 +1 @@ +export * from './privacy-policy.service'; diff --git a/src/privacy-policy/services/privacy-policy.service.ts b/src/privacy-policy/services/privacy-policy.service.ts new file mode 100644 index 0000000..9c347b8 --- /dev/null +++ b/src/privacy-policy/services/privacy-policy.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; + +@Injectable() +export class PrivacyPolicyService { + async fetchPrivacyPolicy(): Promise { + const projectRoot = process.cwd(); + + const filePath = path.join( + projectRoot, + 'libs', + 'common', + 'src', + 'constants', + 'privacy-policy.html', + ); + + // Ensure the file exists + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + let htmlContent = fs.readFileSync(filePath, 'utf-8'); + + htmlContent = htmlContent.replace(/(\r\n|\n|\r)/gm, ''); // Removes newlines + + return htmlContent; + } +} diff --git a/src/product/product.module.ts b/src/product/product.module.ts index e5ffd25..aa13d20 100644 --- a/src/product/product.module.ts +++ b/src/product/product.module.ts @@ -1,10 +1,12 @@ -import { Module } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; import { ProductService } from './services'; import { ProductRepository } from '@app/common/modules/product/repositories'; import { ProductController } from './controllers'; +@Global() @Module({ controllers: [ProductController], providers: [ProductService, ProductRepository], + exports: [ProductService], }) export class ProductModule {} diff --git a/src/product/services/product.service.ts b/src/product/services/product.service.ts index cebf9ba..4b9377c 100644 --- a/src/product/services/product.service.ts +++ b/src/product/services/product.service.ts @@ -21,4 +21,23 @@ export class ProductService { message: 'List of products retrieved successfully', }); } + + async findOne(productUuid: string): Promise { + const product = await this.productRepository.findOne({ + where: { + uuid: productUuid, + }, + }); + if (!product) { + throw new HttpException( + `No product with ${productUuid} found in the system`, + HttpStatus.NOT_FOUND, + ); + } + + return new SuccessResponseDto({ + data: product, + message: 'Succefully retrieved product', + }); + } } diff --git a/src/project/command/create-orphan-space-command.ts b/src/project/command/create-orphan-space-command.ts new file mode 100644 index 0000000..2355c91 --- /dev/null +++ b/src/project/command/create-orphan-space-command.ts @@ -0,0 +1,6 @@ +import { ICommand } from '@nestjs/cqrs'; +import { ProjectEntity } from '@app/common/modules/project/entities'; + +export class CreateOrphanSpaceCommand implements ICommand { + constructor(public readonly project: ProjectEntity) {} +} diff --git a/src/project/command/index.ts b/src/project/command/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/project/controllers/index.ts b/src/project/controllers/index.ts new file mode 100644 index 0000000..2b606fd --- /dev/null +++ b/src/project/controllers/index.ts @@ -0,0 +1 @@ +export * from './project.controller'; diff --git a/src/project/controllers/project-user.controller.ts b/src/project/controllers/project-user.controller.ts new file mode 100644 index 0000000..7cda75e --- /dev/null +++ b/src/project/controllers/project-user.controller.ts @@ -0,0 +1,48 @@ +import { ControllerRoute } from '@app/common/constants/controller-route'; +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BaseResponseDto } from '@app/common/dto/base.response.dto'; +import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; +import { GetProjectParam } from '../dto'; +import { ProjectUserService } from '../services/project-user.service'; + +@ApiTags('Project Module') +@Controller({ + version: '1', + path: ControllerRoute.PROJECT_USER.ROUTE, +}) +export class ProjectUserController { + constructor(private readonly projectUserService: ProjectUserService) {} + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: + ControllerRoute.PROJECT.ACTIONS.GET_USER_BY_UUID_IN_PROJECT_SUMMARY, + description: + ControllerRoute.PROJECT.ACTIONS.GET_USER_BY_UUID_IN_PROJECT_DESCRIPTION, + }) + @Get() + async findUsersByProject( + @Param() params: GetProjectParam, + ): Promise { + return this.projectUserService.getUsersByProject(params.projectUuid); + } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: ControllerRoute.PROJECT.ACTIONS.GET_USERS_BY_PROJECT_SUMMARY, + description: + ControllerRoute.PROJECT.ACTIONS.GET_USERS_BY_PROJECT_DESCRIPTION, + }) + @Get(':invitedUserUuid') + async findUserByUuidInProject( + @Param() params: GetProjectParam, + @Param('invitedUserUuid') invitedUserUuid: string, + ): Promise { + return this.projectUserService.getUserByUuidInProject( + params.projectUuid, + invitedUserUuid, + ); + } +} diff --git a/src/project/controllers/project.controller.ts b/src/project/controllers/project.controller.ts new file mode 100644 index 0000000..a39ced1 --- /dev/null +++ b/src/project/controllers/project.controller.ts @@ -0,0 +1,89 @@ +import { ControllerRoute } from '@app/common/constants/controller-route'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ProjectService } from '../services'; +import { CreateProjectDto, GetProjectParam } from '../dto'; +import { BaseResponseDto } from '@app/common/dto/base.response.dto'; +import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; +import { PaginationRequestGetListDto } from '@app/common/dto/pagination.request.dto'; + +@ApiTags('Project Module') +@Controller({ + version: '1', + path: ControllerRoute.PROJECT.ROUTE, +}) +export class ProjectController { + constructor(private readonly projectService: ProjectService) {} + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: ControllerRoute.PROJECT.ACTIONS.CREATE_PROJECT_SUMMARY, + description: ControllerRoute.PROJECT.ACTIONS.CREATE_PROJECT_DESCRIPTION, + }) + @Post() + async createProject(@Body() dto: CreateProjectDto): Promise { + return this.projectService.createProject(dto); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: ControllerRoute.PROJECT.ACTIONS.UPDATE_PROJECT_SUMMARY, + description: ControllerRoute.PROJECT.ACTIONS.UPDATE_PROJECT_DESCRIPTION, + }) + @Put(':projectUuid') + async updateProject( + @Param() params: GetProjectParam, + @Body() dto: CreateProjectDto, + ): Promise { + return this.projectService.updateProject(params.projectUuid, dto); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: ControllerRoute.PROJECT.ACTIONS.DELETE_PROJECT_SUMMARY, + description: ControllerRoute.PROJECT.ACTIONS.DELETE_PROJECT_DESCRIPTION, + }) + @Delete(':projectUuid') + async deleteProject( + @Param() params: GetProjectParam, + ): Promise { + return this.projectService.deleteProject(params.projectUuid); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: ControllerRoute.PROJECT.ACTIONS.LIST_PROJECTS_SUMMARY, + description: ControllerRoute.PROJECT.ACTIONS.LIST_PROJECTS_DESCRIPTION, + }) + @Get() + async list( + @Query() query: PaginationRequestGetListDto, + ): Promise { + return this.projectService.listProjects(query); + } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: ControllerRoute.PROJECT.ACTIONS.GET_PROJECT_SUMMARY, + description: ControllerRoute.PROJECT.ACTIONS.GET_PROJECT_DESCRIPTION, + }) + @Get(':projectUuid') + async findOne(@Param() params: GetProjectParam): Promise { + return this.projectService.getProject(params.projectUuid); + } +} diff --git a/src/project/dto/create-project.dto.ts b/src/project/dto/create-project.dto.ts new file mode 100644 index 0000000..9637856 --- /dev/null +++ b/src/project/dto/create-project.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; + +export class CreateProjectDto { + @ApiProperty({ + example: 'Project 1', + description: 'Name of the project', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ + example: 'Optional description of the project', + description: 'Description of the project', + required: false, + }) + @IsString() + @IsOptional() + description?: string; +} diff --git a/src/project/dto/get-project.param.ts b/src/project/dto/get-project.param.ts new file mode 100644 index 0000000..2ec5825 --- /dev/null +++ b/src/project/dto/get-project.param.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class GetProjectParam { + @ApiProperty({ + description: 'UUID of the Project', + example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', + }) + @IsUUID() + projectUuid: string; +} diff --git a/src/project/dto/index.ts b/src/project/dto/index.ts new file mode 100644 index 0000000..568bd4f --- /dev/null +++ b/src/project/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-project.dto'; +export * from './get-project.param'; diff --git a/src/project/handler/create-orphan-space.handler.service.ts b/src/project/handler/create-orphan-space.handler.service.ts new file mode 100644 index 0000000..662b93e --- /dev/null +++ b/src/project/handler/create-orphan-space.handler.service.ts @@ -0,0 +1,54 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { CreateOrphanSpaceCommand } from '../command/create-orphan-space-command'; +import { SpaceRepository } from '@app/common/modules/space'; +import { CommunityRepository } from '@app/common/modules/community/repositories'; +import { Logger } from '@nestjs/common'; +import { + ORPHAN_COMMUNITY_DESCRIPTION, + ORPHAN_COMMUNITY_NAME, + ORPHAN_SPACE_NAME, +} from '@app/common/constants/orphan-constant'; + +@CommandHandler(CreateOrphanSpaceCommand) +export class CreateOrphanSpaceHandler + implements ICommandHandler +{ + private readonly logger = new Logger(CreateOrphanSpaceHandler.name); + + constructor( + private readonly spaceRepository: SpaceRepository, + private readonly communityRepository: CommunityRepository, + ) {} + + async execute(command: CreateOrphanSpaceCommand): Promise { + try { + const { project } = command; + const orphanCommunityName = `${ORPHAN_COMMUNITY_NAME}-${project.name}`; + + let orphanCommunity = await this.communityRepository.findOne({ + where: { name: orphanCommunityName, project }, + }); + + if (!orphanCommunity) { + orphanCommunity = this.communityRepository.create({ + name: orphanCommunityName, + description: ORPHAN_COMMUNITY_DESCRIPTION, + project, + }); + orphanCommunity = await this.communityRepository.save(orphanCommunity); + } + + const orphanSpace = this.spaceRepository.create({ + spaceName: ORPHAN_SPACE_NAME, + community: orphanCommunity, + }); + await this.spaceRepository.save(orphanSpace); + } catch (error) { + this.logger.error( + `Error when creating orphan space for project ${JSON.stringify( + command.project.uuid, + )} - ERROR ${error}.`, + ); + } + } +} diff --git a/src/project/handler/index.ts b/src/project/handler/index.ts new file mode 100644 index 0000000..acbf37d --- /dev/null +++ b/src/project/handler/index.ts @@ -0,0 +1 @@ +export * from './create-orphan-space.handler.service' \ No newline at end of file diff --git a/src/project/index.ts b/src/project/index.ts new file mode 100644 index 0000000..c3c63b7 --- /dev/null +++ b/src/project/index.ts @@ -0,0 +1 @@ +export * from './project.module'; diff --git a/src/project/project.module.ts b/src/project/project.module.ts new file mode 100644 index 0000000..260df68 --- /dev/null +++ b/src/project/project.module.ts @@ -0,0 +1,32 @@ +import { Global, Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { ProjectController } from './controllers'; +import { ProjectService } from './services'; +import { ProjectRepository } from '@app/common/modules/project/repositiories'; +import { CreateOrphanSpaceHandler } from './handler'; +import { SpaceRepository } from '@app/common/modules/space'; +import { CommunityRepository } from '@app/common/modules/community/repositories'; +import { InviteUserRepository } from '@app/common/modules/Invite-user/repositiories'; +import { ProjectUserController } from './controllers/project-user.controller'; +import { ProjectUserService } from './services/project-user.service'; +import { UserSpaceRepository } from '@app/common/modules/user/repositories'; + +const CommandHandlers = [CreateOrphanSpaceHandler]; + +@Global() +@Module({ + imports: [CqrsModule], + controllers: [ProjectController, ProjectUserController], + providers: [ + ...CommandHandlers, + SpaceRepository, + CommunityRepository, + ProjectService, + ProjectUserService, + ProjectRepository, + InviteUserRepository, + UserSpaceRepository, + ], + exports: [ProjectService, CqrsModule], +}) +export class ProjectModule {} diff --git a/src/project/services/index.ts b/src/project/services/index.ts new file mode 100644 index 0000000..82e8c13 --- /dev/null +++ b/src/project/services/index.ts @@ -0,0 +1 @@ +export * from './project.service'; diff --git a/src/project/services/project-user.service.ts b/src/project/services/project-user.service.ts new file mode 100644 index 0000000..8a2250b --- /dev/null +++ b/src/project/services/project-user.service.ts @@ -0,0 +1,132 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { BaseResponseDto } from '@app/common/dto/base.response.dto'; +import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; +import { InviteUserRepository } from '@app/common/modules/Invite-user/repositiories'; +import { ProjectService } from './project.service'; +import { UserSpaceRepository } from '@app/common/modules/user/repositories'; + +@Injectable() +export class ProjectUserService { + constructor( + private readonly inviteUserRepository: InviteUserRepository, + private readonly projectService: ProjectService, + private readonly userSpaceRepository: UserSpaceRepository, + ) {} + + async getUsersByProject(uuid: string): Promise { + try { + const project = await this.projectService.getProject(uuid); + if (!project) { + throw new HttpException( + `Project with ID ${uuid} not found`, + HttpStatus.NOT_FOUND, + ); + } + const allUsers = await this.inviteUserRepository.find({ + where: { project: { uuid }, isActive: true }, + select: [ + 'uuid', + 'firstName', + 'lastName', + 'email', + 'createdAt', + 'status', + 'phoneNumber', + 'jobTitle', + 'invitedBy', + 'isEnabled', + ], + relations: ['roleType'], + order: { + createdAt: 'ASC', + }, + }); + + const normalizedUsers = allUsers.map((user) => { + const createdAt = new Date(user.createdAt); + const createdDate = createdAt.toLocaleDateString(); + const createdTime = createdAt.toLocaleTimeString(); + + return { + ...user, + roleType: user.roleType.type, + createdDate, + createdTime, + }; + }); + + return new SuccessResponseDto({ + message: `Users in project with ID ${uuid} retrieved successfully`, + data: normalizedUsers, + statusCode: HttpStatus.OK, + }); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + `An error occurred while retrieving users in the project with id ${uuid}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async getUserByUuidInProject( + projectUuid: string, + invitedUserUuid: string, + ): Promise { + try { + const user = await this.inviteUserRepository.findOne({ + where: { + project: { uuid: projectUuid }, + uuid: invitedUserUuid, + isActive: true, + }, + select: [ + 'uuid', + 'firstName', + 'lastName', + 'email', + 'createdAt', + 'status', + 'phoneNumber', + 'jobTitle', + 'invitedBy', + 'isEnabled', + ], + relations: ['roleType', 'spaces.space'], + }); + + if (!user) { + throw new HttpException( + `User with ID ${invitedUserUuid} not found in project ${projectUuid}`, + HttpStatus.NOT_FOUND, + ); + } + + const createdAt = new Date(user.createdAt); + const createdDate = createdAt.toLocaleDateString(); + const createdTime = createdAt.toLocaleTimeString(); + return new SuccessResponseDto({ + message: `User in project with ID ${projectUuid} retrieved successfully`, + data: { + ...user, + roleType: user.roleType.type, + createdDate, + createdTime, + spaces: user.spaces.map((space) => space.space), + }, + statusCode: HttpStatus.OK, + }); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + `An error occurred while retrieving user in the project with id ${projectUuid}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/project/services/project.service.ts b/src/project/services/project.service.ts new file mode 100644 index 0000000..75aa67a --- /dev/null +++ b/src/project/services/project.service.ts @@ -0,0 +1,192 @@ +import { ProjectRepository } from '@app/common/modules/project/repositiories'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { CreateProjectDto } from '../dto'; +import { BaseResponseDto } from '@app/common/dto/base.response.dto'; +import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; +import { ProjectEntity } from '@app/common/modules/project/entities'; +import { + TypeORMCustomModel, + TypeORMCustomModelFindAllQuery, +} from '@app/common/models/typeOrmCustom.model'; +import { ProjectDto } from '@app/common/modules/project/dtos'; +import { PageResponse } from '@app/common/dto/pagination.response.dto'; +import { CommandBus } from '@nestjs/cqrs'; +import { CreateOrphanSpaceCommand } from '../command/create-orphan-space-command'; + +@Injectable() +export class ProjectService { + constructor( + private readonly projectRepository: ProjectRepository, + private commandBus: CommandBus, + ) {} + + async createProject( + createProjectDto: CreateProjectDto, + ): Promise { + const { name } = createProjectDto; + + try { + // Check if the project name already exists + const isNameExist = await this.validate(name); + if (isNameExist) { + throw new HttpException( + `Project with name ${name} already exists`, + HttpStatus.CONFLICT, + ); + } + + const newProject = this.projectRepository.create(createProjectDto); + const savedProject = await this.projectRepository.save(newProject); + + await this.commandBus.execute(new CreateOrphanSpaceCommand(savedProject)); + + return new SuccessResponseDto({ + message: `Project with ID ${savedProject.uuid} successfully created`, + data: savedProject, + statusCode: HttpStatus.CREATED, + }); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + `An error occurred while creating the project. Please try again later. ${error}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async deleteProject(uuid: string): Promise { + try { + // Find the project by UUID + const project = await this.findOne(uuid); + + if (!project) { + throw new HttpException( + `Project with ID ${uuid} not found`, + HttpStatus.NOT_FOUND, + ); + } + + // Delete the project + await this.projectRepository.delete({ uuid }); + + return new SuccessResponseDto({ + message: `Project with ID ${uuid} successfully deleted`, + statusCode: HttpStatus.OK, + }); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + `An error occurred while deleting the project ${uuid}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async listProjects( + pageable: Partial, + ): Promise { + try { + pageable.modelName = 'project'; + const customModel = TypeORMCustomModel(this.projectRepository); + + const { baseResponseDto, paginationResponseDto } = + await customModel.findAll(pageable); + return new PageResponse( + baseResponseDto, + paginationResponseDto, + ); + } catch (error) { + throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + async getProject(uuid: string): Promise { + try { + // Find the project by UUID + const project = await this.findOne(uuid); + + if (!project) { + throw new HttpException( + `Project with ID ${uuid} not found`, + HttpStatus.NOT_FOUND, + ); + } + + return new SuccessResponseDto({ + message: `Project with ID ${uuid} retrieved successfully`, + data: project, + statusCode: HttpStatus.OK, + }); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + `An error occurred while retrieving the project with id ${uuid}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async updateProject( + uuid: string, + updateProjectDto: CreateProjectDto, + ): Promise { + try { + // Find the project by UUID + const project = await this.findOne(uuid); + + if (!project) { + throw new HttpException( + `Project with ID ${uuid} not found`, + HttpStatus.NOT_FOUND, + ); + } + + if (updateProjectDto.name && updateProjectDto.name !== project.name) { + const isNameExist = await this.validate(updateProjectDto.name); + if (isNameExist) { + throw new HttpException( + `Project with name ${updateProjectDto.name} already exists`, + HttpStatus.CONFLICT, + ); + } + } + + // Update the project details + Object.assign(project, updateProjectDto); + const updatedProject = await this.projectRepository.save(project); + + return new SuccessResponseDto({ + message: `Project with ID ${uuid} successfully updated`, + data: updatedProject, + statusCode: HttpStatus.OK, + }); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + `An error occurred while updating the project with ID ${uuid}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async findOne(uuid: string): Promise { + const project = await this.projectRepository.findOne({ where: { uuid } }); + return project; + } + + async validate(name: string): Promise { + return await this.projectRepository.exists({ where: { name } }); + } +} diff --git a/src/role/controllers/role.controller.ts b/src/role/controllers/role.controller.ts index a53cd1c..c684d68 100644 --- a/src/role/controllers/role.controller.ts +++ b/src/role/controllers/role.controller.ts @@ -1,17 +1,9 @@ -import { - Body, - Controller, - Get, - HttpStatus, - Post, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, HttpStatus, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { RoleService } from '../services/role.service'; -import { AddUserRoleDto } from '../dtos'; -import { SuperAdminRoleGuard } from 'src/guards/super.admin.role.guard'; import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; import { ControllerRoute } from '@app/common/constants/controller-route'; // Assuming this is where the routes are defined +import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; @ApiTags('Role Module') @Controller({ @@ -22,7 +14,7 @@ export class RoleController { constructor(private readonly roleService: RoleService) {} @ApiBearerAuth() - @UseGuards(SuperAdminRoleGuard) + @UseGuards(JwtAuthGuard) @Get('types') @ApiOperation({ summary: ControllerRoute.ROLE.ACTIONS.FETCH_ROLE_TYPES_SUMMARY, @@ -36,19 +28,4 @@ export class RoleController { data: roleTypes, }; } - - @ApiBearerAuth() - @UseGuards(SuperAdminRoleGuard) - @Post() - @ApiOperation({ - summary: ControllerRoute.ROLE.ACTIONS.ADD_USER_ROLE_SUMMARY, - description: ControllerRoute.ROLE.ACTIONS.ADD_USER_ROLE_DESCRIPTION, - }) - async addUserRoleType(@Body() addUserRoleDto: AddUserRoleDto) { - await this.roleService.addUserRoleType(addUserRoleDto); - return { - statusCode: HttpStatus.OK, - message: 'User Role Added Successfully', - }; - } } diff --git a/src/role/role.module.ts b/src/role/role.module.ts index 4e51725..fca78e0 100644 --- a/src/role/role.module.ts +++ b/src/role/role.module.ts @@ -7,7 +7,6 @@ import { RoleController } from './controllers/role.controller'; import { DeviceUserPermissionRepository } from '@app/common/modules/device/repositories'; import { PermissionTypeRepository } from '@app/common/modules/permission/repositories'; import { RoleTypeRepository } from '@app/common/modules/role-type/repositories'; -import { UserRoleRepository } from '@app/common/modules/user/repositories'; @Module({ imports: [ConfigModule, DeviceRepositoryModule], @@ -18,7 +17,6 @@ import { UserRoleRepository } from '@app/common/modules/user/repositories'; DeviceRepository, RoleService, RoleTypeRepository, - UserRoleRepository, ], exports: [RoleService], }) diff --git a/src/role/services/role.service.ts b/src/role/services/role.service.ts index ba10f44..307f8a5 100644 --- a/src/role/services/role.service.ts +++ b/src/role/services/role.service.ts @@ -1,55 +1,24 @@ +import { Injectable } from '@nestjs/common'; import { RoleTypeRepository } from './../../../libs/common/src/modules/role-type/repositories/role.type.repository'; -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import { AddUserRoleDto } from '../dtos/role.add.dto'; -import { UserRoleRepository } from '@app/common/modules/user/repositories'; -import { QueryFailedError } from 'typeorm'; -import { CommonErrorCodes } from '@app/common/constants/error-codes.enum'; +import { RoleType } from '@app/common/constants/role.type.enum'; @Injectable() export class RoleService { - constructor( - private readonly roleTypeRepository: RoleTypeRepository, - private readonly userRoleRepository: UserRoleRepository, - ) {} - - async addUserRoleType(addUserRoleDto: AddUserRoleDto) { - try { - const roleType = await this.fetchRoleByType(addUserRoleDto.roleType); - - if (roleType.uuid) { - return await this.userRoleRepository.save({ - user: { uuid: addUserRoleDto.userUuid }, - roleType: { uuid: roleType.uuid }, - }); - } - } catch (error) { - if ( - error instanceof QueryFailedError && - error.driverError.code === CommonErrorCodes.DUPLICATE_ENTITY - ) { - // Postgres unique constraint violation error code - throw new HttpException( - 'This role already exists for this user', - HttpStatus.CONFLICT, - ); - } - throw new HttpException( - error.message || 'Internal Server Error', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } + constructor(private readonly roleTypeRepository: RoleTypeRepository) {} async fetchRoleTypes() { const roleTypes = await this.roleTypeRepository.find(); - - return roleTypes; + const roles = roleTypes.filter( + (roleType) => roleType.type !== RoleType.SUPER_ADMIN, + ); + return roles; } - private async fetchRoleByType(roleType: string) { - return await this.roleTypeRepository.findOne({ + async findRoleByType(roleType: RoleType) { + const role = await this.roleTypeRepository.findOne({ where: { type: roleType, }, }); + return role; } } diff --git a/src/scene/controllers/scene.controller.ts b/src/scene/controllers/scene.controller.ts index eaf67ec..655efe5 100644 --- a/src/scene/controllers/scene.controller.ts +++ b/src/scene/controllers/scene.controller.ts @@ -16,11 +16,12 @@ import { AddSceneTapToRunDto, UpdateSceneTapToRunDto, } from '../dtos/scene.dto'; -import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; 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') @Controller({ @@ -31,7 +32,8 @@ export class SceneController { constructor(private readonly sceneService: SceneService) {} @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SCENES_ADD') @Post('tap-to-run') @ApiOperation({ summary: ControllerRoute.SCENE.ACTIONS.CREATE_TAP_TO_RUN_SCENE_SUMMARY, @@ -45,7 +47,8 @@ export class SceneController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SCENES_DELETE') @Delete('tap-to-run/:sceneUuid') @ApiOperation({ summary: ControllerRoute.SCENE.ACTIONS.DELETE_TAP_TO_RUN_SCENE_SUMMARY, @@ -59,7 +62,8 @@ export class SceneController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SCENES_CONTROL') @Post('tap-to-run/:sceneUuid/trigger') @ApiOperation({ summary: ControllerRoute.SCENE.ACTIONS.TRIGGER_TAP_TO_RUN_SCENE_SUMMARY, @@ -71,7 +75,8 @@ export class SceneController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SCENES_VIEW') @Get('tap-to-run/:sceneUuid') @ApiOperation({ summary: ControllerRoute.SCENE.ACTIONS.GET_TAP_TO_RUN_SCENE_SUMMARY, @@ -84,7 +89,8 @@ export class SceneController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SCENES_UPDATE') @Put('tap-to-run/:sceneUuid') @ApiOperation({ summary: ControllerRoute.SCENE.ACTIONS.UPDATE_TAP_TO_RUN_SCENE_SUMMARY, @@ -102,7 +108,8 @@ export class SceneController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SCENES_ADD') @Post('icon') async addSceneIcon(@Body() addSceneIconDto: AddSceneIconDto) { const tapToRunScene = await this.sceneService.addSceneIcon(addSceneIconDto); @@ -114,7 +121,8 @@ export class SceneController { }; } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SCENES_VIEW') @Get('icon') async getAllIcons() { const icons = await this.sceneService.getAllIcons(); diff --git a/src/scene/scene.module.ts b/src/scene/scene.module.ts index 9da0156..a6a1435 100644 --- a/src/scene/scene.module.ts +++ b/src/scene/scene.module.ts @@ -14,6 +14,7 @@ import { } from '@app/common/modules/scene/repositories'; import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; +import { AutomationRepository } from '@app/common/modules/automation/repositories'; @Module({ imports: [ConfigModule, SpaceRepositoryModule, DeviceStatusFirebaseModule], @@ -28,6 +29,7 @@ import { SceneDeviceRepository } from '@app/common/modules/scene-device/reposito SceneIconRepository, SceneRepository, SceneDeviceRepository, + AutomationRepository, ], exports: [SceneService], }) diff --git a/src/scene/services/scene.service.ts b/src/scene/services/scene.service.ts index 691fbe1..1a9196e 100644 --- a/src/scene/services/scene.service.ts +++ b/src/scene/services/scene.service.ts @@ -23,7 +23,10 @@ import { SceneDetailsResult, } from '../interface/scene.interface'; import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter'; -import { ActionExecutorEnum } from '@app/common/constants/automation.enum'; +import { + ActionExecutorEnum, + ActionTypeEnum, +} from '@app/common/constants/automation.enum'; import { SceneIconRepository, SceneRepository, @@ -40,6 +43,7 @@ import { HttpStatusCode } from 'axios'; import { ConvertedAction } from '@app/common/integrations/tuya/interfaces'; import { DeviceService } from 'src/device/services'; import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; +import { AutomationRepository } from '@app/common/modules/automation/repositories'; @Injectable() export class SceneService { @@ -48,6 +52,7 @@ export class SceneService { private readonly sceneIconRepository: SceneIconRepository, private readonly sceneRepository: SceneRepository, private readonly sceneDeviceRepository: SceneDeviceRepository, + private readonly automationRepository: AutomationRepository, private readonly tuyaService: TuyaService, @Inject(forwardRef(() => DeviceService)) private readonly deviceService: DeviceService, @@ -218,38 +223,43 @@ export class SceneService { const scenesData = await this.sceneRepository.find({ where: { space: { uuid: spaceUuid }, + disabled: false, ...(showInHomePage ? { showInHomePage } : {}), }, relations: ['sceneIcon', 'space'], }); - const scenes = await Promise.all( - scenesData.map(async (scene) => { + const safeFetch = async (scene: any) => { + try { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { actions, ...sceneDetails } = await this.getScene( scene, spaceUuid, ); - return sceneDetails; - }), - ); + } catch (error) { + console.warn( + `Skipping scene UUID: ${scene.uuid} due to error: ${error.message}`, + ); + return null; + } + }; - return scenes; - } catch (err) { + const scenes = await Promise.all(scenesData.map(safeFetch)); + + return scenes.filter(Boolean); // Remove null values + } catch (error) { console.error( `Error fetching Tap-to-Run scenes for space UUID ${spaceUuid}:`, - err.message, + error.message, ); - if (err instanceof HttpException) { - throw err; - } else { - throw new HttpException( - 'An error occurred while retrieving scenes', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } + throw error instanceof HttpException + ? error + : new HttpException( + 'An error occurred while retrieving scenes', + HttpStatus.INTERNAL_SERVER_ERROR, + ); } } @@ -459,6 +469,12 @@ export class SceneService { ); if (sceneDetails.id) { + if (sceneDetails.type === ActionTypeEnum.AUTOMATION) { + const automation = await this.automationRepository.findOne({ + where: { automationTuyaUuid: action.entityId }, + }); + action.entityId = automation.uuid; + } action.name = sceneDetails.name; action.type = sceneDetails.type; } @@ -495,12 +511,16 @@ export class SceneService { const space = await this.getSpaceByUuid(scene.space.uuid); await this.delete(scene.sceneTuyaUuid, space.spaceTuyaUuid); - await this.sceneDeviceRepository.delete({ - scene: { uuid: sceneUuid }, - }); - await this.sceneRepository.delete({ - uuid: sceneUuid, - }); + 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`, }); @@ -563,6 +583,17 @@ export class SceneService { if (device) { action.entity_id = device.deviceTuyaUuid; } + } else if ( + action.action_executor === ActionExecutorEnum.RULE_DISABLE || + action.action_executor === ActionExecutorEnum.RULE_ENABLE + ) { + const automation = await this.automationRepository.findOne({ + where: { uuid: action.entity_id }, + }); + + if (automation) { + action.entity_id = automation.automationTuyaUuid; + } } }), ); diff --git a/src/space-model/commands/index.ts b/src/space-model/commands/index.ts new file mode 100644 index 0000000..da760ea --- /dev/null +++ b/src/space-model/commands/index.ts @@ -0,0 +1,2 @@ +export * from './propogate-subspace-update-command'; +export * from './propagate-space-model-deletion.command'; diff --git a/src/space-model/commands/propagate-space-model-deletion.command.ts b/src/space-model/commands/propagate-space-model-deletion.command.ts new file mode 100644 index 0000000..0cacd5b --- /dev/null +++ b/src/space-model/commands/propagate-space-model-deletion.command.ts @@ -0,0 +1,9 @@ +import { SpaceModelEntity } from '@app/common/modules/space-model'; + +export class PropogateDeleteSpaceModelCommand { + constructor( + public readonly param: { + spaceModel: SpaceModelEntity; + }, + ) {} +} diff --git a/src/space-model/commands/propogate-subspace-update-command.ts b/src/space-model/commands/propogate-subspace-update-command.ts new file mode 100644 index 0000000..a2b5203 --- /dev/null +++ b/src/space-model/commands/propogate-subspace-update-command.ts @@ -0,0 +1,14 @@ +import { ICommand } from '@nestjs/cqrs'; +import { SpaceModelEntity } from '@app/common/modules/space-model'; +import { ModifyspaceModelPayload } from '../interfaces'; +import { QueryRunner } from 'typeorm'; + +export class PropogateUpdateSpaceModelCommand implements ICommand { + constructor( + public readonly param: { + spaceModel: SpaceModelEntity; + modifiedSpaceModels: ModifyspaceModelPayload; + queryRunner: QueryRunner; + }, + ) {} +} diff --git a/src/space-model/controllers/index.ts b/src/space-model/controllers/index.ts new file mode 100644 index 0000000..c12699e --- /dev/null +++ b/src/space-model/controllers/index.ts @@ -0,0 +1 @@ +export * from './space-model.controller'; diff --git a/src/space-model/controllers/space-model.controller.ts b/src/space-model/controllers/space-model.controller.ts new file mode 100644 index 0000000..57e2db6 --- /dev/null +++ b/src/space-model/controllers/space-model.controller.ts @@ -0,0 +1,110 @@ +import { ControllerRoute } from '@app/common/constants/controller-route'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { SpaceModelService } from '../services'; +import { + CreateSpaceModelDto, + SpaceModelParam, + UpdateSpaceModelDto, +} from '../dtos'; +import { ProjectParam } from 'src/community/dtos'; +import { BaseResponseDto } from '@app/common/dto/base.response.dto'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { Permissions } from 'src/decorators/permissions.decorator'; +import { PaginationRequestGetListDto } from '@app/common/dto/pagination.request.dto'; + +@ApiTags('Space Model Module') +@Controller({ + version: '1', + path: ControllerRoute.SPACE_MODEL.ROUTE, +}) +export class SpaceModelController { + constructor(private readonly spaceModelService: SpaceModelService) {} + + @ApiBearerAuth() + @UseGuards(PermissionsGuard) + @Permissions('SPACE_MODEL_ADD') + @ApiOperation({ + summary: ControllerRoute.SPACE_MODEL.ACTIONS.CREATE_SPACE_MODEL_SUMMARY, + description: + ControllerRoute.SPACE_MODEL.ACTIONS.CREATE_SPACE_MODEL_DESCRIPTION, + }) + @Post() + async createSpaceModel( + @Body() createSpaceModelDto: CreateSpaceModelDto, + @Param() projectParam: ProjectParam, + ): Promise { + return await this.spaceModelService.createSpaceModel( + createSpaceModelDto, + projectParam, + ); + } + + @ApiBearerAuth() + @UseGuards(PermissionsGuard) + @Permissions('SPACE_MODEL_VIEW') + @ApiOperation({ + summary: ControllerRoute.SPACE_MODEL.ACTIONS.LIST_SPACE_MODEL_SUMMARY, + description: + ControllerRoute.SPACE_MODEL.ACTIONS.LIST_SPACE_MODEL_DESCRIPTION, + }) + @Get() + async listSpaceModel( + @Param() projectParam: ProjectParam, + @Query() query: PaginationRequestGetListDto, + ): Promise { + return await this.spaceModelService.list(projectParam, query); + } + + @ApiBearerAuth() + @UseGuards(PermissionsGuard) + @Permissions('SPACE_MODEL_VIEW') + @ApiOperation({ + summary: ControllerRoute.SPACE_MODEL.ACTIONS.LIST_SPACE_MODEL_SUMMARY, + description: + ControllerRoute.SPACE_MODEL.ACTIONS.LIST_SPACE_MODEL_DESCRIPTION, + }) + @Get(':spaceModelUuid') + async get(@Param() param: SpaceModelParam): Promise { + return await this.spaceModelService.findOne(param); + } + + @ApiBearerAuth() + @UseGuards(PermissionsGuard) + @Permissions('SPACE_MODEL_UPDATE') + @ApiOperation({ + summary: ControllerRoute.SPACE_MODEL.ACTIONS.UPDATE_SPACE_MODEL_SUMMARY, + description: + ControllerRoute.SPACE_MODEL.ACTIONS.UPDATE_SPACE_MODEL_DESCRIPTION, + }) + @Put(':spaceModelUuid') + async update( + @Body() dto: UpdateSpaceModelDto, + @Param() param: SpaceModelParam, + ): Promise { + return await this.spaceModelService.update(dto, param); + } + + @ApiBearerAuth() + @UseGuards(PermissionsGuard) + @Permissions('SPACE_MODEL_DELETE') + @ApiOperation({ + summary: ControllerRoute.SPACE_MODEL.ACTIONS.DELETE_SPACE_MODEL_SUMMARY, + description: + ControllerRoute.SPACE_MODEL.ACTIONS.DELETE_SPACE_MODEL_DESCRIPTION, + }) + @Delete(':spaceModelUuid') + async delete(@Param() param: SpaceModelParam): Promise { + return await this.spaceModelService.deleteSpaceModel(param); + } +} diff --git a/src/space-model/dtos/create-space-model.dto.ts b/src/space-model/dtos/create-space-model.dto.ts new file mode 100644 index 0000000..0c37779 --- /dev/null +++ b/src/space-model/dtos/create-space-model.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { CreateSubspaceModelDto } from './subspaces-model-dtos/create-subspace-model.dto'; +import { CreateTagModelDto } from './tag-model-dtos/create-tag-model.dto'; + +export class CreateSpaceModelDto { + @ApiProperty({ + description: 'Name of the space model', + example: 'Apartment Model', + }) + @IsNotEmpty() + @IsString() + modelName: string; + + @ApiProperty({ + description: 'List of subspaces included in the model', + type: [CreateSubspaceModelDto], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateSubspaceModelDto) + subspaceModels?: CreateSubspaceModelDto[]; + + @ApiProperty({ + description: 'List of tags associated with the space model', + type: [CreateTagModelDto], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateTagModelDto) + tags?: CreateTagModelDto[]; +} diff --git a/src/space-model/dtos/index.ts b/src/space-model/dtos/index.ts new file mode 100644 index 0000000..3a04fe1 --- /dev/null +++ b/src/space-model/dtos/index.ts @@ -0,0 +1,6 @@ +export * from './create-space-model.dto'; +export * from './project-param.dto'; +export * from './update-space-model.dto'; +export * from './space-model-param'; +export * from './subspaces-model-dtos'; +export * from './tag-model-dtos'; diff --git a/src/space-model/dtos/project-param.dto.ts b/src/space-model/dtos/project-param.dto.ts new file mode 100644 index 0000000..69e09b5 --- /dev/null +++ b/src/space-model/dtos/project-param.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class ProjectParam { + @ApiProperty({ + description: 'UUID of the Project', + example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', + }) + @IsUUID() + projectUuid: string; +} diff --git a/src/space-model/dtos/space-model-param.ts b/src/space-model/dtos/space-model-param.ts new file mode 100644 index 0000000..2111546 --- /dev/null +++ b/src/space-model/dtos/space-model-param.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; +import { ProjectParam } from './project-param.dto'; +export class SpaceModelParam extends ProjectParam { + @ApiProperty({ + description: 'UUID of the Space', + example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', + }) + @IsUUID() + spaceModelUuid: string; +} diff --git a/src/space-model/dtos/subspaces-model-dtos/create-subspace-model.dto.ts b/src/space-model/dtos/subspaces-model-dtos/create-subspace-model.dto.ts new file mode 100644 index 0000000..1397edc --- /dev/null +++ b/src/space-model/dtos/subspaces-model-dtos/create-subspace-model.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator'; +import { CreateTagModelDto } from '../tag-model-dtos/create-tag-model.dto'; +import { Type } from 'class-transformer'; + +export class CreateSubspaceModelDto { + @ApiProperty({ + description: 'Name of the subspace', + example: 'Living Room', + }) + @IsNotEmpty() + @IsString() + subspaceName: string; + + @ApiProperty({ + description: 'List of tag models associated with the subspace', + type: [CreateTagModelDto], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateTagModelDto) + tags?: CreateTagModelDto[]; +} diff --git a/src/space-model/dtos/subspaces-model-dtos/delete-subspace-model.dto.ts b/src/space-model/dtos/subspaces-model-dtos/delete-subspace-model.dto.ts new file mode 100644 index 0000000..62fe84e --- /dev/null +++ b/src/space-model/dtos/subspaces-model-dtos/delete-subspace-model.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class DeleteSubspaceModelDto { + @ApiProperty({ + description: 'Uuid of the subspace model need to be deleted', + example: '982fc3a3-64dc-4afb-a5b5-65ee8fef0424', + }) + @IsNotEmpty() + @IsString() + subspaceUuid: string; +} diff --git a/src/space-model/dtos/subspaces-model-dtos/index.ts b/src/space-model/dtos/subspaces-model-dtos/index.ts new file mode 100644 index 0000000..28b01ef --- /dev/null +++ b/src/space-model/dtos/subspaces-model-dtos/index.ts @@ -0,0 +1,4 @@ +export * from './delete-subspace-model.dto'; +export * from './create-subspace-model.dto'; +export * from './update-subspace-model.dto'; +export * from './modify-subspace-model.dto'; diff --git a/src/space-model/dtos/subspaces-model-dtos/modify-subspace-model.dto.ts b/src/space-model/dtos/subspaces-model-dtos/modify-subspace-model.dto.ts new file mode 100644 index 0000000..cbf021c --- /dev/null +++ b/src/space-model/dtos/subspaces-model-dtos/modify-subspace-model.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsString, + IsOptional, + IsArray, + ValidateNested, + IsEnum, +} from 'class-validator'; +import { ModifyTagModelDto } from '../tag-model-dtos'; +import { ModifyAction } from '@app/common/constants/modify-action.enum'; + +export class ModifySubspaceModelDto { + @ApiProperty({ + description: 'Action to perform: add, update, or delete', + example: ModifyAction.ADD, + }) + @IsEnum(ModifyAction) + action: ModifyAction; + + @ApiPropertyOptional({ + description: 'UUID of the subspace (required for update/delete)', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @IsOptional() + @IsString() + uuid?: string; + + @ApiPropertyOptional({ + description: 'Name of the subspace (required for add/update)', + example: 'Living Room', + }) + @IsOptional() + @IsString() + subspaceName?: string; + + @ApiPropertyOptional({ + description: + 'List of tag modifications (add/update/delete) for the subspace', + type: [ModifyTagModelDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ModifyTagModelDto) + tags?: ModifyTagModelDto[]; +} diff --git a/src/space-model/dtos/subspaces-model-dtos/update-subspace-model.dto.ts b/src/space-model/dtos/subspaces-model-dtos/update-subspace-model.dto.ts new file mode 100644 index 0000000..a386f4e --- /dev/null +++ b/src/space-model/dtos/subspaces-model-dtos/update-subspace-model.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class UpdateSubspaceModelDto { + @ApiProperty({ + description: 'Name of the subspace', + example: 'Living Room', + }) + @IsNotEmpty() + @IsString() + subspaceName?: string; + + @IsNotEmpty() + @IsString() + subspaceUuid: string; +} diff --git a/src/space-model/dtos/tag-model-dtos/create-tag-model.dto.ts b/src/space-model/dtos/tag-model-dtos/create-tag-model.dto.ts new file mode 100644 index 0000000..c271eef --- /dev/null +++ b/src/space-model/dtos/tag-model-dtos/create-tag-model.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateTagModelDto { + @ApiProperty({ + description: 'Tag models associated with the space or subspace models', + example: 'Temperature Control', + }) + @IsNotEmpty() + @IsString() + tag: string; + + @ApiPropertyOptional({ + description: 'UUID of the tag model (required for update/delete)', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @IsOptional() + @IsString() + uuid?: string; + + @ApiProperty({ + description: 'ID of the product associated with the tag', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @IsNotEmpty() + @IsString() + productUuid: string; +} diff --git a/src/space-model/dtos/tag-model-dtos/index.ts b/src/space-model/dtos/tag-model-dtos/index.ts new file mode 100644 index 0000000..a0f136d --- /dev/null +++ b/src/space-model/dtos/tag-model-dtos/index.ts @@ -0,0 +1,3 @@ +export * from './create-tag-model.dto'; +export * from './update-tag-model.dto'; +export * from './modify-tag-model.dto'; diff --git a/src/space-model/dtos/tag-model-dtos/modify-tag-model.dto.ts b/src/space-model/dtos/tag-model-dtos/modify-tag-model.dto.ts new file mode 100644 index 0000000..2b64fe3 --- /dev/null +++ b/src/space-model/dtos/tag-model-dtos/modify-tag-model.dto.ts @@ -0,0 +1,37 @@ +import { ModifyAction } from '@app/common/constants/modify-action.enum'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsEnum } from 'class-validator'; + +export class ModifyTagModelDto { + @ApiProperty({ + description: 'Action to perform: add, update, or delete', + example: ModifyAction.ADD, + }) + @IsEnum(ModifyAction) + action: ModifyAction; + + @ApiPropertyOptional({ + description: 'UUID of the tag model (required for update/delete)', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @IsOptional() + @IsString() + uuid?: string; + + @ApiPropertyOptional({ + description: 'Name of the tag model (required for add/update)', + example: 'Temperature Sensor', + }) + @IsOptional() + @IsString() + tag?: string; + + @ApiPropertyOptional({ + description: + 'UUID of the product associated with the tag (required for add)', + example: 'c789a91e-549a-4753-9006-02f89e8170e0', + }) + @IsOptional() + @IsString() + productUuid?: string; +} diff --git a/src/space-model/dtos/tag-model-dtos/update-tag-model.dto.ts b/src/space-model/dtos/tag-model-dtos/update-tag-model.dto.ts new file mode 100644 index 0000000..ca5612f --- /dev/null +++ b/src/space-model/dtos/tag-model-dtos/update-tag-model.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class UpdateTagModelDto { + @ApiProperty({ + description: 'UUID of the tag to be updated', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @IsNotEmpty() + @IsUUID() + uuid: string; + + @ApiProperty({ + description: 'Updated name of the tag', + example: 'Updated Tag Name', + required: false, + }) + @IsOptional() + @IsString() + tag?: string; +} diff --git a/src/space-model/dtos/update-space-model.dto.ts b/src/space-model/dtos/update-space-model.dto.ts new file mode 100644 index 0000000..d1110ea --- /dev/null +++ b/src/space-model/dtos/update-space-model.dto.ts @@ -0,0 +1,76 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { CreateSubspaceModelDto } from './subspaces-model-dtos/create-subspace-model.dto'; +import { Type } from 'class-transformer'; +import { + DeleteSubspaceModelDto, + ModifySubspaceModelDto, + UpdateSubspaceModelDto, +} from './subspaces-model-dtos'; +import { ModifyTagModelDto } from './tag-model-dtos'; + +export class ModifySubspacesModelDto { + @ApiProperty({ + description: 'List of subspaces to add', + type: [CreateSubspaceModelDto], + required: false, + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateSubspaceModelDto) + add?: CreateSubspaceModelDto[]; + + @ApiProperty({ + description: 'List of subspaces to add', + type: [CreateSubspaceModelDto], + required: false, + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => UpdateSubspaceModelDto) + update?: UpdateSubspaceModelDto[]; + + @ApiProperty({ + description: 'List of subspaces to delete', + type: [DeleteSubspaceModelDto], + required: false, + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => DeleteSubspaceModelDto) + delete?: DeleteSubspaceModelDto[]; +} + +export class UpdateSpaceModelDto { + @ApiProperty({ + description: 'Updated name of the space model', + example: 'New Space Model Name', + }) + @IsOptional() + @IsString() + modelName?: string; + + @ApiPropertyOptional({ + description: 'List of subspace modifications (add/update/delete)', + type: [ModifySubspaceModelDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ModifySubspaceModelDto) + subspaceModels?: ModifySubspaceModelDto[]; + + @ApiPropertyOptional({ + description: + 'List of tag modifications (add/update/delete) for the space model', + type: [ModifyTagModelDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ModifyTagModelDto) + tags?: ModifyTagModelDto[]; +} diff --git a/src/space-model/handlers/index.ts b/src/space-model/handlers/index.ts new file mode 100644 index 0000000..d7bb550 --- /dev/null +++ b/src/space-model/handlers/index.ts @@ -0,0 +1,2 @@ +export * from './propate-subspace-handler'; +export * from './propogate-space-model-deletion.handler'; diff --git a/src/space-model/handlers/propate-subspace-handler.ts b/src/space-model/handlers/propate-subspace-handler.ts new file mode 100644 index 0000000..7c0bfd9 --- /dev/null +++ b/src/space-model/handlers/propate-subspace-handler.ts @@ -0,0 +1,246 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { PropogateUpdateSpaceModelCommand } from '../commands'; +import { SpaceEntity, SpaceRepository } from '@app/common/modules/space'; +import { SubspaceRepository } from '@app/common/modules/space/repositories/subspace.repository'; +import { + SpaceModelEntity, + SubspaceModelEntity, + TagModel, +} from '@app/common/modules/space-model'; +import { DataSource, QueryRunner } from 'typeorm'; +import { SubSpaceService } from 'src/space/services'; +import { TagService } from 'src/space/services/tag'; +import { TagModelService } from '../services'; +import { UpdatedSubspaceModelPayload } from '../interfaces'; +import { ModifyAction } from '@app/common/constants/modify-action.enum'; +import { ModifySubspaceDto } from 'src/space/dtos'; + +@CommandHandler(PropogateUpdateSpaceModelCommand) +export class PropogateUpdateSpaceModelHandler + implements ICommandHandler +{ + constructor( + private readonly spaceRepository: SpaceRepository, + private readonly subspaceRepository: SubspaceRepository, + private readonly dataSource: DataSource, + private readonly subSpaceService: SubSpaceService, + private readonly tagService: TagService, + private readonly tagModelService: TagModelService, + ) {} + + async execute(command: PropogateUpdateSpaceModelCommand): Promise { + const { spaceModel, modifiedSpaceModels, queryRunner } = command.param; + + try { + const spaces = await queryRunner.manager.find(SpaceEntity, { + where: { spaceModel }, + }); + + const { modifiedSubspaceModels = {}, modifiedTags = {} } = + modifiedSpaceModels; + + const { + addedSubspaceModels = [], + updatedSubspaceModels = [], + deletedSubspaceModels = [], + } = modifiedSubspaceModels; + + const { added = [], updated = [], deleted = [] } = modifiedTags; + + if (addedSubspaceModels.length > 0) { + await this.addSubspaceModels( + modifiedSpaceModels.modifiedSubspaceModels.addedSubspaceModels, + spaces, + queryRunner, + ); + } else if (updatedSubspaceModels.length > 0) { + await this.updateSubspaceModels( + modifiedSpaceModels.modifiedSubspaceModels.updatedSubspaceModels, + queryRunner, + ); + } + if (deletedSubspaceModels.length > 0) { + const dtos: ModifySubspaceDto[] = + modifiedSpaceModels.modifiedSubspaceModels.deletedSubspaceModels.map( + (model) => ({ + action: ModifyAction.DELETE, + uuid: model, + }), + ); + await this.subSpaceService.modifySubSpace(dtos, queryRunner); + } + + if (added.length > 0) { + await this.createTags( + modifiedSpaceModels.modifiedTags.added, + queryRunner, + null, + spaceModel, + ); + } + + if (updated.length > 0) { + await this.updateTags( + modifiedSpaceModels.modifiedTags.updated, + queryRunner, + ); + } + + if (deleted.length > 0) { + await this.deleteTags( + modifiedSpaceModels.modifiedTags.deleted, + queryRunner, + ); + } + } catch (error) { + console.error(error); + } + } + + async addSubspaceModels( + subspaceModels: SubspaceModelEntity[], + spaces: SpaceEntity[], + queryRunner: QueryRunner, + ) { + for (const space of spaces) { + await this.subSpaceService.createSubSpaceFromModel( + subspaceModels, + space, + queryRunner, + ); + } + } + + async updateSubspaceModels( + subspaceModels: UpdatedSubspaceModelPayload[], + queryRunner: QueryRunner, + ): Promise { + const subspaceUpdatePromises = subspaceModels.map(async (model) => { + const { + updated: tagsToUpdate, + deleted: tagsToDelete, + added: tagsToAdd, + } = model.modifiedTags; + + // Perform tag operations concurrently + await Promise.all([ + tagsToUpdate?.length && this.updateTags(tagsToUpdate, queryRunner), + tagsToDelete?.length && this.deleteTags(tagsToDelete, queryRunner), + tagsToAdd?.length && + this.createTags( + tagsToAdd, + queryRunner, + model.subspaceModelUuid, + null, + ), + ]); + + // Update subspace names + const subspaces = await queryRunner.manager.find( + this.subspaceRepository.target, + { + where: { + subSpaceModel: { + uuid: model.subspaceModelUuid, + }, + }, + }, + ); + + if (subspaces.length > 0) { + const updateSubspacePromises = subspaces.map((subspace) => + queryRunner.manager.update( + this.subspaceRepository.target, + { uuid: subspace.uuid }, + { subspaceName: model.subspaceName }, + ), + ); + await Promise.all(updateSubspacePromises); + } + }); + + // Wait for all subspace model updates to complete + await Promise.all(subspaceUpdatePromises); + } + async updateTags(models: TagModel[], queryRunner: QueryRunner) { + if (!models?.length) return; + + const updatePromises = models.map((model) => + this.tagService.updateTagsFromModel(model, queryRunner), + ); + await Promise.all(updatePromises); + } + + async deleteTags(uuids: string[], queryRunner: QueryRunner) { + const deletePromises = uuids.map((uuid) => + this.tagService.deleteTagFromModel(uuid, queryRunner), + ); + await Promise.all(deletePromises); + } + + async createTags( + models: TagModel[], + queryRunner: QueryRunner, + subspaceModelUuid?: string, + spaceModel?: SpaceModelEntity, + ): Promise { + if (!models.length) { + return; + } + + if (subspaceModelUuid) { + await this.processSubspaces(subspaceModelUuid, models, queryRunner); + } + + if (spaceModel) { + await this.processSpaces(spaceModel.uuid, models, queryRunner); + } + } + + private async processSubspaces( + subspaceModelUuid: string, + models: TagModel[], + queryRunner: QueryRunner, + ): Promise { + const subspaces = await this.subspaceRepository.find({ + where: { + subSpaceModel: { + uuid: subspaceModelUuid, + }, + }, + }); + + if (subspaces.length > 0) { + const subspacePromises = subspaces.map((subspace) => + this.tagService.createTagsFromModel( + queryRunner, + models, + null, + subspace, + ), + ); + await Promise.all(subspacePromises); + } + } + + private async processSpaces( + spaceModelUuid: string, + models: TagModel[], + queryRunner: QueryRunner, + ): Promise { + const spaces = await this.spaceRepository.find({ + where: { + spaceModel: { + uuid: spaceModelUuid, + }, + }, + }); + + if (spaces.length > 0) { + const spacePromises = spaces.map((space) => + this.tagService.createTagsFromModel(queryRunner, models, space, null), + ); + await Promise.all(spacePromises); + } + } +} diff --git a/src/space-model/handlers/propogate-space-model-deletion.handler.ts b/src/space-model/handlers/propogate-space-model-deletion.handler.ts new file mode 100644 index 0000000..acd690e --- /dev/null +++ b/src/space-model/handlers/propogate-space-model-deletion.handler.ts @@ -0,0 +1,57 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { Logger } from '@nestjs/common'; + +import { PropogateDeleteSpaceModelCommand } from '../commands'; +import { SpaceRepository } from '@app/common/modules/space'; +import { SpaceService } from '../../space/services/space.service'; +import { DataSource } from 'typeorm'; + +@CommandHandler(PropogateDeleteSpaceModelCommand) +export class PropogateDeleteSpaceModelHandler + implements ICommandHandler +{ + private readonly logger = new Logger(PropogateDeleteSpaceModelHandler.name); + + constructor( + private readonly spaceRepository: SpaceRepository, + private readonly spaceService: SpaceService, + private readonly dataSource: DataSource, + ) {} + + async execute(command: PropogateDeleteSpaceModelCommand): Promise { + const { spaceModel } = command.param; + const queryRunner = this.dataSource.createQueryRunner(); + + try { + await queryRunner.connect(); + await queryRunner.startTransaction(); + const spaces = await this.spaceRepository.find({ + where: { + spaceModel: { + uuid: spaceModel.uuid, + }, + }, + relations: ['subspaces', 'tags', 'subspaces.tags'], + }); + + for (const space of spaces) { + try { + await this.spaceService.unlinkSpaceFromModel(space, queryRunner); + } catch (innerError) { + this.logger.error( + `Error unlinking space model for space with UUID ${space.uuid}:`, + innerError.stack || innerError, + ); + } + } + } catch (error) { + await queryRunner.rollbackTransaction(); + this.logger.error( + 'Error propagating delete space model:', + error.stack || error, + ); + } finally { + await queryRunner.release(); + } + } +} diff --git a/src/space-model/index.ts b/src/space-model/index.ts new file mode 100644 index 0000000..8885fc3 --- /dev/null +++ b/src/space-model/index.ts @@ -0,0 +1 @@ +export * from './space-model.module'; diff --git a/src/space-model/interfaces/index.ts b/src/space-model/interfaces/index.ts new file mode 100644 index 0000000..0bacfcd --- /dev/null +++ b/src/space-model/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './update-subspace.interface'; +export * from './modify-subspace.interface'; diff --git a/src/space-model/interfaces/modify-subspace.interface.ts b/src/space-model/interfaces/modify-subspace.interface.ts new file mode 100644 index 0000000..8969baf --- /dev/null +++ b/src/space-model/interfaces/modify-subspace.interface.ts @@ -0,0 +1,24 @@ +import { SubspaceModelEntity, TagModel } from '@app/common/modules/space-model'; + +export interface ModifyspaceModelPayload { + modifiedSubspaceModels?: ModifySubspaceModelPayload; + modifiedTags?: ModifiedTagsModelPayload; +} + +export interface ModifySubspaceModelPayload { + addedSubspaceModels?: SubspaceModelEntity[]; + updatedSubspaceModels?: UpdatedSubspaceModelPayload[]; + deletedSubspaceModels?: string[]; +} + +export interface UpdatedSubspaceModelPayload { + subspaceName?: string; + modifiedTags?: ModifiedTagsModelPayload; + subspaceModelUuid: string; +} + +export interface ModifiedTagsModelPayload { + added?: TagModel[]; + updated?: TagModel[]; + deleted?: string[]; +} diff --git a/src/space-model/interfaces/update-subspace.interface.ts b/src/space-model/interfaces/update-subspace.interface.ts new file mode 100644 index 0000000..10332ad --- /dev/null +++ b/src/space-model/interfaces/update-subspace.interface.ts @@ -0,0 +1,39 @@ +import { SubspaceModelEntity } from '@app/common/modules/space-model'; + +export interface AddSubspaceModelInterface { + subspaceModel: SubspaceModelEntity; +} + +export interface ProductModelInterface {} + +export interface IModifySubspaceModelInterface { + spaceModelUuid: string; + new?: AddSubspaceModelInterface[]; + update?: IUpdateSubspaceModelInterface[]; + delete?: IDeletedSubsaceModelInterface[]; +} + +export interface IModifiedProductItemsModelsInterface { + delete?: string[]; +} + +export interface IUpdateSubspaceModelInterface { + subspaceName?: string; + uuid: string; + productModels?: IModifiedProductItemsModelsInterface[]; +} + +export interface IDeletedSubsaceModelInterface { + uuid: string; +} + +export interface IUpdatedProductModelInterface { + productModelUuid: string; + productModifiedItemModel: IModifiedProductItemsModelsInterface; +} + +export interface IModifiedProductModelsInterface { + add?: ProductModelInterface[]; + update?: IUpdatedProductModelInterface[]; + delete?: string[]; +} diff --git a/src/space-model/services/index.ts b/src/space-model/services/index.ts new file mode 100644 index 0000000..20dca88 --- /dev/null +++ b/src/space-model/services/index.ts @@ -0,0 +1,3 @@ +export * from './space-model.service'; +export * from './subspace'; +export * from './tag-model.service'; diff --git a/src/space-model/services/space-model.service.ts b/src/space-model/services/space-model.service.ts new file mode 100644 index 0000000..2162c38 --- /dev/null +++ b/src/space-model/services/space-model.service.ts @@ -0,0 +1,400 @@ +import { + SpaceModelEntity, + SpaceModelRepository, +} from '@app/common/modules/space-model'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { CreateSpaceModelDto, UpdateSpaceModelDto } from '../dtos'; +import { ProjectParam } from 'src/community/dtos'; +import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; +import { SubSpaceModelService } from './subspace/subspace-model.service'; +import { DataSource, QueryRunner } from 'typeorm'; +import { + TypeORMCustomModel, + TypeORMCustomModelFindAllQuery, +} from '@app/common/models/typeOrmCustom.model'; +import { PageResponse } from '@app/common/dto/pagination.response.dto'; +import { SpaceModelParam } from '../dtos/space-model-param'; +import { ProjectService } from 'src/project/services'; +import { ProjectEntity } from '@app/common/modules/project/entities'; +import { TagModelService } from './tag-model.service'; +import { BaseResponseDto } from '@app/common/dto/base.response.dto'; +import { CommandBus } from '@nestjs/cqrs'; +import { PropogateUpdateSpaceModelCommand } from '../commands'; +import { + ModifiedTagsModelPayload, + ModifySubspaceModelPayload, +} from '../interfaces'; +import { SpaceModelDto } from '@app/common/modules/space-model/dtos'; + +@Injectable() +export class SpaceModelService { + constructor( + private readonly dataSource: DataSource, + private readonly spaceModelRepository: SpaceModelRepository, + private readonly projectService: ProjectService, + private readonly subSpaceModelService: SubSpaceModelService, + private readonly tagModelService: TagModelService, + private commandBus: CommandBus, + ) {} + + async createSpaceModel( + createSpaceModelDto: CreateSpaceModelDto, + params: ProjectParam, + ): Promise { + const { modelName, subspaceModels, tags } = createSpaceModelDto; + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const project = await this.validateProject(params.projectUuid); + + await this.validateNameUsingQueryRunner( + modelName, + params.projectUuid, + queryRunner, + ); + + const spaceModel = this.spaceModelRepository.create({ + modelName, + project, + }); + + const savedSpaceModel = await queryRunner.manager.save(spaceModel); + + if (subspaceModels?.length) { + savedSpaceModel.subspaceModels = + await this.subSpaceModelService.createSubSpaceModels( + subspaceModels, + savedSpaceModel, + queryRunner, + tags, + ); + } + + if (tags?.length) { + savedSpaceModel.tags = await this.tagModelService.createTags( + tags, + queryRunner, + savedSpaceModel, + null, + ); + } + + await queryRunner.commitTransaction(); + + return new SuccessResponseDto({ + message: `Successfully created new space model with uuid ${savedSpaceModel.uuid}`, + data: savedSpaceModel, + statusCode: HttpStatus.CREATED, + }); + } catch (error) { + await queryRunner.rollbackTransaction(); + + const errorMessage = + error instanceof HttpException + ? error.message + : 'An unexpected error occurred'; + const statusCode = + error instanceof HttpException + ? error.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + throw new HttpException(errorMessage, statusCode); + } finally { + await queryRunner.release(); + } + } + + async list( + param: ProjectParam, + pageable: Partial, + ) { + await this.validateProject(param.projectUuid); + + try { + pageable.modelName = 'space-model'; + pageable.where = { + project: { uuid: param.projectUuid }, + disabled: false, + }; + pageable.include = + 'subspaceModels,tags,subspaceModels.tags,tags.product,subspaceModels.tags.product'; + + const queryBuilder = await this.spaceModelRepository + .createQueryBuilder('spaceModel') + .leftJoinAndSelect( + 'spaceModel.subspaceModels', + 'subspaceModels', + 'subspaceModels.disabled = :subspaceDisabled', + { subspaceDisabled: false }, + ) + .leftJoinAndSelect( + 'spaceModel.tags', + 'tags', + 'tags.disabled = :tagsDisabled', + { tagsDisabled: false }, + ) + .leftJoinAndSelect('tags.product', 'spaceTagproduct') + .leftJoinAndSelect( + 'subspaceModels.tags', + 'subspaceModelTags', + 'subspaceModelTags.disabled = :subspaceModelTagsDisabled', + { subspaceModelTagsDisabled: false }, + ) + .leftJoinAndSelect('subspaceModelTags.product', 'subspaceTagproduct') + .where('spaceModel.disabled = :disabled', { disabled: false }) + .andWhere('spaceModel.project = :projectUuid', { + projectUuid: param.projectUuid, + }); + + const customModel = TypeORMCustomModel(this.spaceModelRepository); + const { baseResponseDto, paginationResponseDto } = + await customModel.findAll( + { + page: pageable.page || 1, + size: pageable.size || 10, + modelName: 'spaceModel', + }, + queryBuilder, + ); + + return new PageResponse( + baseResponseDto, + paginationResponseDto, + ); + } catch (error) { + throw new HttpException( + `Error fetching paginated list: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async validateProject(projectUuid: string): Promise { + return await this.projectService.findOne(projectUuid); + } + + async update(dto: UpdateSpaceModelDto, param: SpaceModelParam) { + const queryRunner = this.dataSource.createQueryRunner(); + await this.validateProject(param.projectUuid); + const spaceModel = await this.validateSpaceModel(param.spaceModelUuid); + await queryRunner.connect(); + + let modifiedSubspaceModels: ModifySubspaceModelPayload = {}; + let modifiedTagsModelPayload: ModifiedTagsModelPayload = {}; + try { + await queryRunner.startTransaction(); + + const { modelName } = dto; + if (modelName) { + await this.validateNameUsingQueryRunner( + modelName, + param.projectUuid, + queryRunner, + ); + spaceModel.modelName = modelName; + await queryRunner.manager.save( + this.spaceModelRepository.target, + spaceModel, + ); + } + + const spaceTagsAfterMove = this.tagModelService.getSubspaceTagsToBeAdded( + dto.tags, + dto.subspaceModels, + ); + + const modifiedSubspaces = this.tagModelService.getModifiedSubspaces( + dto.tags, + dto.subspaceModels, + ); + + if (dto.subspaceModels) { + modifiedSubspaceModels = + await this.subSpaceModelService.modifySubSpaceModels( + modifiedSubspaces, + spaceModel, + queryRunner, + ); + } + + if (dto.tags) { + modifiedTagsModelPayload = await this.tagModelService.modifyTags( + spaceTagsAfterMove, + queryRunner, + spaceModel, + null, + ); + } + + await queryRunner.commitTransaction(); + + await this.commandBus.execute( + new PropogateUpdateSpaceModelCommand({ + spaceModel: spaceModel, + modifiedSpaceModels: { + modifiedSubspaceModels, + modifiedTags: modifiedTagsModelPayload, + }, + queryRunner, + }), + ); + + return new SuccessResponseDto({ + message: 'SpaceModel updated successfully', + }); + } catch (error) { + await queryRunner.rollbackTransaction(); + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + error.message || 'Failed to update SpaceModel', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } finally { + await queryRunner.release(); + } + } + + async deleteSpaceModel(param: SpaceModelParam): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + await this.validateProject(param.projectUuid); + const spaceModel = await this.validateSpaceModel(param.spaceModelUuid); + + if (spaceModel.subspaceModels?.length) { + const deleteSubspaceDtos = spaceModel.subspaceModels.map( + (subspace) => ({ + subspaceUuid: subspace.uuid, + }), + ); + + await this.subSpaceModelService.deleteSubspaceModels( + deleteSubspaceDtos, + queryRunner, + ); + } + + if (spaceModel.tags?.length) { + const deleteSpaceTagsDtos = spaceModel.tags.map((tag) => tag.uuid); + + await this.tagModelService.deleteTags(deleteSpaceTagsDtos, queryRunner); + } + + await queryRunner.manager.update( + this.spaceModelRepository.target, + { uuid: param.spaceModelUuid }, + { disabled: true }, + ); + + await queryRunner.commitTransaction(); + + return new SuccessResponseDto({ + message: `SpaceModel with UUID ${param.spaceModelUuid} deleted successfully.`, + statusCode: HttpStatus.OK, + }); + } catch (error) { + await queryRunner.rollbackTransaction(); + + const errorMessage = + error instanceof HttpException + ? error.message + : 'An unexpected error occurred while deleting the SpaceModel'; + const statusCode = + error instanceof HttpException + ? error.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + throw new HttpException(errorMessage, statusCode); + } finally { + await queryRunner.release(); + } + } + + async validateName(modelName: string, projectUuid: string): Promise { + const isModelExist = await this.spaceModelRepository.findOne({ + where: { modelName, project: { uuid: projectUuid }, disabled: false }, + }); + + if (isModelExist) { + throw new HttpException( + `Model name ${modelName} already exists in the project with UUID ${projectUuid}.`, + HttpStatus.CONFLICT, + ); + } + } + + async validateNameUsingQueryRunner( + modelName: string, + projectUuid: string, + queryRunner: QueryRunner, + ): Promise { + const isModelExist = await queryRunner.manager.findOne(SpaceModelEntity, { + where: { modelName, project: { uuid: projectUuid }, disabled: false }, + }); + + if (isModelExist) { + throw new HttpException( + `Model name ${modelName} already exists in the project with UUID ${projectUuid}.`, + HttpStatus.CONFLICT, + ); + } + } + + async findOne(params: SpaceModelParam): Promise { + try { + await this.validateProject(params.projectUuid); + const spaceModel = await this.validateSpaceModel(params.spaceModelUuid); + + return new SuccessResponseDto({ + message: 'SpaceModel retrieved successfully', + data: spaceModel, + }); + } catch (error) { + throw new HttpException( + `Failed to retrieve space model: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async validateSpaceModel(uuid: string): Promise { + const spaceModel = await this.spaceModelRepository + .createQueryBuilder('spaceModel') + .leftJoinAndSelect( + 'spaceModel.subspaceModels', + 'subspaceModels', + 'subspaceModels.disabled = :subspaceDisabled', + { subspaceDisabled: false }, + ) + .leftJoinAndSelect( + 'spaceModel.tags', + 'tags', + 'tags.disabled = :tagsDisabled', + { tagsDisabled: false }, + ) + .leftJoinAndSelect('tags.product', 'spaceTagproduct') + .leftJoinAndSelect( + 'subspaceModels.tags', + 'subspaceModelTags', + 'subspaceModelTags.disabled = :subspaceModelTagsDisabled', + { subspaceModelTagsDisabled: false }, + ) + .leftJoinAndSelect('subspaceModelTags.product', 'subspaceTagproduct') + .where('spaceModel.disabled = :disabled', { disabled: false }) + .where('spaceModel.disabled = :disabled', { disabled: false }) + .andWhere('spaceModel.uuid = :uuid', { uuid }) + .getOne(); + + if (!spaceModel) { + throw new HttpException('space model not found', HttpStatus.NOT_FOUND); + } + return spaceModel; + } +} diff --git a/src/space-model/services/subspace/index.ts b/src/space-model/services/subspace/index.ts new file mode 100644 index 0000000..3965e6d --- /dev/null +++ b/src/space-model/services/subspace/index.ts @@ -0,0 +1 @@ +export * from './subspace-model.service'; diff --git a/src/space-model/services/subspace/subspace-model.service.ts b/src/space-model/services/subspace/subspace-model.service.ts new file mode 100644 index 0000000..bd76a9c --- /dev/null +++ b/src/space-model/services/subspace/subspace-model.service.ts @@ -0,0 +1,371 @@ +import { + SpaceModelEntity, + SubspaceModelEntity, + SubspaceModelRepository, +} from '@app/common/modules/space-model'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { CreateSubspaceModelDto, CreateTagModelDto } from '../../dtos'; +import { Not, QueryRunner } from 'typeorm'; +import { + IDeletedSubsaceModelInterface, + ModifySubspaceModelPayload, + UpdatedSubspaceModelPayload, +} from 'src/space-model/interfaces'; +import { + DeleteSubspaceModelDto, + ModifySubspaceModelDto, +} from 'src/space-model/dtos/subspaces-model-dtos'; +import { TagModelService } from '../tag-model.service'; +import { ModifyAction } from '@app/common/constants/modify-action.enum'; + +@Injectable() +export class SubSpaceModelService { + constructor( + private readonly subspaceModelRepository: SubspaceModelRepository, + private readonly tagModelService: TagModelService, + ) {} + + async createSubSpaceModels( + subSpaceModelDtos: CreateSubspaceModelDto[], + spaceModel: SpaceModelEntity, + queryRunner: QueryRunner, + otherTags?: CreateTagModelDto[], + ): Promise { + try { + await this.validateInputDtos(subSpaceModelDtos, spaceModel); + + const subspaces = subSpaceModelDtos.map((subspaceDto) => + queryRunner.manager.create(this.subspaceModelRepository.target, { + subspaceName: subspaceDto.subspaceName, + spaceModel, + }), + ); + + const savedSubspaces = await queryRunner.manager.save(subspaces); + + await Promise.all( + subSpaceModelDtos.map(async (dto, index) => { + const subspace = savedSubspaces[index]; + + const otherDtoTags = subSpaceModelDtos + .filter((_, i) => i !== index) + .flatMap((otherDto) => otherDto.tags || []); + if (dto.tags && dto.tags.length > 0) { + subspace.tags = await this.tagModelService.createTags( + dto.tags, + queryRunner, + null, + subspace, + [...(otherTags || []), ...otherDtoTags], + ); + } + }), + ); + + return savedSubspaces; + } catch (error) { + if (error instanceof HttpException) { + throw error; // Rethrow known HttpExceptions + } + + // Handle unexpected errors + throw new HttpException( + `An error occurred while creating subspace models: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async deleteSubspaceModels( + deleteDtos: DeleteSubspaceModelDto[], + queryRunner: QueryRunner, + ): Promise { + const deleteResults: IDeletedSubsaceModelInterface[] = []; + + for (const dto of deleteDtos) { + const subspaceModel = await this.findOne(dto.subspaceUuid); + + await queryRunner.manager.update( + this.subspaceModelRepository.target, + { uuid: dto.subspaceUuid }, + { disabled: true }, + ); + + if (subspaceModel.tags?.length) { + const modifyTagDtos = subspaceModel.tags.map((tag) => ({ + uuid: tag.uuid, + action: ModifyAction.DELETE, + })); + await this.tagModelService.modifyTags( + modifyTagDtos, + queryRunner, + null, + subspaceModel, + ); + } + + deleteResults.push({ uuid: dto.subspaceUuid }); + } + + return deleteResults; + } + + async modifySubSpaceModels( + subspaceDtos: ModifySubspaceModelDto[], + spaceModel: SpaceModelEntity, + queryRunner: QueryRunner, + ): Promise { + const modifiedSubspaceModels: ModifySubspaceModelPayload = { + addedSubspaceModels: [], + updatedSubspaceModels: [], + deletedSubspaceModels: [], + }; + try { + for (const subspace of subspaceDtos) { + switch (subspace.action) { + case ModifyAction.ADD: + const subspaceModel = await this.handleAddAction( + subspace, + spaceModel, + queryRunner, + ); + modifiedSubspaceModels.addedSubspaceModels.push(subspaceModel); + break; + case ModifyAction.UPDATE: + const updatedSubspaceModel = await this.handleUpdateAction( + subspace, + queryRunner, + ); + modifiedSubspaceModels.updatedSubspaceModels.push( + updatedSubspaceModel, + ); + break; + case ModifyAction.DELETE: + await this.handleDeleteAction(subspace, queryRunner); + modifiedSubspaceModels.deletedSubspaceModels.push(subspace.uuid); + break; + default: + throw new HttpException( + `Invalid action "${subspace.action}".`, + HttpStatus.BAD_REQUEST, + ); + } + } + return modifiedSubspaceModels; + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + `An error occurred while modifying subspace models: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async handleAddAction( + subspace: ModifySubspaceModelDto, + spaceModel: SpaceModelEntity, + queryRunner: QueryRunner, + ): Promise { + try { + const createTagDtos: CreateTagModelDto[] = + subspace.tags?.map((tag) => ({ + tag: tag.tag, + uuid: tag.uuid, + productUuid: tag.productUuid, + })) || []; + + const [createdSubspaceModel] = await this.createSubSpaceModels( + [ + { + subspaceName: subspace.subspaceName, + tags: createTagDtos, + }, + ], + spaceModel, + queryRunner, + ); + + return createdSubspaceModel; + } catch (error) { + if (error instanceof HttpException) { + throw error; // Rethrow known HttpExceptions + } + + throw new HttpException( + `An error occurred while adding subspace: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async handleUpdateAction( + modifyDto: ModifySubspaceModelDto, + queryRunner: QueryRunner, + ): Promise { + const updatePayload: UpdatedSubspaceModelPayload = { + subspaceModelUuid: modifyDto.uuid, + }; + + const subspace = await this.findOne(modifyDto.uuid); + + await this.updateSubspaceName( + queryRunner, + subspace, + modifyDto.subspaceName, + ); + updatePayload.subspaceName = modifyDto.subspaceName; + + if (modifyDto.tags?.length) { + updatePayload.modifiedTags = await this.tagModelService.modifyTags( + modifyDto.tags, + queryRunner, + null, + subspace, + ); + } + + return updatePayload; + } + + private async handleDeleteAction( + subspace: ModifySubspaceModelDto, + queryRunner: QueryRunner, + ) { + const subspaceModel = await this.findOne(subspace.uuid); + + await queryRunner.manager.update( + this.subspaceModelRepository.target, + { uuid: subspace.uuid }, + { disabled: true }, + ); + + if (subspaceModel.tags?.length) { + const modifyTagDtos: CreateTagModelDto[] = subspaceModel.tags.map( + (tag) => ({ + uuid: tag.uuid, + action: ModifyAction.ADD, + tag: tag.tag, + productUuid: tag.product.uuid, + }), + ); + await this.tagModelService.moveTags( + modifyTagDtos, + queryRunner, + subspaceModel.spaceModel, + null, + ); + } + } + + private async findOne(subspaceUuid: string): Promise { + const subspace = await this.subspaceModelRepository.findOne({ + where: { uuid: subspaceUuid, disabled: false }, + relations: ['tags', 'spaceModel', 'tags.product'], + }); + if (!subspace) { + throw new HttpException( + `SubspaceModel with UUID ${subspaceUuid} not found.`, + HttpStatus.NOT_FOUND, + ); + } + return subspace; + } + + private async validateInputDtos( + subSpaceModelDtos: CreateSubspaceModelDto[], + spaceModel: SpaceModelEntity, + ): Promise { + try { + if (subSpaceModelDtos.length === 0) { + throw new HttpException( + 'Subspace models cannot be empty.', + HttpStatus.BAD_REQUEST, + ); + } + + await this.validateName( + subSpaceModelDtos.map((dto) => dto.subspaceName), + spaceModel, + ); + } catch (error) { + if (error instanceof HttpException) { + throw error; // Rethrow known HttpExceptions to preserve their message and status + } + + // Wrap unexpected errors + throw new HttpException( + `An error occurred while validating subspace models: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async checkDuplicateNames( + subspaceName: string, + spaceModelUuid: string, + excludeUuid?: string, + ): Promise { + const duplicateSubspace = await this.subspaceModelRepository.findOne({ + where: { + subspaceName, + spaceModel: { + uuid: spaceModelUuid, + }, + disabled: false, + ...(excludeUuid && { uuid: Not(excludeUuid) }), + }, + }); + + if (duplicateSubspace) { + throw new HttpException( + `A subspace with the name '${subspaceName}' already exists within the same space model. ${spaceModelUuid}`, + HttpStatus.CONFLICT, + ); + } + } + + private async validateName( + names: string[], + spaceModel: SpaceModelEntity, + ): Promise { + const seenNames = new Set(); + const duplicateNames = new Set(); + + for (const name of names) { + if (!seenNames.add(name)) { + duplicateNames.add(name); + } + } + + if (duplicateNames.size > 0) { + throw new HttpException( + `Duplicate subspace model names found: ${[...duplicateNames].join(', ')}`, + HttpStatus.CONFLICT, + ); + } + + for (const name of names) { + await this.checkDuplicateNames(name, spaceModel.uuid); + } + } + + private async updateSubspaceName( + queryRunner: QueryRunner, + subSpaceModel: SubspaceModelEntity, + subspaceName?: string, + ): Promise { + if (subspaceName) { + await this.checkDuplicateNames( + subspaceName, + subSpaceModel.spaceModel.uuid, + subSpaceModel.uuid, + ); + + subSpaceModel.subspaceName = subspaceName; + await queryRunner.manager.save(subSpaceModel); + } + } +} diff --git a/src/space-model/services/tag-model.service.ts b/src/space-model/services/tag-model.service.ts new file mode 100644 index 0000000..b663590 --- /dev/null +++ b/src/space-model/services/tag-model.service.ts @@ -0,0 +1,546 @@ +import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { QueryRunner } from 'typeorm'; +import { + SpaceModelEntity, + TagModel, +} from '@app/common/modules/space-model/entities'; +import { SubspaceModelEntity } from '@app/common/modules/space-model/entities'; +import { TagModelRepository } from '@app/common/modules/space-model'; +import { + CreateTagModelDto, + ModifySubspaceModelDto, + ModifyTagModelDto, +} from '../dtos'; +import { ProductService } from 'src/product/services'; +import { ModifyAction } from '@app/common/constants/modify-action.enum'; +import { ModifiedTagsModelPayload } from '../interfaces'; + +@Injectable() +export class TagModelService { + constructor( + private readonly tagModelRepository: TagModelRepository, + private readonly productService: ProductService, + ) {} + + async createTags( + tags: CreateTagModelDto[], + queryRunner: QueryRunner, + spaceModel?: SpaceModelEntity, + subspaceModel?: SubspaceModelEntity, + additionalTags?: CreateTagModelDto[], + tagsToDelete?: ModifyTagModelDto[], + ): Promise { + if (!tags.length) { + throw new HttpException('Tags cannot be empty.', HttpStatus.BAD_REQUEST); + } + + const combinedTags = additionalTags ? [...tags, ...additionalTags] : tags; + const duplicateTags = this.findDuplicateTags(combinedTags); + + if (duplicateTags.length > 0) { + throw new HttpException( + `Duplicate tags found for the same product: ${duplicateTags.join(', ')}`, + HttpStatus.BAD_REQUEST, + ); + } + + const tagEntitiesToCreate = tags.filter((tagDto) => !tagDto.uuid); + const tagEntitiesToUpdate = tags.filter((tagDto) => !!tagDto.uuid); + + try { + const createdTags = await this.bulkSaveTags( + tagEntitiesToCreate, + queryRunner, + spaceModel, + subspaceModel, + tagsToDelete, + ); + + // Update existing tags + const updatedTags = await this.moveTags( + tagEntitiesToUpdate, + queryRunner, + spaceModel, + subspaceModel, + ); + + // Combine created and updated tags + return [...createdTags, ...updatedTags]; + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + `Failed to create tag models due to an unexpected error.: ${error}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async bulkSaveTags( + tags: CreateTagModelDto[], + queryRunner: QueryRunner, + spaceModel?: SpaceModelEntity, + subspaceModel?: SubspaceModelEntity, + tagsToDelete?: ModifyTagModelDto[], + ): Promise { + if (!tags.length) { + return []; + } + + const tagEntities = await Promise.all( + tags.map((tagDto) => + this.prepareTagEntity( + tagDto, + queryRunner, + spaceModel, + subspaceModel, + tagsToDelete, + ), + ), + ); + + try { + return await queryRunner.manager.save(tagEntities); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + `Failed to save tag models due to an unexpected error: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async moveTags( + tags: CreateTagModelDto[], + queryRunner: QueryRunner, + spaceModel?: SpaceModelEntity, + subspaceModel?: SubspaceModelEntity, + ): Promise { + if (!tags.length) { + return []; + } + + try { + return await Promise.all( + tags.map(async (tagDto) => { + try { + const tag = await this.getTagByUuid(tagDto.uuid); + if (!tag) { + throw new HttpException( + `Tag with UUID ${tagDto.uuid} not found.`, + HttpStatus.NOT_FOUND, + ); + } + + if (subspaceModel && subspaceModel.spaceModel) { + await queryRunner.manager.update( + this.tagModelRepository.target, + { uuid: tag.uuid }, + { subspaceModel, spaceModel: null }, + ); + tag.subspaceModel = subspaceModel; + } + + if (!subspaceModel && spaceModel) { + await queryRunner.manager.update( + this.tagModelRepository.target, + { uuid: tag.uuid }, + { subspaceModel: null, spaceModel: spaceModel }, + ); + tag.subspaceModel = null; + tag.spaceModel = spaceModel; + } + + return tag; + } catch (error) { + console.error( + `Error moving tag with UUID ${tagDto.uuid}: ${error.message}`, + ); + throw error; // Re-throw the error to propagate it to the parent Promise.all + } + }), + ); + } catch (error) { + console.error(`Error in moveTags: ${error.message}`); + throw new HttpException( + `Failed to move tags due to an unexpected error: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async updateTag( + tag: ModifyTagModelDto, + queryRunner: QueryRunner, + spaceModel?: SpaceModelEntity, + subspaceModel?: SubspaceModelEntity, + ): Promise { + try { + const existingTag = await this.getTagByUuid(tag.uuid); + + if (tag.tag !== existingTag.tag) { + if (spaceModel) { + await this.checkTagReuse( + tag.tag, + existingTag.product.uuid, + spaceModel, + ); + } else { + await this.checkTagReuse( + tag.tag, + existingTag.product.uuid, + subspaceModel.spaceModel, + ); + } + + if (tag.tag) { + existingTag.tag = tag.tag; + } + } + return await queryRunner.manager.save(existingTag); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + throw new HttpException( + error.message || 'Failed to update tags', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async deleteTags(tagUuids: string[], queryRunner: QueryRunner) { + try { + const deletePromises = tagUuids.map((id) => + queryRunner.manager.update( + this.tagModelRepository.target, + { uuid: id }, + { disabled: true }, + ), + ); + + await Promise.all(deletePromises); + return { message: 'Tags deleted successfully', tagUuids }; + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + throw new HttpException( + error.message || 'Failed to delete tags', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private findDuplicateTags(tags: CreateTagModelDto[]): string[] { + const seen = new Map(); + const duplicates: string[] = []; + + tags.forEach((tagDto) => { + const key = `${tagDto.productUuid}-${tagDto.tag}`; + if (seen.has(key)) { + duplicates.push(`${tagDto.tag} for Product: ${tagDto.productUuid}`); + } else { + seen.set(key, true); + } + }); + + return duplicates; + } + + async modifyTags( + tags: ModifyTagModelDto[], + queryRunner: QueryRunner, + spaceModel?: SpaceModelEntity, + subspaceModel?: SubspaceModelEntity, + ): Promise { + const modifiedTagModels: ModifiedTagsModelPayload = { + added: [], + updated: [], + deleted: [], + }; + try { + const tagsToDelete = tags.filter( + (tag) => tag.action === ModifyAction.DELETE, + ); + + for (const tag of tags) { + if (tag.action === ModifyAction.ADD) { + const createTagDto: CreateTagModelDto = { + tag: tag.tag as string, + uuid: tag.uuid, + productUuid: tag.productUuid as string, + }; + + const newModel = await this.createTags( + [createTagDto], + queryRunner, + spaceModel, + subspaceModel, + null, + tagsToDelete, + ); + modifiedTagModels.added.push(...newModel); + } else if (tag.action === ModifyAction.UPDATE) { + const updatedModel = await this.updateTag( + tag, + queryRunner, + spaceModel, + subspaceModel, + ); + modifiedTagModels.updated.push(updatedModel); + } else if (tag.action === ModifyAction.DELETE) { + await queryRunner.manager.update( + this.tagModelRepository.target, + { uuid: tag.uuid }, + { disabled: true }, + ); + modifiedTagModels.deleted.push(tag.uuid); + } else { + throw new HttpException( + `Invalid action "${tag.action}" provided.`, + HttpStatus.BAD_REQUEST, + ); + } + } + return modifiedTagModels; + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + `An error occurred while modifying tag models: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async checkTagReuse( + tag: string, + productUuid: string, + spaceModel: SpaceModelEntity, + tagsToDelete?: ModifyTagModelDto[], + ): Promise { + try { + // Query to find existing tags + const tagExists = await this.tagModelRepository.find({ + where: [ + { + tag, + spaceModel: { uuid: spaceModel.uuid }, + product: { uuid: productUuid }, + disabled: false, + }, + { + tag, + subspaceModel: { spaceModel: { uuid: spaceModel.uuid } }, + product: { uuid: productUuid }, + disabled: false, + }, + ], + }); + + // Remove tags that are marked for deletion + const filteredTagExists = tagExists.filter( + (existingTag) => + !tagsToDelete?.some( + (deleteTag) => deleteTag.uuid === existingTag.uuid, + ), + ); + + // If any tags remain, throw an exception + if (filteredTagExists.length > 0) { + throw new HttpException( + `Tag ${tag} can't be reused`, + HttpStatus.CONFLICT, + ); + } + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + console.error(`Error while checking tag reuse: ${error.message}`); + throw new HttpException( + `An error occurred while checking tag reuse: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async prepareTagEntity( + tagDto: CreateTagModelDto, + queryRunner: QueryRunner, + spaceModel?: SpaceModelEntity, + subspaceModel?: SubspaceModelEntity, + tagsToDelete?: ModifyTagModelDto[], + ): Promise { + try { + const product = await this.productService.findOne(tagDto.productUuid); + + if (!product) { + throw new HttpException( + `Product with UUID ${tagDto.productUuid} not found.`, + HttpStatus.NOT_FOUND, + ); + } + + if (spaceModel) { + await this.checkTagReuse( + tagDto.tag, + tagDto.productUuid, + spaceModel, + tagsToDelete, + ); + } else if (subspaceModel && subspaceModel.spaceModel) { + await this.checkTagReuse( + tagDto.tag, + tagDto.productUuid, + subspaceModel.spaceModel, + ); + } else { + throw new HttpException( + `Invalid subspaceModel or spaceModel provided.`, + HttpStatus.BAD_REQUEST, + ); + } + + return queryRunner.manager.create(TagModel, { + tag: tagDto.tag, + product: product.data, + spaceModel: spaceModel, + subspaceModel: subspaceModel, + }); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + throw new HttpException( + `An error occurred while preparing the tag entity: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async getTagByUuid(uuid: string): Promise { + const tag = await this.tagModelRepository.findOne({ + where: { uuid, disabled: false }, + relations: ['product'], + }); + if (!tag) { + throw new HttpException( + `Tag model with ID ${uuid} not found.`, + HttpStatus.NOT_FOUND, + ); + } + return tag; + } + + async getTagByName( + tag: string, + subspaceUuid?: string, + spaceUuid?: string, + ): Promise { + const queryConditions: any = { tag }; + + if (spaceUuid) { + queryConditions.spaceModel = { uuid: spaceUuid }; + } else if (subspaceUuid) { + queryConditions.subspaceModel = { uuid: subspaceUuid }; + } else { + throw new HttpException( + 'Either spaceUuid or subspaceUuid must be provided.', + HttpStatus.BAD_REQUEST, + ); + } + queryConditions.disabled = false; + + const existingTag = await this.tagModelRepository.findOne({ + where: queryConditions, + relations: ['product'], + }); + + if (!existingTag) { + throw new HttpException( + `Tag model with tag "${tag}" not found.`, + HttpStatus.NOT_FOUND, + ); + } + + return existingTag; + } + + getSubspaceTagsToBeAdded( + spaceTags?: ModifyTagModelDto[], + subspaceModels?: ModifySubspaceModelDto[], + ): ModifyTagModelDto[] { + if (!subspaceModels || subspaceModels.length === 0) { + return spaceTags; + } + + const spaceTagsToDelete = spaceTags?.filter( + (tag) => tag.action === 'delete', + ); + + const tagsToAdd = subspaceModels.flatMap( + (subspace) => subspace.tags?.filter((tag) => tag.action === 'add') || [], + ); + + const commonTagUuids = new Set( + tagsToAdd + .filter((tagToAdd) => + spaceTagsToDelete.some( + (tagToDelete) => tagToAdd.uuid === tagToDelete.uuid, + ), + ) + .map((tag) => tag.uuid), + ); + + const remainingTags = spaceTags.filter( + (tag) => !commonTagUuids.has(tag.uuid), // Exclude tags in commonTagUuids + ); + + return remainingTags; + } + + getModifiedSubspaces( + spaceTags: ModifyTagModelDto[], + subspaceModels: ModifySubspaceModelDto[], + ): ModifySubspaceModelDto[] { + if (!subspaceModels || subspaceModels.length === 0) { + return []; + } + + // Extract tags marked for addition in spaceTags + const spaceTagsToAdd = spaceTags.filter((tag) => tag.action === 'add'); + + // Find UUIDs of tags that are common between spaceTagsToAdd and subspace tags marked for deletion + const commonTagUuids = new Set( + spaceTagsToAdd + .flatMap((tagToAdd) => + subspaceModels.flatMap( + (subspace) => + subspace.tags?.filter( + (tagToDelete) => + tagToDelete.action === 'delete' && + tagToAdd.uuid === tagToDelete.uuid, + ) || [], + ), + ) + .map((tag) => tag.uuid), + ); + + // Modify subspaceModels by removing tags with UUIDs present in commonTagUuids + const modifiedSubspaces = subspaceModels.map((subspace) => ({ + ...subspace, + tags: subspace.tags?.filter((tag) => !commonTagUuids.has(tag.uuid)) || [], + })); + + return modifiedSubspaces; + } +} diff --git a/src/space-model/space-model.module.ts b/src/space-model/space-model.module.ts new file mode 100644 index 0000000..c00a00d --- /dev/null +++ b/src/space-model/space-model.module.ts @@ -0,0 +1,78 @@ +import { SpaceRepositoryModule } from '@app/common/modules/space/space.repository.module'; +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { SpaceModelController } from './controllers'; +import { + SpaceModelService, + SubSpaceModelService, + TagModelService, +} from './services'; +import { + SpaceModelRepository, + SubspaceModelRepository, + TagModelRepository, +} from '@app/common/modules/space-model'; +import { ProjectRepository } from '@app/common/modules/project/repositiories'; +import { ProductRepository } from '@app/common/modules/product/repositories'; +import { + PropogateDeleteSpaceModelHandler, + PropogateUpdateSpaceModelHandler, +} from './handlers'; +import { CqrsModule } from '@nestjs/cqrs'; +import { + InviteSpaceRepository, + SpaceLinkRepository, + SpaceRepository, + TagRepository, +} from '@app/common/modules/space'; +import { SubspaceRepository } from '@app/common/modules/space/repositories/subspace.repository'; +import { + SpaceLinkService, + SpaceService, + SubspaceDeviceService, + SubSpaceService, + ValidationService, +} from 'src/space/services'; +import { TagService } from 'src/space/services/tag'; +import { CommunityService } from 'src/community/services'; +import { DeviceRepository } from '@app/common/modules/device/repositories'; +import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; +import { CommunityRepository } from '@app/common/modules/community/repositories'; + +const CommandHandlers = [ + PropogateUpdateSpaceModelHandler, + PropogateDeleteSpaceModelHandler, +]; + +@Module({ + imports: [ConfigModule, SpaceRepositoryModule, CqrsModule], + controllers: [SpaceModelController], + providers: [ + ...CommandHandlers, + SpaceModelService, + SpaceService, + SpaceModelRepository, + SpaceRepository, + ProjectRepository, + SubSpaceModelService, + SubspaceModelRepository, + ProductRepository, + SubspaceRepository, + TagModelService, + TagModelRepository, + SubSpaceService, + ValidationService, + TagService, + SubspaceDeviceService, + CommunityService, + TagRepository, + DeviceRepository, + TuyaService, + CommunityRepository, + SpaceLinkService, + SpaceLinkRepository, + InviteSpaceRepository, + ], + exports: [CqrsModule, SpaceModelService], +}) +export class SpaceModelModule {} diff --git a/src/space/commands/disable-space.command.ts b/src/space/commands/disable-space.command.ts new file mode 100644 index 0000000..1c66f21 --- /dev/null +++ b/src/space/commands/disable-space.command.ts @@ -0,0 +1,10 @@ +import { SpaceEntity } from '@app/common/modules/space'; + +export class DisableSpaceCommand { + constructor( + public readonly param: { + spaceUuid: string; + orphanSpace: SpaceEntity; + }, + ) {} +} diff --git a/src/space/commands/index.ts b/src/space/commands/index.ts new file mode 100644 index 0000000..a9a7b85 --- /dev/null +++ b/src/space/commands/index.ts @@ -0,0 +1 @@ +export * from './disable-space.command'; diff --git a/src/space/common/base-product-item.service.ts b/src/space/common/base-product-item.service.ts new file mode 100644 index 0000000..dd62050 --- /dev/null +++ b/src/space/common/base-product-item.service.ts @@ -0,0 +1,69 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { QueryRunner } from 'typeorm'; + +export abstract class BaseProductItemService { + protected async validateTags( + incomingTags: string[], + queryRunner: QueryRunner, + spaceUuid: string, + ): Promise { + const duplicateTags = incomingTags.filter( + (tag, index) => incomingTags.indexOf(tag) !== index, + ); + + if (duplicateTags.length > 0) { + throw new HttpException( + `Duplicate tags found in the request: ${[...new Set(duplicateTags)].join(', ')}`, + HttpStatus.BAD_REQUEST, + ); + } + + const existingTagsQuery = ` + SELECT tag FROM ( + SELECT spi.tag + FROM "space-product-item" spi + INNER JOIN "space-product" spm ON spi.space_product_uuid = spm.uuid + WHERE spm.space_uuid = $1 + UNION + SELECT spi.tag + FROM "subspace-product-item" spi + INNER JOIN "subspace-product" spm ON spi.subspace_product_uuid = spm.uuid + INNER JOIN "subspace" sm ON spm.subspace_uuid = sm.uuid + WHERE sm.space_uuid = $1 + ) AS combined_tags; + `; + + const existingTags = await queryRunner.manager.query(existingTagsQuery, [ + spaceUuid, + ]); + + const existingTagSet = new Set( + existingTags.map((row: { tag: string }) => row.tag), + ); + const conflictingTags = incomingTags.filter((tag) => + existingTagSet.has(tag), + ); + + if (conflictingTags.length > 0) { + throw new HttpException( + `Tags already exist in the model: ${conflictingTags.join(', ')}`, + HttpStatus.CONFLICT, + ); + } + } + + protected async saveProductItems( + productItems: T[], + targetRepository: any, + queryRunner: QueryRunner, + ): Promise { + try { + await queryRunner.manager.save(targetRepository, productItems); + } catch (error) { + throw new HttpException( + error.message || 'An error occurred while creating product items.', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } +} diff --git a/src/space/common/index.ts b/src/space/common/index.ts new file mode 100644 index 0000000..6e76f39 --- /dev/null +++ b/src/space/common/index.ts @@ -0,0 +1 @@ +export * from './base-product-item.service'; diff --git a/src/space/controllers/space-device.controller.ts b/src/space/controllers/space-device.controller.ts index 160a130..7009531 100644 --- a/src/space/controllers/space-device.controller.ts +++ b/src/space/controllers/space-device.controller.ts @@ -1,10 +1,11 @@ import { ControllerRoute } from '@app/common/constants/controller-route'; import { Controller, Get, Param, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { GetSpaceParam } from '../dtos'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { SpaceDeviceService } from '../services'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { Permissions } from 'src/decorators/permissions.decorator'; @ApiTags('Space Module') @Controller({ @@ -15,7 +16,8 @@ export class SpaceDeviceController { constructor(private readonly spaceDeviceService: SpaceDeviceService) {} @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SPACE_DEVICE_VIEW_DEVICE_IN_SPACE') @ApiOperation({ summary: ControllerRoute.SPACE_DEVICES.ACTIONS.LIST_SPACE_DEVICE_SUMMARY, description: diff --git a/src/space/controllers/space-scene.controller.ts b/src/space/controllers/space-scene.controller.ts index 5517362..7d6a13d 100644 --- a/src/space/controllers/space-scene.controller.ts +++ b/src/space/controllers/space-scene.controller.ts @@ -1,11 +1,12 @@ import { ControllerRoute } from '@app/common/constants/controller-route'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; -import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { SpaceSceneService } from '../services'; import { GetSceneDto } from '../../scene/dtos'; import { GetSpaceParam } from '../dtos'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { Permissions } from 'src/decorators/permissions.decorator'; @ApiTags('Space Module') @Controller({ @@ -16,7 +17,8 @@ export class SpaceSceneController { constructor(private readonly sceneService: SpaceSceneService) {} @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SCENES_VIEW') @ApiOperation({ summary: ControllerRoute.SPACE_SCENE.ACTIONS.GET_TAP_TO_RUN_SCENE_BY_SPACE_SUMMARY, diff --git a/src/space/controllers/space-user.controller.ts b/src/space/controllers/space-user.controller.ts index 89fa3a0..d2709ad 100644 --- a/src/space/controllers/space-user.controller.ts +++ b/src/space/controllers/space-user.controller.ts @@ -3,8 +3,9 @@ import { Controller, Delete, Param, Post, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { SpaceUserService } from '../services'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; -import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { UserSpaceParam } from '../dtos'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { Permissions } from 'src/decorators/permissions.decorator'; @ApiTags('Space Module') @Controller({ @@ -16,7 +17,8 @@ export class SpaceUserController { @ApiBearerAuth() @Post('/:userUuid') - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SPACE_ASSIGN_USER_TO_SPACE') @ApiOperation({ summary: ControllerRoute.SPACE_USER.ACTIONS.ASSOCIATE_SPACE_USER_DESCRIPTION, @@ -26,15 +28,13 @@ export class SpaceUserController { async associateUserToSpace( @Param() params: UserSpaceParam, ): Promise { - return this.spaceUserService.associateUserToSpace( - params.userUuid, - params.spaceUuid, - ); + return this.spaceUserService.associateUserToSpace(params); } @ApiBearerAuth() @Delete('/:userUuid') - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SPACE_ASSIGN_USER_TO_SPACE') @ApiOperation({ summary: ControllerRoute.SPACE_USER.ACTIONS.DISSOCIATE_SPACE_USER_SUMMARY, description: @@ -43,9 +43,6 @@ export class SpaceUserController { async disassociateUserFromSpace( @Param() params: UserSpaceParam, ): Promise { - return this.spaceUserService.disassociateUserFromSpace( - params.userUuid, - params.spaceUuid, - ); + return this.spaceUserService.disassociateUserFromSpace(params); } } diff --git a/src/space/controllers/space.controller.ts b/src/space/controllers/space.controller.ts index e0615eb..ca47488 100644 --- a/src/space/controllers/space.controller.ts +++ b/src/space/controllers/space.controller.ts @@ -9,12 +9,15 @@ import { Param, Post, Put, + Query, UseGuards, } from '@nestjs/common'; -import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; -import { AddSpaceDto, CommunitySpaceParam } from '../dtos'; +import { AddSpaceDto, CommunitySpaceParam, UpdateSpaceDto } from '../dtos'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { GetSpaceParam } from '../dtos/get.space.param'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { Permissions } from 'src/decorators/permissions.decorator'; +import { GetSpaceDto } from '../dtos/get.space.dto'; @ApiTags('Space Module') @Controller({ @@ -25,7 +28,8 @@ export class SpaceController { constructor(private readonly spaceService: SpaceService) {} @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SPACE_ADD') @ApiOperation({ summary: ControllerRoute.SPACE.ACTIONS.CREATE_SPACE_SUMMARY, description: ControllerRoute.SPACE.ACTIONS.CREATE_SPACE_DESCRIPTION, @@ -37,12 +41,13 @@ export class SpaceController { ): Promise { return await this.spaceService.createSpace( addSpaceDto, - communitySpaceParam.communityUuid, + communitySpaceParam, ); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SPACE_VIEW') @ApiOperation({ summary: ControllerRoute.SPACE.ACTIONS.GET_COMMUNITY_SPACES_HIERARCHY_SUMMARY, @@ -51,26 +56,30 @@ export class SpaceController { }) @Get() async getHierarchy( - @Param() param: CommunitySpaceParam, + @Param() params: CommunitySpaceParam, + @Query() getSpaceDto: GetSpaceDto, ): Promise { return this.spaceService.getSpacesHierarchyForCommunity( - param.communityUuid, + params, + getSpaceDto, ); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SPACE_DELETE') @ApiOperation({ summary: ControllerRoute.SPACE.ACTIONS.DELETE_SPACE_SUMMARY, description: ControllerRoute.SPACE.ACTIONS.DELETE_SPACE_DESCRIPTION, }) @Delete('/:spaceUuid') - async deleteSpace(@Param() params: GetSpaceParam): Promise { - return this.spaceService.delete(params.spaceUuid, params.communityUuid); + async deleteSpace(@Param() params: GetSpaceParam) { + return await this.spaceService.delete(params); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SPACE_UPDATE') @Put('/:spaceUuid') @ApiOperation({ summary: ControllerRoute.SPACE.ACTIONS.UPDATE_SPACE_SUMMARY, @@ -78,28 +87,26 @@ export class SpaceController { }) async updateSpace( @Param() params: GetSpaceParam, - @Body() updateSpaceDto: AddSpaceDto, + @Body() updateSpaceDto: UpdateSpaceDto, ): Promise { - return this.spaceService.updateSpace( - params.spaceUuid, - params.communityUuid, - updateSpaceDto, - ); + return this.spaceService.updateSpace(params, updateSpaceDto); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SPACE_VIEW') @ApiOperation({ summary: ControllerRoute.SPACE.ACTIONS.GET_SPACE_SUMMARY, description: ControllerRoute.SPACE.ACTIONS.GET_SPACE_DESCRIPTION, }) @Get('/:spaceUuid') async get(@Param() params: GetSpaceParam): Promise { - return this.spaceService.findOne(params.spaceUuid); + return this.spaceService.findOne(params); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SPACE_VIEW') @ApiOperation({ summary: ControllerRoute.SPACE.ACTIONS.GET_HEIRARCHY_SUMMARY, description: ControllerRoute.SPACE.ACTIONS.GET_HEIRARCHY_DESCRIPTION, @@ -108,21 +115,21 @@ export class SpaceController { async getHierarchyUnderSpace( @Param() params: GetSpaceParam, ): Promise { - return this.spaceService.getSpacesHierarchyForSpace(params.spaceUuid); + return this.spaceService.getSpacesHierarchyForSpace(params); } - //should it be post? @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SPACE_MEMBER_ADD') @ApiOperation({ summary: ControllerRoute.SPACE.ACTIONS.CREATE_INVITATION_CODE_SPACE_SUMMARY, description: ControllerRoute.SPACE.ACTIONS.CREATE_INVITATION_CODE_SPACE_DESCRIPTION, }) - @Get(':spaceUuid/invitation-code') + @Post(':spaceUuid/invitation-code') async generateSpaceInvitationCode( @Param() params: GetSpaceParam, ): Promise { - return this.spaceService.getSpaceInvitationCode(params.spaceUuid); + return this.spaceService.generateSpaceInvitationCode(params); } } diff --git a/src/space/controllers/subspace/subspace-device.controller.ts b/src/space/controllers/subspace/subspace-device.controller.ts index 189d490..664cf38 100644 --- a/src/space/controllers/subspace/subspace-device.controller.ts +++ b/src/space/controllers/subspace/subspace-device.controller.ts @@ -1,5 +1,4 @@ import { ControllerRoute } from '@app/common/constants/controller-route'; -import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { Controller, Delete, @@ -12,6 +11,8 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { DeviceSubSpaceParam, GetSubSpaceParam } from '../../dtos'; import { SubspaceDeviceService } from 'src/space/services'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { Permissions } from 'src/decorators/permissions.decorator'; @ApiTags('Space Module') @Controller({ @@ -22,7 +23,8 @@ export class SubSpaceDeviceController { constructor(private readonly subspaceDeviceService: SubspaceDeviceService) {} @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SUBSPACE_DEVICE_VIEW_DEVICE_IN_SUBSPACE') @ApiOperation({ summary: ControllerRoute.SUBSPACE_DEVICE.ACTIONS.LIST_SUBSPACE_DEVICE_SUMMARY, @@ -37,7 +39,8 @@ export class SubSpaceDeviceController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SUBSPACE_ASSIGN_DEVICE_TO_SUBSPACE') @ApiOperation({ summary: ControllerRoute.SUBSPACE_DEVICE.ACTIONS.ASSOCIATE_SUBSPACE_DEVICE_SUMMARY, @@ -53,7 +56,8 @@ export class SubSpaceDeviceController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SUBSPACE_DELETE_DEVICE_FROM_SUBSPACE') @ApiOperation({ summary: ControllerRoute.SUBSPACE_DEVICE.ACTIONS diff --git a/src/space/controllers/subspace/subspace.controller.ts b/src/space/controllers/subspace/subspace.controller.ts index 9f766c4..3ed36af 100644 --- a/src/space/controllers/subspace/subspace.controller.ts +++ b/src/space/controllers/subspace/subspace.controller.ts @@ -14,8 +14,9 @@ import { SubSpaceService } from '../../services'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { AddSubspaceDto, GetSpaceParam, GetSubSpaceParam } from '../../dtos'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; -import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { PaginationRequestGetListDto } from '@app/common/dto/pagination.request.dto'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { Permissions } from 'src/decorators/permissions.decorator'; @ApiTags('Space Module') @Controller({ @@ -26,7 +27,8 @@ export class SubSpaceController { constructor(private readonly subSpaceService: SubSpaceService) {} @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SUBSPACE_ADD') @Post() @ApiOperation({ summary: ControllerRoute.SUBSPACE.ACTIONS.CREATE_SUBSPACE_SUMMARY, @@ -40,7 +42,8 @@ export class SubSpaceController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SUBSPACE_VIEW') @ApiOperation({ summary: ControllerRoute.SUBSPACE.ACTIONS.LIST_SUBSPACES_SUMMARY, description: ControllerRoute.SUBSPACE.ACTIONS.LIST_SUBSPACES_DESCRIPTION, @@ -54,18 +57,20 @@ export class SubSpaceController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SUBSPACE_VIEW') @ApiOperation({ summary: ControllerRoute.SUBSPACE.ACTIONS.GET_SUBSPACE_SUMMARY, description: ControllerRoute.SUBSPACE.ACTIONS.GET_SUBSPACE_DESCRIPTION, }) @Get(':subSpaceUuid') async findOne(@Param() params: GetSubSpaceParam): Promise { - return this.subSpaceService.findOne(params); + return this.subSpaceService.getOne(params); } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SUBSPACE_UPDATE') @ApiOperation({ summary: ControllerRoute.SUBSPACE.ACTIONS.UPDATE_SUBSPACE_SUMMARY, description: ControllerRoute.SUBSPACE.ACTIONS.UPDATE_SUBSPACE_DESCRIPTION, @@ -79,7 +84,8 @@ export class SubSpaceController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('SUBSPACE_DELETE') @ApiOperation({ summary: ControllerRoute.SUBSPACE.ACTIONS.DELETE_SUBSPACE_SUMMARY, description: ControllerRoute.SUBSPACE.ACTIONS.DELETE_SUBSPACE_DESCRIPTION, diff --git a/src/space/dtos/add.space.dto.ts b/src/space/dtos/add.space.dto.ts index 1f369b1..15595be 100644 --- a/src/space/dtos/add.space.dto.ts +++ b/src/space/dtos/add.space.dto.ts @@ -1,7 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { - IsArray, IsBoolean, IsNotEmpty, IsNumber, @@ -10,6 +9,8 @@ import { IsUUID, ValidateNested, } from 'class-validator'; +import { AddSubspaceDto } from './subspace'; +import { CreateTagDto } from './tag'; export class AddSpaceDto { @ApiProperty({ @@ -29,9 +30,14 @@ export class AddSpaceDto { @IsOptional() parentUuid?: string; + @ApiProperty({ + description: 'Icon identifier for the space', + example: 'assets/location', + required: false, + }) @IsString() - @IsNotEmpty() - public icon: string; + @IsOptional() + public icon?: string; @ApiProperty({ description: 'Indicates whether the space is private or public', @@ -49,15 +55,35 @@ export class AddSpaceDto { @IsNumber() y: number; + @ApiProperty({ + description: 'UUID of the Space', + example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', + }) + @IsString() + @IsOptional() + spaceModelUuid?: string; + @ApiProperty({ description: 'Y position on canvas', example: 200 }) @IsString() @IsOptional() - direction: string; + direction?: string; - @IsArray() + @ApiProperty({ + description: 'List of subspaces included in the model', + type: [AddSubspaceDto], + }) + @IsOptional() @ValidateNested({ each: true }) - @Type(() => ProductAssignmentDto) - products: ProductAssignmentDto[]; + @Type(() => AddSubspaceDto) + subspaces?: AddSubspaceDto[]; + + @ApiProperty({ + description: 'List of tags associated with the space model', + type: [CreateTagDto], + }) + @ValidateNested({ each: true }) + @Type(() => CreateTagDto) + tags?: CreateTagDto[]; } export class AddUserSpaceDto { @@ -100,11 +126,3 @@ export class AddUserSpaceUsingCodeDto { Object.assign(this, dto); } } - -class ProductAssignmentDto { - @IsNotEmpty() - productId: string; - - @IsNotEmpty() - count: number; -} diff --git a/src/space/dtos/community-space.param.ts b/src/space/dtos/community-space.param.ts index ab35e4e..caf05b0 100644 --- a/src/space/dtos/community-space.param.ts +++ b/src/space/dtos/community-space.param.ts @@ -1,7 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsUUID } from 'class-validator'; +import { ProjectParam } from './project.param.dto'; -export class CommunitySpaceParam { +export class CommunitySpaceParam extends ProjectParam { @ApiProperty({ description: 'UUID of the community this space belongs to', example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', diff --git a/src/space/dtos/get.space.dto.ts b/src/space/dtos/get.space.dto.ts new file mode 100644 index 0000000..d0b6475 --- /dev/null +++ b/src/space/dtos/get.space.dto.ts @@ -0,0 +1,19 @@ +import { BooleanValues } from '@app/common/constants/boolean-values.enum'; +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class GetSpaceDto { + @ApiProperty({ + example: true, + description: 'Only return spaces with devices', + required: false, + default: false, + }) + @IsOptional() + @IsBoolean() + @Transform((value) => { + return value.obj.onlyWithDevices === BooleanValues.TRUE; + }) + public onlyWithDevices?: boolean = false; +} diff --git a/src/space/dtos/index.ts b/src/space/dtos/index.ts index 2cebe7b..506efa9 100644 --- a/src/space/dtos/index.ts +++ b/src/space/dtos/index.ts @@ -3,3 +3,6 @@ export * from './community-space.param'; export * from './get.space.param'; export * from './user-space.param'; export * from './subspace'; +export * from './project.param.dto'; +export * from './update.space.dto'; +export * from './tag'; diff --git a/src/space/dtos/project.param.dto.ts b/src/space/dtos/project.param.dto.ts new file mode 100644 index 0000000..8bb2929 --- /dev/null +++ b/src/space/dtos/project.param.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class ProjectParam { + @ApiProperty({ + description: 'UUID of the project this community belongs to', + example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', + }) + @IsUUID() + projectUuid: string; +} diff --git a/src/space/dtos/subspace/add.subspace.dto.ts b/src/space/dtos/subspace/add.subspace.dto.ts index a2b12e2..95ad0fb 100644 --- a/src/space/dtos/subspace/add.subspace.dto.ts +++ b/src/space/dtos/subspace/add.subspace.dto.ts @@ -1,5 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { + IsArray, + IsNotEmpty, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; +import { CreateTagDto } from '../tag'; +import { Type } from 'class-transformer'; export class AddSubspaceDto { @ApiProperty({ @@ -9,4 +17,14 @@ export class AddSubspaceDto { @IsNotEmpty() @IsString() subspaceName: string; + + @ApiProperty({ + description: 'List of tags associated with the subspace', + type: [CreateTagDto], + }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateTagDto) + @IsOptional() + tags?: CreateTagDto[]; } diff --git a/src/space/dtos/subspace/delete.subspace.dto.ts b/src/space/dtos/subspace/delete.subspace.dto.ts new file mode 100644 index 0000000..3329a14 --- /dev/null +++ b/src/space/dtos/subspace/delete.subspace.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class DeleteSubspaceDto { + @ApiProperty({ + description: 'Uuid of the subspace model need to be deleted', + example: '982fc3a3-64dc-4afb-a5b5-65ee8fef0424', + }) + @IsNotEmpty() + @IsString() + subspaceUuid: string; +} diff --git a/src/space/dtos/subspace/index.ts b/src/space/dtos/subspace/index.ts index 0463a85..0071b44 100644 --- a/src/space/dtos/subspace/index.ts +++ b/src/space/dtos/subspace/index.ts @@ -1,3 +1,6 @@ export * from './add.subspace.dto'; export * from './get.subspace.param'; export * from './add.subspace-device.param'; +export * from './update.subspace.dto'; +export * from './delete.subspace.dto'; +export * from './modify.subspace.dto'; diff --git a/src/space/dtos/subspace/modify.subspace.dto.ts b/src/space/dtos/subspace/modify.subspace.dto.ts new file mode 100644 index 0000000..39a40e6 --- /dev/null +++ b/src/space/dtos/subspace/modify.subspace.dto.ts @@ -0,0 +1,47 @@ +import { ModifyAction } from '@app/common/constants/modify-action.enum'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsEnum, + IsOptional, + IsString, + IsArray, + ValidateNested, +} from 'class-validator'; +import { ModifyTagDto } from '../tag/modify-tag.dto'; + +export class ModifySubspaceDto { + @ApiProperty({ + description: 'Action to perform: add, update, or delete', + example: ModifyAction.ADD, + }) + @IsEnum(ModifyAction) + action: ModifyAction; + + @ApiPropertyOptional({ + description: 'UUID of the subspace (required for update/delete)', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @IsOptional() + @IsString() + uuid?: string; + + @ApiPropertyOptional({ + description: 'Name of the subspace (required for add/update)', + example: 'Living Room', + }) + @IsOptional() + @IsString() + subspaceName?: string; + + @ApiPropertyOptional({ + description: + 'List of tag modifications (add/update/delete) for the subspace', + type: [ModifyTagDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ModifyTagDto) + tags?: ModifyTagDto[]; +} diff --git a/src/space/dtos/subspace/update.subspace.dto.ts b/src/space/dtos/subspace/update.subspace.dto.ts new file mode 100644 index 0000000..0931d9e --- /dev/null +++ b/src/space/dtos/subspace/update.subspace.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class UpdateSubspaceDto { + @ApiProperty({ + description: 'Name of the subspace', + example: 'Living Room', + }) + @IsNotEmpty() + @IsString() + subspaceName?: string; + + @IsNotEmpty() + @IsString() + subspaceUuid: string; +} diff --git a/src/space/dtos/tag/create-tag-dto.ts b/src/space/dtos/tag/create-tag-dto.ts new file mode 100644 index 0000000..3e61f39 --- /dev/null +++ b/src/space/dtos/tag/create-tag-dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateTagDto { + @ApiProperty({ + description: 'Tag associated with the space or subspace', + example: 'Temperature Control', + }) + @IsNotEmpty() + @IsString() + tag: string; + + @ApiPropertyOptional({ + description: 'UUID of the tag (required for update/delete)', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @IsOptional() + @IsString() + uuid?: string; + + @ApiProperty({ + description: 'ID of the product associated with the tag', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @IsNotEmpty() + @IsString() + productUuid: string; +} diff --git a/src/space/dtos/tag/index.ts b/src/space/dtos/tag/index.ts new file mode 100644 index 0000000..599ba4b --- /dev/null +++ b/src/space/dtos/tag/index.ts @@ -0,0 +1 @@ +export * from './create-tag-dto'; diff --git a/src/space/dtos/tag/modify-tag.dto.ts b/src/space/dtos/tag/modify-tag.dto.ts new file mode 100644 index 0000000..6088a2a --- /dev/null +++ b/src/space/dtos/tag/modify-tag.dto.ts @@ -0,0 +1,37 @@ +import { ModifyAction } from '@app/common/constants/modify-action.enum'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; + +export class ModifyTagDto { + @ApiProperty({ + description: 'Action to perform: add, update, or delete', + example: ModifyAction.ADD, + }) + @IsEnum(ModifyAction) + action: ModifyAction; + + @ApiPropertyOptional({ + description: 'UUID of the tag (required for update/delete)', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @IsOptional() + @IsString() + uuid?: string; + + @ApiPropertyOptional({ + description: 'Name of the tag (required for add/update)', + example: 'Temperature Sensor', + }) + @IsOptional() + @IsString() + tag?: string; + + @ApiPropertyOptional({ + description: + 'UUID of the product associated with the tag (required for add)', + example: 'c789a91e-549a-4753-9006-02f89e8170e0', + }) + @IsOptional() + @IsString() + productUuid?: string; +} diff --git a/src/space/dtos/update.space.dto.ts b/src/space/dtos/update.space.dto.ts new file mode 100644 index 0000000..02efb86 --- /dev/null +++ b/src/space/dtos/update.space.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsArray, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; +import { ModifySubspaceDto } from './subspace'; +import { Type } from 'class-transformer'; +import { ModifyTagDto } from './tag/modify-tag.dto'; + +export class UpdateSpaceDto { + @ApiProperty({ + description: 'Updated name of the space ', + example: 'New Space Name', + }) + @IsOptional() + @IsString() + spaceName?: string; + + @ApiProperty({ + description: 'Icon identifier for the space', + example: 'assets/location', + required: false, + }) + @IsString() + @IsOptional() + public icon?: string; + + @ApiProperty({ description: 'X position on canvas', example: 120 }) + @IsNumber() + @IsOptional() + x?: number; + + @ApiProperty({ description: 'Y position on canvas', example: 200 }) + @IsNumber() + @IsOptional() + y?: number; + + @ApiPropertyOptional({ + description: 'List of subspace modifications (add/update/delete)', + type: [ModifySubspaceDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ModifySubspaceDto) + subspace?: ModifySubspaceDto[]; + + @ApiPropertyOptional({ + description: + 'List of tag modifications (add/update/delete) for the space model', + type: [ModifyTagDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ModifyTagDto) + tags?: ModifyTagDto[]; +} diff --git a/src/space/handlers/disable-space.handler.ts b/src/space/handlers/disable-space.handler.ts new file mode 100644 index 0000000..64669fa --- /dev/null +++ b/src/space/handlers/disable-space.handler.ts @@ -0,0 +1,100 @@ +import { SpaceEntity } from '@app/common/modules/space'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { DeviceService } from 'src/device/services'; +import { UserSpaceService } from 'src/users/services'; +import { DataSource } from 'typeorm'; +import { DisableSpaceCommand } from '../commands'; +import { + SubSpaceService, + SpaceLinkService, + SpaceSceneService, +} from '../services'; +import { TagService } from '../services/tag'; + +@CommandHandler(DisableSpaceCommand) +export class DisableSpaceHandler + implements ICommandHandler +{ + constructor( + private readonly subSpaceService: SubSpaceService, + private readonly userService: UserSpaceService, + private readonly tagService: TagService, + private readonly deviceService: DeviceService, + private readonly spaceLinkService: SpaceLinkService, + private readonly sceneService: SpaceSceneService, + private readonly dataSource: DataSource, + ) {} + + async execute(command: DisableSpaceCommand): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const { spaceUuid, orphanSpace } = command.param; + + const space = await queryRunner.manager.findOne(SpaceEntity, { + where: { uuid: spaceUuid, disabled: false }, + relations: [ + 'subspaces', + 'parent', + 'tags', + 'devices', + 'outgoingConnections', + 'incomingConnections', + 'scenes', + 'children', + 'userSpaces', + ], + }); + + if (!space) { + throw new HttpException( + `Space with UUID ${spaceUuid} not found`, + HttpStatus.NOT_FOUND, + ); + } + + if (space.children && space.children.length > 0) { + for (const child of space.children) { + await this.execute( + new DisableSpaceCommand({ spaceUuid: child.uuid, orphanSpace }), + ); + } + } + + const tagUuids = space.tags?.map((tag) => tag.uuid) || []; + const subspaceDtos = + space.subspaces?.map((subspace) => ({ + subspaceUuid: subspace.uuid, + })) || []; + const deletionTasks = [ + this.subSpaceService.deleteSubspaces(subspaceDtos, queryRunner), + this.userService.deleteUserSpace(space.uuid), + this.tagService.deleteTags(tagUuids, queryRunner), + this.deviceService.deleteDevice( + space.devices, + orphanSpace, + queryRunner, + ), + this.spaceLinkService.deleteSpaceLink(space, queryRunner), + this.sceneService.deleteScenes(space, queryRunner), + ]; + + await Promise.all(deletionTasks); + + // Mark space as disabled + space.disabled = true; + await queryRunner.manager.save(space); + + await queryRunner.commitTransaction(); + } catch (error) { + await queryRunner.rollbackTransaction(); + console.error(`Failed to disable space: ${error.message}`); + } finally { + await queryRunner.release(); + } + } +} diff --git a/src/space/handlers/index.ts b/src/space/handlers/index.ts new file mode 100644 index 0000000..ceb1ca9 --- /dev/null +++ b/src/space/handlers/index.ts @@ -0,0 +1 @@ +export * from './disable-space.handler'; diff --git a/src/space/interfaces/add-subspace.interface.ts b/src/space/interfaces/add-subspace.interface.ts new file mode 100644 index 0000000..207622c --- /dev/null +++ b/src/space/interfaces/add-subspace.interface.ts @@ -0,0 +1,5 @@ +import { SubspaceEntity } from '@app/common/modules/space'; + +export interface ModifySubspacePayload { + addedSubspaces?: SubspaceEntity[]; +} diff --git a/src/space/interfaces/index.ts b/src/space/interfaces/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/space/services/index.ts b/src/space/services/index.ts index 79eb32f..5f86e3d 100644 --- a/src/space/services/index.ts +++ b/src/space/services/index.ts @@ -4,4 +4,4 @@ export * from './space-device.service'; export * from './subspace'; export * from './space-link'; export * from './space-scene.service'; -export * from './space-products'; +export * from './space-validation.service'; diff --git a/src/space/services/space-device.service.ts b/src/space/services/space-device.service.ts index 4dc24ba..490cca7 100644 --- a/src/space/services/space-device.service.ts +++ b/src/space/services/space-device.service.ts @@ -1,59 +1,54 @@ import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; -import { CommunityRepository } from '@app/common/modules/community/repositories'; -import { SpaceRepository } from '@app/common/modules/space/repositories'; + import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { GetDeviceDetailsInterface } from 'src/device/interfaces/get.device.interface'; import { GetSpaceParam } from '../dtos'; 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 { ProductRepository } from '@app/common/modules/product/repositories'; + +import { ValidationService } from './space-validation.service'; +import { ProductType } from '@app/common/constants/product-type.enum'; +import { BatteryStatus } from '@app/common/constants/battery-status.enum'; @Injectable() export class SpaceDeviceService { constructor( - private readonly spaceRepository: SpaceRepository, private readonly tuyaService: TuyaService, - private readonly productRepository: ProductRepository, - private readonly communityRepository: CommunityRepository, + private readonly validationService: ValidationService, ) {} async listDevicesInSpace(params: GetSpaceParam): Promise { - const { spaceUuid, communityUuid } = params; + const { spaceUuid, communityUuid, projectUuid } = params; + try { - const space = await this.validateCommunityAndSpace( - communityUuid, - spaceUuid, + // Validate community, project, and fetch space including devices in a single query + const space = await this.validationService.fetchSpaceDevices(spaceUuid); + + if (!space || !space.devices?.length) { + throw new HttpException( + 'The space does not contain any devices.', + HttpStatus.BAD_REQUEST, + ); + } + + // Fetch space hierarchy **once** and reverse it to get an ordered hierarchy + const spaceHierarchy = + await this.validationService.getFullSpaceHierarchy(space); + const orderedHierarchy = spaceHierarchy.reverse(); + + // Fetch Tuya details for each device in parallel using Promise.allSettled + const deviceDetailsPromises = space.devices.map((device) => + this.fetchDeviceDetails(device, orderedHierarchy), ); - const safeFetch = async (device: any) => { - try { - const tuyaDetails = await this.getDeviceDetailsByDeviceIdTuya( - device.deviceTuyaUuid, - ); - - return { - uuid: device.uuid, - deviceTuyaUuid: device.deviceTuyaUuid, - productUuid: device.productDevice.uuid, - productType: device.productDevice.prodType, - isActive: device.isActive, - updatedAt: device.updatedAt, - ...tuyaDetails, - }; - } catch (error) { - console.warn( - `Skipping device with deviceTuyaUuid: ${device.deviceTuyaUuid} due to error.`, - ); - return null; - } - }; - - const detailedDevices = await Promise.all(space.devices.map(safeFetch)); + const detailedDevices = (await Promise.allSettled(deviceDetailsPromises)) + .filter((result) => result.status === 'fulfilled' && result.value) + .map((result) => (result as PromiseFulfilledResult).value); return new SuccessResponseDto({ - data: detailedDevices.filter(Boolean), // Remove null or undefined values - message: 'Successfully retrieved list of devices', + data: detailedDevices, + message: 'Successfully retrieved list of devices.', }); } catch (error) { console.error('Error listing devices in space:', error); @@ -64,29 +59,68 @@ export class SpaceDeviceService { } } - async validateCommunityAndSpace(communityUuid: string, spaceUuid: string) { - const community = await this.communityRepository.findOne({ - where: { uuid: communityUuid }, - }); - if (!community) { - this.throwNotFound('Community', communityUuid); - } + private async fetchDeviceDetails(device: any, orderedHierarchy: any[]) { + try { + // Fetch Tuya details in parallel + const [tuyaDetails, tuyaDeviceStatusResponse] = await Promise.all([ + this.getDeviceDetailsByDeviceIdTuya(device.deviceTuyaUuid), + this.tuyaService.getDevicesInstructionStatusTuya(device.deviceTuyaUuid), + ]); - const space = await this.spaceRepository.findOne({ - where: { uuid: spaceUuid, community: { uuid: communityUuid } }, - relations: ['devices', 'devices.productDevice'], - }); - if (!space) { - this.throwNotFound('Space', spaceUuid); + const tuyaStatusList = + tuyaDeviceStatusResponse?.result?.[0]?.status || []; + + return { + spaces: orderedHierarchy.map((space) => ({ + uuid: space.uuid, + spaceName: space.spaceName, + })), + uuid: device.uuid, + deviceTuyaUuid: device.deviceTuyaUuid, + productUuid: device.productDevice?.uuid || null, + productType: device.productDevice?.prodType || null, + isActive: device.isActive, + updatedAt: device.updatedAt, + deviceTag: device.tag, + subspace: device.subspace, + ...tuyaDetails, + ...(this.extractBatteryStatus( + device.productDevice?.prodType, + tuyaStatusList, + ) !== null && { + battery: this.extractBatteryStatus( + device.productDevice?.prodType, + tuyaStatusList, + ), + }), + status: tuyaStatusList, + }; + } catch (error) { + console.warn( + `Skipping device ${device.deviceTuyaUuid} due to error: ${error.message}`, + ); + return null; } - return space; } - private throwNotFound(entity: string, uuid: string) { - throw new HttpException( - `${entity} with ID ${uuid} not found`, - HttpStatus.NOT_FOUND, + private extractBatteryStatus( + deviceType: string, + tuyaStatus: any[], + ): number | null { + const batteryCodes = { + [ProductType.DL]: BatteryStatus.RESIDUAL_ELECTRICITY, + [ProductType.DS]: BatteryStatus.BATTERY_PERCENTAGE, + [ProductType.WL]: BatteryStatus.BATTERY_PERCENTAGE, + [ProductType.SOS]: BatteryStatus.BATTERY_PERCENTAGE, + }; + + const batteryCode = batteryCodes[deviceType]; + if (!batteryCode) return null; + + const batteryStatus = tuyaStatus.find( + (status) => status.code === batteryCode, ); + return batteryStatus ? batteryStatus.value : null; } private async getDeviceDetailsByDeviceIdTuya( diff --git a/src/space/services/space-link/space-link.service.ts b/src/space/services/space-link/space-link.service.ts index 0acece0..80d18ed 100644 --- a/src/space/services/space-link/space-link.service.ts +++ b/src/space/services/space-link/space-link.service.ts @@ -1,40 +1,41 @@ -import { - SpaceLinkRepository, - SpaceRepository, -} from '@app/common/modules/space/repositories'; +import { SpaceEntity, SpaceLinkEntity } from '@app/common/modules/space'; +import { SpaceLinkRepository } from '@app/common/modules/space/repositories'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { QueryRunner } from 'typeorm'; @Injectable() export class SpaceLinkService { - constructor( - private readonly spaceRepository: SpaceRepository, - private readonly spaceLinkRepository: SpaceLinkRepository, - ) {} + constructor(private readonly spaceLinkRepository: SpaceLinkRepository) {} async saveSpaceLink( startSpaceId: string, endSpaceId: string, direction: string, + queryRunner: QueryRunner, ): Promise { try { // Check if a link between the startSpace and endSpace already exists - const existingLink = await this.spaceLinkRepository.findOne({ + const existingLink = await queryRunner.manager.findOne(SpaceLinkEntity, { where: { startSpace: { uuid: startSpaceId }, endSpace: { uuid: endSpaceId }, + disabled: false, }, }); if (existingLink) { // Update the direction if the link exists existingLink.direction = direction; - await this.spaceLinkRepository.save(existingLink); + await queryRunner.manager.save(SpaceLinkEntity, existingLink); return; } - const existingEndSpaceLink = await this.spaceLinkRepository.findOne({ - where: { endSpace: { uuid: endSpaceId } }, - }); + const existingEndSpaceLink = await queryRunner.manager.findOne( + SpaceLinkEntity, + { + where: { endSpace: { uuid: endSpaceId } }, + }, + ); if ( existingEndSpaceLink && @@ -46,7 +47,7 @@ export class SpaceLinkService { } // Find start space - const startSpace = await this.spaceRepository.findOne({ + const startSpace = await queryRunner.manager.findOne(SpaceEntity, { where: { uuid: startSpaceId }, }); @@ -58,7 +59,7 @@ export class SpaceLinkService { } // Find end space - const endSpace = await this.spaceRepository.findOne({ + const endSpace = await queryRunner.manager.findOne(SpaceEntity, { where: { uuid: endSpaceId }, }); @@ -76,7 +77,7 @@ export class SpaceLinkService { direction, }); - await this.spaceLinkRepository.save(spaceLink); + await queryRunner.manager.save(SpaceLinkEntity, spaceLink); } catch (error) { throw new HttpException( error.message || @@ -85,4 +86,35 @@ export class SpaceLinkService { ); } } + async deleteSpaceLink( + space: SpaceEntity, + queryRunner: QueryRunner, + ): Promise { + try { + const spaceLinks = await queryRunner.manager.find(SpaceLinkEntity, { + where: [ + { startSpace: space, disabled: false }, + { endSpace: space, disabled: false }, + ], + }); + + if (spaceLinks.length === 0) { + return; + } + + const linkIds = spaceLinks.map((link) => link.uuid); + + await queryRunner.manager + .createQueryBuilder() + .update(SpaceLinkEntity) + .set({ disabled: true }) + .whereInIds(linkIds) + .execute(); + } catch (error) { + throw new HttpException( + `Failed to disable space links for the given space: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } } diff --git a/src/space/services/space-products/index.ts b/src/space/services/space-products/index.ts deleted file mode 100644 index d0b92d2..0000000 --- a/src/space/services/space-products/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './space-products.service'; diff --git a/src/space/services/space-products/space-products.service.ts b/src/space/services/space-products/space-products.service.ts deleted file mode 100644 index 5b218e4..0000000 --- a/src/space/services/space-products/space-products.service.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; -import { ProductRepository } from '@app/common/modules/product/repositories'; -import { SpaceEntity } from '@app/common/modules/space/entities'; -import { SpaceProductEntity } from '@app/common/modules/space/entities/space-product.entity'; -import { SpaceProductRepository } from '@app/common/modules/space/repositories'; -import { In } from 'typeorm'; - -@Injectable() -export class SpaceProductService { - constructor( - private readonly productRepository: ProductRepository, - private readonly spaceProductRepository: SpaceProductRepository, - ) {} - - async assignProductsToSpace( - space: SpaceEntity, - products: { productId: string; count: number }[], - ): Promise { - try { - const uniqueProducts = this.validateUniqueProducts(products); - const productEntities = await this.getProductEntities(uniqueProducts); - - // Fetch existing space products - const existingSpaceProducts = await this.spaceProductRepository.find({ - where: { - space: { - uuid: space.uuid, - }, - }, - relations: ['product'], - }); - - const updatedProducts = []; - const newProducts = []; - - for (const { productId, count } of uniqueProducts) { - const product = productEntities.get(productId); - if (!product) { - throw new HttpException( - `Product with ID ${productId} not found`, - HttpStatus.NOT_FOUND, - ); - } - - // Check if product already exists in the space - const existingProduct = existingSpaceProducts.find( - (spaceProduct) => spaceProduct.product.uuid === productId, - ); - - if (existingProduct) { - // If count is different, update the existing record - if (existingProduct.productCount !== count) { - existingProduct.productCount = count; - updatedProducts.push(existingProduct); - } - } else { - // Add new product if it doesn't exist - newProducts.push( - this.spaceProductRepository.create({ - space, - product, - productCount: count, - }), - ); - } - } - - // Save updates and new records - if (updatedProducts.length > 0) { - await this.spaceProductRepository.save(updatedProducts); - } - - if (newProducts.length > 0) { - await this.spaceProductRepository.save(newProducts); - } - - return [...updatedProducts, ...newProducts]; - } catch (error) { - console.error('Error assigning products to space:', error); - - if (!(error instanceof HttpException)) { - throw new HttpException( - 'An error occurred while assigning products to the space', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - throw error; - } - } - - private validateUniqueProducts( - products: { productId: string; count: number }[], - ): { productId: string; count: number }[] { - const productIds = new Set(); - const uniqueProducts = []; - - for (const product of products) { - if (productIds.has(product.productId)) { - throw new HttpException( - `Duplicate product ID found: ${product.productId}`, - HttpStatus.BAD_REQUEST, - ); - } - productIds.add(product.productId); - uniqueProducts.push(product); - } - - return uniqueProducts; - } - - private async getProductEntities( - products: { productId: string; count: number }[], - ): Promise> { - try { - const productIds = products.map((p) => p.productId); - - const productEntities = await this.productRepository.find({ - where: { uuid: In(productIds) }, - }); - - return new Map(productEntities.map((p) => [p.uuid, p])); - } catch (error) { - console.error('Error fetching product entities:', error); - throw new HttpException( - 'Failed to fetch product entities', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } -} diff --git a/src/space/services/space-scene.service.ts b/src/space/services/space-scene.service.ts index ac26889..f8d2c42 100644 --- a/src/space/services/space-scene.service.ts +++ b/src/space/services/space-scene.service.ts @@ -1,16 +1,19 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { GetSpaceParam } from '../dtos'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; -import { SpaceService } from './space.service'; import { SceneService } from '../../scene/services'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { GetSceneDto } from '../../scene/dtos'; +import { ValidationService } from './space-validation.service'; +import { SpaceEntity } from '@app/common/modules/space'; +import { QueryRunner } from 'typeorm'; +import { SceneEntity } from '@app/common/modules/scene/entities'; @Injectable() export class SpaceSceneService { constructor( - private readonly spaceSevice: SpaceService, private readonly sceneSevice: SceneService, + private readonly validationService: ValidationService, ) {} async getScenes( @@ -18,10 +21,11 @@ export class SpaceSceneService { getSceneDto: GetSceneDto, ): Promise { try { - const { spaceUuid, communityUuid } = params; + const { spaceUuid, communityUuid, projectUuid } = params; - await this.spaceSevice.validateCommunityAndSpace( + await this.validationService.checkCommunityAndProjectSpaceExistence( communityUuid, + projectUuid, spaceUuid, ); @@ -47,4 +51,32 @@ export class SpaceSceneService { } } } + + async deleteScenes( + space: SpaceEntity, + queryRunner: QueryRunner, + ): Promise { + try { + const scenes = await queryRunner.manager.find(SceneEntity, { + where: { space }, + }); + + if (scenes.length === 0) { + return; + } + + const sceneUuids = scenes.map((scene) => scene.uuid); + + await Promise.all( + sceneUuids.map((uuid) => + this.sceneSevice.deleteScene({ sceneUuid: uuid }), + ), + ); + } catch (error) { + throw new HttpException( + `Failed to delete scenes: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } } diff --git a/src/space/services/space-user.service.ts b/src/space/services/space-user.service.ts index d4f2e9d..fe94983 100644 --- a/src/space/services/space-user.service.ts +++ b/src/space/services/space-user.service.ts @@ -1,23 +1,22 @@ import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; -import { SpaceRepository } from '@app/common/modules/space/repositories'; import { UserRepository, UserSpaceRepository, } from '@app/common/modules/user/repositories'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { UserSpaceParam } from '../dtos'; +import { ValidationService } from './space-validation.service'; @Injectable() export class SpaceUserService { constructor( - private readonly spaceRepository: SpaceRepository, + private readonly validationService: ValidationService, private readonly userRepository: UserRepository, private readonly userSpaceRepository: UserSpaceRepository, ) {} - async associateUserToSpace( - userUuid: string, - spaceUuid: string, - ): Promise { + async associateUserToSpace(params: UserSpaceParam): Promise { + const { communityUuid, spaceUuid, userUuid, projectUuid } = params; // Find the user by ID const user = await this.userRepository.findOne({ where: { uuid: userUuid }, @@ -30,15 +29,12 @@ export class SpaceUserService { } // Find the space by ID - const space = await this.spaceRepository.findOne({ - where: { uuid: spaceUuid }, - }); - if (!space) { - throw new HttpException( - `Space with ID ${spaceUuid} not found`, - HttpStatus.NOT_FOUND, + const space = + await this.validationService.validateSpaceWithinCommunityAndProject( + communityUuid, + projectUuid, + spaceUuid, ); - } // Check if the association already exists const existingAssociation = await this.userSpaceRepository.findOne({ @@ -61,9 +57,9 @@ export class SpaceUserService { } async disassociateUserFromSpace( - userUuid: string, - spaceUuid: string, + params: UserSpaceParam, ): Promise { + const { userUuid, spaceUuid, communityUuid, projectUuid } = params; // Find the user by ID const user = await this.userRepository.findOne({ where: { uuid: userUuid }, @@ -76,15 +72,11 @@ export class SpaceUserService { } // Find the space by ID - const space = await this.spaceRepository.findOne({ - where: { uuid: spaceUuid }, - }); - if (!space) { - throw new HttpException( - `Space with ID ${spaceUuid} not found`, - HttpStatus.NOT_FOUND, - ); - } + await this.validationService.checkCommunityAndProjectSpaceExistence( + communityUuid, + projectUuid, + spaceUuid, + ); // Find the existing association const existingAssociation = await this.userSpaceRepository.findOne({ diff --git a/src/space/services/space-validation.service.ts b/src/space/services/space-validation.service.ts new file mode 100644 index 0000000..9dc614b --- /dev/null +++ b/src/space/services/space-validation.service.ts @@ -0,0 +1,225 @@ +import { SpaceEntity } from '@app/common/modules/space/entities'; +import { SpaceRepository } from '@app/common/modules/space/repositories'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { CommunityService } from '../../community/services'; +import { ProjectService } from '../../project/services'; +import { + SpaceModelEntity, + SpaceModelRepository, +} from '@app/common/modules/space-model'; +import { ProjectRepository } from '@app/common/modules/project/repositiories'; +import { CommunityRepository } from '@app/common/modules/community/repositories'; +import { DeviceRepository } from '@app/common/modules/device/repositories'; + +@Injectable() +export class ValidationService { + constructor( + private readonly projectService: ProjectService, + private readonly communityService: CommunityService, + private readonly spaceRepository: SpaceRepository, + private readonly projectRepository: ProjectRepository, + private readonly communityRepository: CommunityRepository, + private readonly spaceModelRepository: SpaceModelRepository, + private readonly deviceRepository: DeviceRepository, + ) {} + + async validateCommunityAndProject( + communityUuid: string, + projectUuid: string, + ) { + const project = await this.projectService.findOne(projectUuid); + const community = await this.communityService.getCommunityById({ + communityUuid, + projectUuid, + }); + + return { community: community.data, project: project }; + } + + async checkCommunityAndProjectSpaceExistence( + communityUuid: string, + projectUuid: string, + spaceUuid?: string, + ): Promise { + const [projectExists, communityExists, spaceExists] = await Promise.all([ + this.projectRepository.exists({ where: { uuid: projectUuid } }), + this.communityRepository.exists({ + where: { uuid: communityUuid, project: { uuid: projectUuid } }, + }), + spaceUuid + ? this.spaceRepository.exists({ + where: { uuid: spaceUuid }, + }) + : Promise.resolve(true), + ]); + + if (!projectExists) + throw new HttpException(`Project not found`, HttpStatus.NOT_FOUND); + if (!communityExists) + throw new HttpException(`Community not found`, HttpStatus.NOT_FOUND); + if (spaceUuid && !spaceExists) + throw new HttpException(`Space not found`, HttpStatus.NOT_FOUND); + } + + async validateSpaceWithinCommunityAndProject( + communityUuid: string, + projectUuid: string, + spaceUuid?: string, + ): Promise { + await this.validateCommunityAndProject(communityUuid, projectUuid); + + const space = await this.validateSpace(spaceUuid); + + return space; + } + + async validateSpace(spaceUuid: string): Promise { + const space = await this.spaceRepository.findOne({ + where: { uuid: spaceUuid, disabled: false }, + relations: [ + 'parent', + 'children', + 'subspaces', + 'tags', + 'subspaces.tags', + 'subspaces.devices', + ], + }); + + if (!space) { + throw new HttpException( + `Space with UUID ${spaceUuid} not found`, + HttpStatus.NOT_FOUND, + ); + } + + const devices = await this.deviceRepository.find({ + where: { spaceDevice: { uuid: spaceUuid } }, + select: ['uuid', 'deviceTuyaUuid', 'isActive', 'createdAt', 'updatedAt'], + relations: ['productDevice', 'tag', 'subspace'], + }); + + space.devices = devices; + + return space; + } + + async fetchSpaceDevices(spaceUuid: string): Promise { + const space = await this.spaceRepository.findOne({ + where: { uuid: spaceUuid, disabled: false }, + relations: [ + 'devices', + 'devices.productDevice', + 'devices.tag', + 'devices.subspace', + ], + }); + + if (!space) { + throw new HttpException( + `Space with UUID ${spaceUuid} not found`, + HttpStatus.NOT_FOUND, + ); + } + return space; + } + + async validateSpaceModel(spaceModelUuid: string): Promise { + const spaceModel = await this.spaceModelRepository.findOne({ + where: { uuid: spaceModelUuid }, + relations: [ + 'subspaceModels', + 'subspaceModels.tags', + 'tags', + 'subspaceModels.tags.product', + 'tags.product', + ], + }); + + if (!spaceModel) { + throw new HttpException( + `Space model with UUID ${spaceModelUuid} not found`, + HttpStatus.NOT_FOUND, + ); + } + + return spaceModel; + } + + async getFullSpaceHierarchy( + space: SpaceEntity, + ): Promise<{ uuid: string; spaceName: string }[]> { + try { + // Fetch only the relevant spaces, starting with the target space + const targetSpace = await this.spaceRepository.findOne({ + where: { uuid: space.uuid }, + relations: ['parent', 'children'], + }); + + // Fetch only the ancestors of the target space + const ancestors = await this.fetchAncestors(targetSpace); + + // Optionally, fetch descendants if required + const descendants = await this.fetchDescendants(targetSpace); + + const fullHierarchy = [...ancestors, targetSpace, ...descendants].map( + (space) => ({ + uuid: space.uuid, + spaceName: space.spaceName, + }), + ); + + return fullHierarchy; + } catch (error) { + console.error('Error fetching space hierarchy:', error.message); + throw new HttpException( + 'Error fetching space hierarchy', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async fetchAncestors(space: SpaceEntity): Promise { + const ancestors: SpaceEntity[] = []; + + let currentSpace = space; + while (currentSpace && currentSpace.parent) { + // Fetch the parent space + const parent = await this.spaceRepository.findOne({ + where: { uuid: currentSpace.parent.uuid }, + relations: ['parent'], // To continue fetching upwards + }); + + if (parent) { + ancestors.push(parent); + currentSpace = parent; + } else { + currentSpace = null; + } + } + + // Return the ancestors in reverse order to have the root at the start + return ancestors.reverse(); + } + + private async fetchDescendants(space: SpaceEntity): Promise { + const descendants: SpaceEntity[] = []; + + // Fetch the immediate children of the current space + const children = await this.spaceRepository.find({ + where: { parent: { uuid: space.uuid } }, + relations: ['children'], // To continue fetching downwards + }); + + for (const child of children) { + // Add the child to the descendants list + descendants.push(child); + + // Recursively fetch the child's descendants + const childDescendants = await this.fetchDescendants(child); + descendants.push(...childDescendants); + } + + return descendants; + } +} diff --git a/src/space/services/space.service.ts b/src/space/services/space.service.ts index 6e3e600..0815525 100644 --- a/src/space/services/space.service.ts +++ b/src/space/services/space.service.ts @@ -1,107 +1,297 @@ -import { SpaceRepository } from '@app/common/modules/space/repositories'; +import { + InviteSpaceRepository, + SpaceRepository, +} from '@app/common/modules/space/repositories'; import { BadRequestException, HttpException, HttpStatus, Injectable, } from '@nestjs/common'; -import { AddSpaceDto } from '../dtos'; +import { + AddSpaceDto, + AddSubspaceDto, + CommunitySpaceParam, + CreateTagDto, + GetSpaceParam, + UpdateSpaceDto, +} from '../dtos'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; -import { CommunityRepository } from '@app/common/modules/community/repositories'; import { SpaceEntity } from '@app/common/modules/space/entities'; import { generateRandomString } from '@app/common/helper/randomString'; import { SpaceLinkService } from './space-link'; -import { SpaceProductService } from './space-products'; - +import { SubSpaceService } from './subspace'; +import { DataSource, QueryRunner } from 'typeorm'; +import { ValidationService } from './space-validation.service'; +import { + ORPHAN_COMMUNITY_NAME, + ORPHAN_SPACE_NAME, +} from '@app/common/constants/orphan-constant'; +import { CommandBus } from '@nestjs/cqrs'; +import { TagService } from './tag'; +import { SpaceModelService } from 'src/space-model/services'; +import { DisableSpaceCommand } from '../commands'; +import { GetSpaceDto } from '../dtos/get.space.dto'; +import { removeCircularReferences } from '@app/common/helper/removeCircularReferences'; @Injectable() export class SpaceService { constructor( + private readonly dataSource: DataSource, private readonly spaceRepository: SpaceRepository, - private readonly communityRepository: CommunityRepository, + private readonly inviteSpaceRepository: InviteSpaceRepository, private readonly spaceLinkService: SpaceLinkService, - private readonly spaceProductService: SpaceProductService, + private readonly subSpaceService: SubSpaceService, + private readonly validationService: ValidationService, + private readonly tagService: TagService, + private readonly spaceModelService: SpaceModelService, + private commandBus: CommandBus, ) {} async createSpace( addSpaceDto: AddSpaceDto, - communityId: string, + params: CommunitySpaceParam, ): Promise { - const { parentUuid, direction, products } = addSpaceDto; + const { parentUuid, direction, spaceModelUuid, subspaces, tags } = + addSpaceDto; + const { communityUuid, projectUuid } = params; - const community = await this.validateCommunity(communityId); + if (addSpaceDto.spaceName === ORPHAN_SPACE_NAME) { + throw new HttpException( + `Name ${ORPHAN_SPACE_NAME} cannot be used`, + HttpStatus.BAD_REQUEST, + ); + } + + const queryRunner = this.dataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + const { community } = + await this.validationService.validateCommunityAndProject( + communityUuid, + projectUuid, + ); + + this.validateSpaceCreation(addSpaceDto, spaceModelUuid); + + const parent = parentUuid + ? await this.validationService.validateSpace(parentUuid) + : null; + + const spaceModel = spaceModelUuid + ? await this.validationService.validateSpaceModel(spaceModelUuid) + : null; - const parent = parentUuid ? await this.validateSpace(parentUuid) : null; try { - const newSpace = this.spaceRepository.create({ + const space = queryRunner.manager.create(SpaceEntity, { ...addSpaceDto, - community, + spaceModel, parent: parentUuid ? parent : null, + community, }); - await this.spaceRepository.save(newSpace); + const newSpace = await queryRunner.manager.save(space); - if (direction && parent) { - await this.spaceLinkService.saveSpaceLink( - parent.uuid, - newSpace.uuid, - direction, - ); - } + await Promise.all([ + spaceModelUuid && + this.createFromModel(spaceModelUuid, queryRunner, newSpace), + direction && parent + ? this.spaceLinkService.saveSpaceLink( + parent.uuid, + newSpace.uuid, + direction, + queryRunner, + ) + : Promise.resolve(), + subspaces?.length + ? this.createSubspaces(subspaces, newSpace, queryRunner, tags) + : Promise.resolve(), + tags?.length + ? this.createTags(tags, queryRunner, newSpace) + : Promise.resolve(), + ]); - if (products && products.length > 0) { - await this.spaceProductService.assignProductsToSpace( - newSpace, - products, - ); - } + await queryRunner.commitTransaction(); return new SuccessResponseDto({ statusCode: HttpStatus.CREATED, - data: newSpace, + data: JSON.parse(JSON.stringify(newSpace, removeCircularReferences())), message: 'Space created successfully', }); } catch (error) { + await queryRunner.rollbackTransaction(); + + if (error instanceof HttpException) { + throw error; + } throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR); + } finally { + await queryRunner.release(); } } - async getSpacesHierarchyForCommunity( - communityUuid: string, - ): Promise { - await this.validateCommunity(communityUuid); + async createFromModel( + spaceModelUuid: string, + queryRunner: QueryRunner, + space: SpaceEntity, + ) { try { - // Get all spaces related to the community, including the parent-child relations - const spaces = await this.spaceRepository.find({ - where: { community: { uuid: communityUuid } }, - relations: [ - 'parent', - 'children', - 'incomingConnections', - 'spaceProducts', - 'spaceProducts.product', - ], // Include parent and children relations - }); + const spaceModel = + await this.spaceModelService.validateSpaceModel(spaceModelUuid); - // Organize spaces into a hierarchical structure - const spaceHierarchy = this.buildSpaceHierarchy(spaces); + space.spaceModel = spaceModel; + await queryRunner.manager.save(SpaceEntity, space); - return new SuccessResponseDto({ - message: `Spaces in community ${communityUuid} successfully fetched in hierarchy`, - data: spaceHierarchy, - statusCode: HttpStatus.OK, - }); + await this.subSpaceService.createSubSpaceFromModel( + spaceModel.subspaceModels, + space, + queryRunner, + ); + + await this.tagService.createTagsFromModel( + queryRunner, + spaceModel.tags, + space, + null, + ); } catch (error) { + if (error instanceof HttpException) { + throw error; + } throw new HttpException( - 'An error occurred while fetching the spaces', + 'An error occurred while creating the space from space model', HttpStatus.INTERNAL_SERVER_ERROR, ); } } - async findOne(spaceUuid: string): Promise { + async getSpacesHierarchyForCommunity( + params: CommunitySpaceParam, + getSpaceDto?: GetSpaceDto, + ): Promise { + const { communityUuid, projectUuid } = params; + const { onlyWithDevices } = getSpaceDto; + await this.validationService.validateCommunityAndProject( + communityUuid, + projectUuid, + ); try { - const space = await this.validateSpace(spaceUuid); + const queryBuilder = this.spaceRepository + .createQueryBuilder('space') + .leftJoinAndSelect('space.parent', 'parent') + .leftJoinAndSelect( + 'space.children', + 'children', + 'children.disabled = :disabled', + { disabled: false }, + ) + .leftJoinAndSelect( + 'space.incomingConnections', + 'incomingConnections', + 'incomingConnections.disabled = :incomingConnectionDisabled', + { incomingConnectionDisabled: false }, + ) + .leftJoinAndSelect( + 'space.tags', + 'tags', + 'tags.disabled = :tagDisabled', + { tagDisabled: false }, + ) + .leftJoinAndSelect('tags.product', 'tagProduct') + .leftJoinAndSelect( + 'space.subspaces', + 'subspaces', + 'subspaces.disabled = :subspaceDisabled', + { subspaceDisabled: false }, + ) + .leftJoinAndSelect( + 'subspaces.tags', + 'subspaceTags', + 'subspaceTags.disabled = :subspaceTagsDisabled', + { subspaceTagsDisabled: false }, + ) + .leftJoinAndSelect('subspaceTags.product', 'subspaceTagProduct') + .leftJoinAndSelect('space.spaceModel', 'spaceModel') + .where('space.community_id = :communityUuid', { communityUuid }) + .andWhere('space.spaceName != :orphanSpaceName', { + orphanSpaceName: ORPHAN_SPACE_NAME, + }) + .andWhere('space.disabled = :disabled', { disabled: false }); + + if (onlyWithDevices) { + queryBuilder.innerJoin('space.devices', 'devices'); + } + + const spaces = await queryBuilder.getMany(); + + const spaceHierarchy = this.buildSpaceHierarchy(spaces); + + return new SuccessResponseDto({ + message: `Spaces in community ${communityUuid} successfully fetched in hierarchy`, + data: onlyWithDevices ? spaces : spaceHierarchy, + statusCode: HttpStatus.OK, + }); + } catch (error) { + throw new HttpException( + `An error occurred while fetching the spaces ${error}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async findOne(params: GetSpaceParam): Promise { + const { communityUuid, spaceUuid, projectUuid } = params; + try { + await this.validationService.validateCommunityAndProject( + communityUuid, + projectUuid, + ); + + const queryBuilder = this.spaceRepository + .createQueryBuilder('space') + .leftJoinAndSelect('space.parent', 'parent') + .leftJoinAndSelect( + 'space.children', + 'children', + 'children.disabled = :disabled', + { disabled: false }, + ) + .leftJoinAndSelect( + 'space.incomingConnections', + 'incomingConnections', + 'incomingConnections.disabled = :incomingConnectionDisabled', + { incomingConnectionDisabled: false }, + ) + .leftJoinAndSelect( + 'space.tags', + 'tags', + 'tags.disabled = :tagDisabled', + { tagDisabled: false }, + ) + .leftJoinAndSelect('tags.product', 'tagProduct') + .leftJoinAndSelect( + 'space.subspaces', + 'subspaces', + 'subspaces.disabled = :subspaceDisabled', + { subspaceDisabled: false }, + ) + .leftJoinAndSelect( + 'subspaces.tags', + 'subspaceTags', + 'subspaceTags.disabled = :subspaceTagsDisabled', + { subspaceTagsDisabled: false }, + ) + .leftJoinAndSelect('subspaceTags.product', 'subspaceTagProduct') + .where('space.community_id = :communityUuid', { communityUuid }) + .andWhere('space.spaceName != :orphanSpaceName', { + orphanSpaceName: ORPHAN_SPACE_NAME, + }) + .andWhere('space.uuid = :spaceUuid', { spaceUuid }) + .andWhere('space.disabled = :disabled', { disabled: false }); + + const space = await queryBuilder.getOne(); return new SuccessResponseDto({ message: `Space with ID ${spaceUuid} successfully fetched`, @@ -119,19 +309,35 @@ export class SpaceService { } } - async delete( - spaceUuid: string, - communityUuid: string, - ): Promise { + async delete(params: GetSpaceParam): Promise { try { - // First, check if the community exists - const space = await this.validateCommunityAndSpace( - communityUuid, - spaceUuid, - ); + const { communityUuid, spaceUuid, projectUuid } = params; - // Delete the space - await this.spaceRepository.remove(space); + const { project } = + await this.validationService.validateCommunityAndProject( + communityUuid, + projectUuid, + ); + + const space = await this.validationService.validateSpace(spaceUuid); + + if (space.spaceName === ORPHAN_SPACE_NAME) { + throw new HttpException( + `space ${ORPHAN_SPACE_NAME} cannot be deleted`, + HttpStatus.BAD_REQUEST, + ); + } + + const orphanSpace = await this.spaceRepository.findOne({ + where: { + community: { + name: `${ORPHAN_COMMUNITY_NAME}-${project.name}`, + }, + spaceName: ORPHAN_SPACE_NAME, + }, + }); + + await this.disableSpace(space, orphanSpace); return new SuccessResponseDto({ message: `Space with ID ${spaceUuid} successfully deleted`, @@ -142,65 +348,161 @@ export class SpaceService { throw error; } throw new HttpException( - 'An error occurred while deleting the space', + `An error occurred while deleting the space ${error.message}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } } + async disableSpace(space: SpaceEntity, orphanSpace: SpaceEntity) { + await this.commandBus.execute( + new DisableSpaceCommand({ spaceUuid: space.uuid, orphanSpace }), + ); + } + async updateSpace( - spaceUuid: string, - communityId: string, - updateSpaceDto: AddSpaceDto, + params: GetSpaceParam, + updateSpaceDto: UpdateSpaceDto, ): Promise { + const { communityUuid, spaceUuid, projectUuid } = params; + + const queryRunner = this.dataSource.createQueryRunner(); + try { - const space = await this.validateCommunityAndSpace( - communityId, - spaceUuid, - ); + await queryRunner.connect(); + await queryRunner.startTransaction(); - // If a parentId is provided, check if the parent exists - const { parentUuid, products } = updateSpaceDto; - const parent = parentUuid ? await this.validateSpace(parentUuid) : null; + const space = + await this.validationService.validateSpaceWithinCommunityAndProject( + communityUuid, + projectUuid, + spaceUuid, + ); - // Update other space properties from updateSpaceDto - Object.assign(space, updateSpaceDto, { parent }); - - // Save the updated space - const updatedSpace = await this.spaceRepository.save(space); - - if (products && products.length > 0) { - await this.spaceProductService.assignProductsToSpace( - updatedSpace, - products, + if (space.spaceName === ORPHAN_SPACE_NAME) { + throw new HttpException( + `Space "${ORPHAN_SPACE_NAME}" cannot be updated`, + HttpStatus.BAD_REQUEST, ); } + this.updateSpaceProperties(space, updateSpaceDto); + + const hasSubspace = updateSpaceDto.subspace?.length > 0; + const hasTags = updateSpaceDto.tags?.length > 0; + + if (hasSubspace || hasTags) { + space.spaceModel = null; + await this.tagService.unlinkModels(space.tags, queryRunner); + await this.subSpaceService.unlinkModels(space.subspaces, queryRunner); + } + await queryRunner.manager.save(space); + + if (hasSubspace) { + const modifiedSubspaces = this.tagService.getModifiedSubspaces( + updateSpaceDto.tags, + updateSpaceDto.subspace, + ); + + await this.subSpaceService.modifySubSpace( + modifiedSubspaces, + queryRunner, + space, + ); + } + + if (hasTags) { + const spaceTagsAfterMove = this.tagService.getSubspaceTagsToBeAdded( + updateSpaceDto.tags, + updateSpaceDto.subspace, + ); + + await this.tagService.modifyTags( + spaceTagsAfterMove, + queryRunner, + space, + ); + } + + await queryRunner.commitTransaction(); + return new SuccessResponseDto({ message: `Space with ID ${spaceUuid} successfully updated`, - data: space, statusCode: HttpStatus.OK, }); } catch (error) { + await queryRunner.rollbackTransaction(); + if (error instanceof HttpException) { throw error; } + throw new HttpException( 'An error occurred while updating the space', HttpStatus.INTERNAL_SERVER_ERROR, ); + } finally { + await queryRunner.release(); } } + async unlinkSpaceFromModel( + space: SpaceEntity, + queryRunner: QueryRunner, + ): Promise { + try { + await queryRunner.manager.update( + this.spaceRepository.target, + { uuid: space.uuid }, + { + spaceModel: null, + }, + ); + + // Unlink subspaces and tags if they exist + if (space.subspaces || space.tags) { + if (space.tags) { + await this.tagService.unlinkModels(space.tags, queryRunner); + } + + if (space.subspaces) { + await this.subSpaceService.unlinkModels(space.subspaces, queryRunner); + } + } + } catch (error) { + throw new HttpException( + `Failed to unlink space model: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private updateSpaceProperties( + space: SpaceEntity, + updateSpaceDto: UpdateSpaceDto, + ): void { + const { spaceName, x, y, icon } = updateSpaceDto; + + if (spaceName) space.spaceName = spaceName; + if (x) space.x = x; + if (y) space.y = y; + if (icon) space.icon = icon; + } + async getSpacesHierarchyForSpace( - spaceUuid: string, + params: GetSpaceParam, ): Promise { - await this.validateSpace(spaceUuid); + const { spaceUuid, communityUuid, projectUuid } = params; + await this.validationService.checkCommunityAndProjectSpaceExistence( + communityUuid, + projectUuid, + spaceUuid, + ); try { // Get all spaces that are children of the provided space, including the parent-child relations const spaces = await this.spaceRepository.find({ - where: { parent: { uuid: spaceUuid } }, + where: { parent: { uuid: spaceUuid }, disabled: false }, relations: ['parent', 'children'], // Include parent and children relations }); @@ -214,24 +516,35 @@ export class SpaceService { }); } catch (error) { throw new HttpException( - 'An error occurred while fetching the spaces under the space', + `An error occurred while fetching the spaces under the space ${error}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } } - async getSpaceInvitationCode(spaceUuid: string): Promise { + async generateSpaceInvitationCode(params: GetSpaceParam): Promise { + const { communityUuid, spaceUuid, projectUuid } = params; try { const invitationCode = generateRandomString(6); - const space = await this.validateSpace(spaceUuid); - - space.invitationCode = invitationCode; - await this.spaceRepository.save(space); + const space = + await this.validationService.validateSpaceWithinCommunityAndProject( + communityUuid, + projectUuid, + spaceUuid, + ); + await this.inviteSpaceRepository.save({ + space: { uuid: spaceUuid }, + invitationCode, + }); return new SuccessResponseDto({ message: `Invitation code has been successfuly added to the space`, - data: space, + data: { + invitationCode, + spaceName: space.spaceName, + spaceUuid: space.uuid, + }, }); } catch (err) { if (err instanceof BadRequestException) { @@ -270,41 +583,41 @@ export class SpaceService { return rootSpaces; } - private async validateCommunity(communityId: string) { - const community = await this.communityRepository.findOne({ - where: { uuid: communityId }, - }); - if (!community) { + private validateSpaceCreation( + addSpaceDto: AddSpaceDto, + spaceModelUuid?: string, + ) { + const hasTagsOrSubspaces = + (addSpaceDto.tags && addSpaceDto.tags.length > 0) || + (addSpaceDto.subspaces && addSpaceDto.subspaces.length > 0); + + if (spaceModelUuid && hasTagsOrSubspaces) { throw new HttpException( - `Community with ID ${communityId} not found`, - HttpStatus.NOT_FOUND, + 'For space creation choose either space model or products and subspace', + HttpStatus.CONFLICT, ); } - return community; } - async validateCommunityAndSpace(communityUuid: string, spaceUuid: string) { - const community = await this.validateCommunity(communityUuid); - if (!community) { - this.throwNotFound('Community', communityUuid); - } - - const space = await this.validateSpace(spaceUuid); - return space; - } - - private async validateSpace(spaceUuid: string) { - const space = await this.spaceRepository.findOne({ - where: { uuid: spaceUuid }, - }); - if (!space) this.throwNotFound('Space', spaceUuid); - return space; - } - - private throwNotFound(entity: string, uuid: string) { - throw new HttpException( - `${entity} with ID ${uuid} not found`, - HttpStatus.NOT_FOUND, + private async createSubspaces( + subspaces: AddSubspaceDto[], + space: SpaceEntity, + queryRunner: QueryRunner, + tags: CreateTagDto[], + ): Promise { + space.subspaces = await this.subSpaceService.createSubspacesFromDto( + subspaces, + space, + queryRunner, + tags, ); } + + private async createTags( + tags: CreateTagDto[], + queryRunner: QueryRunner, + space: SpaceEntity, + ): Promise { + space.tags = await this.tagService.createTags(tags, queryRunner, space); + } } diff --git a/src/space/services/subspace/subspace-device.service.ts b/src/space/services/subspace/subspace-device.service.ts index 74f6610..cdd80db 100644 --- a/src/space/services/subspace/subspace-device.service.ts +++ b/src/space/services/subspace/subspace-device.service.ts @@ -1,35 +1,39 @@ import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { DeviceRepository } from '@app/common/modules/device/repositories'; -import { - SpaceRepository, - SubspaceRepository, -} from '@app/common/modules/space/repositories'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { DeviceSubSpaceParam, GetSubSpaceParam } from '../../dtos'; -import { CommunityRepository } from '@app/common/modules/community/repositories'; import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter'; import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; import { ProductRepository } from '@app/common/modules/product/repositories'; import { GetDeviceDetailsInterface } from '../../../device/interfaces/get.device.interface'; +import { ValidationService } from '../space-validation.service'; +import { SubspaceRepository } from '@app/common/modules/space/repositories/subspace.repository'; +import { In, QueryRunner } from 'typeorm'; +import { DeviceEntity } from '@app/common/modules/device/entities'; +import { TagRepository } from '@app/common/modules/space'; @Injectable() export class SubspaceDeviceService { constructor( - private readonly spaceRepository: SpaceRepository, - private readonly communityRepository: CommunityRepository, private readonly subspaceRepository: SubspaceRepository, private readonly deviceRepository: DeviceRepository, private readonly tuyaService: TuyaService, private readonly productRepository: ProductRepository, + private readonly validationService: ValidationService, + private readonly tagRepository: TagRepository, ) {} async listDevicesInSubspace( params: GetSubSpaceParam, ): Promise { - const { subSpaceUuid, spaceUuid, communityUuid } = params; + const { subSpaceUuid, spaceUuid, communityUuid, projectUuid } = params; - await this.validateCommunityAndSpace(communityUuid, spaceUuid); + await this.validationService.checkCommunityAndProjectSpaceExistence( + communityUuid, + projectUuid, + spaceUuid, + ); const subspace = await this.findSubspaceWithDevices(subSpaceUuid); @@ -50,12 +54,10 @@ export class SubspaceDeviceService { ...tuyaDetails, }; } catch (error) { - console.warn( - `Skipping device with deviceTuyaUuid: ${device.deviceTuyaUuid} due to error.`, - ); return null; } }; + const detailedDevices = await Promise.all(subspace.devices.map(safeFetch)); return new SuccessResponseDto({ @@ -67,27 +69,50 @@ export class SubspaceDeviceService { async associateDeviceToSubspace( params: DeviceSubSpaceParam, ): Promise { - const { subSpaceUuid, deviceUuid, spaceUuid, communityUuid } = params; + const { subSpaceUuid, deviceUuid, spaceUuid, communityUuid, projectUuid } = + params; try { - await this.validateCommunityAndSpace(communityUuid, spaceUuid); + await this.validationService.checkCommunityAndProjectSpaceExistence( + communityUuid, + projectUuid, + spaceUuid, + ); const subspace = await this.findSubspace(subSpaceUuid); const device = await this.findDevice(deviceUuid); + if (device.tag?.subspace?.uuid !== subspace.uuid) { + await this.tagRepository.update( + { uuid: device.tag.uuid }, + { subspace }, + ); + } + + if (!device.tag) { + const tag = this.tagRepository.create({ + tag: `Tag ${this.findNextTag()}`, + product: device.productDevice, + subspace: subspace, + device: device, + }); + await this.tagRepository.save(tag); + device.tag = tag; + } + device.subspace = subspace; const newDevice = await this.deviceRepository.save(device); return new SuccessResponseDto({ 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', + `Failed to associate device to subspace with error = ${error}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } @@ -97,12 +122,16 @@ export class SubspaceDeviceService { async disassociateDeviceFromSubspace( params: DeviceSubSpaceParam, ): Promise { - const { subSpaceUuid, deviceUuid, spaceUuid, communityUuid } = params; + const { subSpaceUuid, deviceUuid, spaceUuid, communityUuid, projectUuid } = + params; try { - await this.validateCommunityAndSpace(communityUuid, spaceUuid); - + await this.validationService.checkCommunityAndProjectSpaceExistence( + communityUuid, + projectUuid, + spaceUuid, + ); const subspace = await this.findSubspace(subSpaceUuid); - const device = await this.findDevice(deviceUuid); + const device = await this.findDeviceWithSubspaceAndTag(deviceUuid); if (!device.subspace || device.subspace.uuid !== subspace.uuid) { throw new HttpException( @@ -111,6 +140,26 @@ export class SubspaceDeviceService { ); } + if (device.tag?.subspace !== null) { + await this.tagRepository.update( + { uuid: device.tag.uuid }, + { subspace: null, space: device.spaceDevice }, + ); + } + + if (!device.tag) { + const tag = this.tagRepository.create({ + tag: `Tag ${this.findNextTag()}`, + product: device.productDevice, + subspace: null, + space: device.spaceDevice, + device: device, + }); + + await this.tagRepository.save(tag); + device.tag = tag; + } + device.subspace = null; const updatedDevice = await this.deviceRepository.save(device); @@ -123,32 +172,12 @@ export class SubspaceDeviceService { throw error; } else { throw new HttpException( - 'Failed to dissociate device from subspace', + `Failed to dissociate device from subspace error = ${error}`, HttpStatus.INTERNAL_SERVER_ERROR, ); } } } - // Helper method to validate community and space - private async validateCommunityAndSpace( - communityUuid: string, - spaceUuid: string, - ) { - const community = await this.communityRepository.findOne({ - where: { uuid: communityUuid }, - }); - if (!community) { - this.throwNotFound('Community', communityUuid); - } - - const space = await this.spaceRepository.findOne({ - where: { uuid: spaceUuid, community: { uuid: communityUuid } }, - }); - if (!space) { - this.throwNotFound('Space', spaceUuid); - } - return space; - } // Helper method to find subspace with devices relation private async findSubspaceWithDevices(subSpaceUuid: string) { @@ -175,7 +204,13 @@ export class SubspaceDeviceService { private async findDevice(deviceUuid: string) { const device = await this.deviceRepository.findOne({ where: { uuid: deviceUuid }, - relations: ['subspace'], + relations: [ + 'subspace', + 'tag', + 'tag.space', + 'tag.subspace', + 'spaceDevice', + ], }); if (!device) { this.throwNotFound('Device', deviceUuid); @@ -183,6 +218,30 @@ export class SubspaceDeviceService { return device; } + async deleteSubspaceDevices( + devices: DeviceEntity[], + queryRunner: QueryRunner, + ): Promise { + const deviceUuids = devices.map((device) => device.uuid); + + try { + if (deviceUuids.length === 0) { + return; + } + + await queryRunner.manager.update( + this.deviceRepository.target, + { uuid: In(deviceUuids) }, + { subspace: null }, + ); + } catch (error) { + throw new HttpException( + `Failed to delete devices with IDs ${deviceUuids.join(', ')}: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + private throwNotFound(entity: string, uuid: string) { throw new HttpException( `${entity} with ID ${uuid} not found`, @@ -218,4 +277,27 @@ export class SubspaceDeviceService { ); } } + + async findNextTag(): Promise { + const tags = await this.tagRepository.find({ select: ['tag'] }); + + const tagNumbers = tags + .map((t) => t.tag.match(/^Tag (\d+)$/)) + .filter((match) => match) + .map((match) => parseInt(match[1])) + .sort((a, b) => a - b); + + const nextTagNumber = tagNumbers.length + ? tagNumbers[tagNumbers.length - 1] + 1 + : 1; + return nextTagNumber; + } + + private async findDeviceWithSubspaceAndTag(deviceUuid: string) { + return await this.deviceRepository.findOne({ + where: { uuid: deviceUuid }, + relations: ['subspace', 'tag', 'spaceDevice'], + select: ['uuid', 'subspace', 'spaceDevice', 'productDevice', 'tag'], + }); + } } diff --git a/src/space/services/subspace/subspace.service.ts b/src/space/services/subspace/subspace.service.ts index 22c60ee..acd2fd3 100644 --- a/src/space/services/subspace/subspace.service.ts +++ b/src/space/services/subspace/subspace.service.ts @@ -1,11 +1,13 @@ import { BaseResponseDto } from '@app/common/dto/base.response.dto'; -import { CommunityRepository } from '@app/common/modules/community/repositories'; -import { - SpaceRepository, - SubspaceRepository, -} from '@app/common/modules/space/repositories'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import { AddSubspaceDto, GetSpaceParam, GetSubSpaceParam } from '../../dtos'; +import { + AddSubspaceDto, + CreateTagDto, + DeleteSubspaceDto, + GetSpaceParam, + GetSubSpaceParam, + ModifySubspaceDto, +} from '../../dtos'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { TypeORMCustomModel, @@ -13,26 +15,146 @@ import { } from '@app/common/models/typeOrmCustom.model'; import { PageResponse } from '@app/common/dto/pagination.response.dto'; import { SubspaceDto } from '@app/common/modules/space/dtos'; +import { In, QueryRunner } from 'typeorm'; +import { + SpaceEntity, + SubspaceEntity, +} from '@app/common/modules/space/entities'; +import { SubspaceModelEntity } from '@app/common/modules/space-model'; +import { ValidationService } from '../space-validation.service'; +import { SubspaceRepository } from '@app/common/modules/space/repositories/subspace.repository'; +import { TagService } from '../tag'; +import { ModifyAction } from '@app/common/constants/modify-action.enum'; +import { SubspaceDeviceService } from './subspace-device.service'; +import { ModifyTagDto } from 'src/space/dtos/tag/modify-tag.dto'; @Injectable() export class SubSpaceService { constructor( - private readonly spaceRepository: SpaceRepository, - private readonly communityRepository: CommunityRepository, private readonly subspaceRepository: SubspaceRepository, + private readonly validationService: ValidationService, + private readonly tagService: TagService, + public readonly deviceService: SubspaceDeviceService, ) {} + async createSubspaces( + subspaceData: Array<{ + subspaceName: string; + space: SpaceEntity; + subSpaceModel?: SubspaceModelEntity; + }>, + queryRunner: QueryRunner, + ): Promise { + try { + const subspaceNames = subspaceData.map((data) => data.subspaceName); + + await this.checkExistingNamesInSpace( + subspaceNames, + subspaceData[0].space, + ); + const subspaces = subspaceData.map((data) => + queryRunner.manager.create(this.subspaceRepository.target, data), + ); + return await queryRunner.manager.save(subspaces); + } catch (error) { + throw new HttpException( + `An unexpected error occurred while creating subspaces. ${error}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async createSubSpaceFromModel( + subspaceModels: SubspaceModelEntity[], + space: SpaceEntity, + queryRunner: QueryRunner, + ): Promise { + if (!subspaceModels?.length) return; + + const subspaceData = subspaceModels.map((subSpaceModel) => ({ + subspaceName: subSpaceModel.subspaceName, + space, + subSpaceModel, + })); + + const subspaces = await this.createSubspaces(subspaceData, queryRunner); + + await Promise.all( + subspaceModels.map((model, index) => + this.tagService.createTagsFromModel( + queryRunner, + model.tags || [], + null, + subspaces[index], + ), + ), + ); + } + + async createSubspacesFromDto( + addSubspaceDtos: AddSubspaceDto[], + space: SpaceEntity, + queryRunner: QueryRunner, + otherTags?: CreateTagDto[], + ): Promise { + try { + this.checkForDuplicateNames( + addSubspaceDtos.map(({ subspaceName }) => subspaceName), + ); + + const subspaceData = addSubspaceDtos.map((dto) => ({ + subspaceName: dto.subspaceName, + space, + })); + + const subspaces = await this.createSubspaces(subspaceData, queryRunner); + + await Promise.all( + addSubspaceDtos.map(async (dto, index) => { + const otherDtoTags = addSubspaceDtos + .filter((_, i) => i !== index) + .flatMap((otherDto) => otherDto.tags || []); + const subspace = subspaces[index]; + if (dto.tags?.length) { + subspace.tags = await this.tagService.createTags( + dto.tags, + queryRunner, + null, + subspace, + [...(otherTags || []), ...otherDtoTags], + ); + } + }), + ); + return subspaces; + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + 'Failed to save subspaces due to an unexpected error.', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + async createSubspace( addSubspaceDto: AddSubspaceDto, params: GetSpaceParam, ): Promise { - const { communityUuid, spaceUuid } = params; - const space = await this.validateCommunityAndSpace( - communityUuid, - spaceUuid, - ); + const space = + await this.validationService.validateSpaceWithinCommunityAndProject( + params.communityUuid, + params.projectUuid, + params.spaceUuid, + ); try { + await this.checkExistingNamesInSpace( + [addSubspaceDto.subspaceName], + space, + ); const newSubspace = this.subspaceRepository.create({ ...addSubspaceDto, space, @@ -54,8 +176,12 @@ export class SubSpaceService { params: GetSpaceParam, pageable: Partial, ): Promise { - const { communityUuid, spaceUuid } = params; - await this.validateCommunityAndSpace(communityUuid, spaceUuid); + const { communityUuid, spaceUuid, projectUuid } = params; + await this.validationService.checkCommunityAndProjectSpaceExistence( + communityUuid, + projectUuid, + spaceUuid, + ); try { pageable.modelName = 'subspace'; @@ -73,46 +199,16 @@ export class SubSpaceService { } } - async findOne(params: GetSubSpaceParam): Promise { - const { communityUuid, subSpaceUuid, spaceUuid } = params; - await this.validateCommunityAndSpace(communityUuid, spaceUuid); - try { - const subSpace = await this.subspaceRepository.findOne({ - where: { - uuid: subSpaceUuid, - }, - }); - - // If space is not found, throw a NotFoundException - if (!subSpace) { - throw new HttpException( - `Sub Space with UUID ${subSpaceUuid} not found`, - HttpStatus.NOT_FOUND, - ); - } - return new SuccessResponseDto({ - message: `Subspace with ID ${subSpaceUuid} successfully fetched`, - data: subSpace, - }); - } catch (error) { - if (error instanceof HttpException) { - throw error; // If it's an HttpException, rethrow it - } else { - throw new HttpException( - 'An error occurred while deleting the subspace', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } - } - async updateSubSpace( params: GetSubSpaceParam, updateSubSpaceDto: AddSubspaceDto, ): Promise { - const { spaceUuid, communityUuid, subSpaceUuid } = params; - await this.validateCommunityAndSpace(communityUuid, spaceUuid); - + const { spaceUuid, communityUuid, subSpaceUuid, projectUuid } = params; + await this.validationService.checkCommunityAndProjectSpaceExistence( + communityUuid, + projectUuid, + spaceUuid, + ); const subSpace = await this.subspaceRepository.findOne({ where: { uuid: subSpaceUuid }, }); @@ -146,8 +242,12 @@ export class SubSpaceService { } async delete(params: GetSubSpaceParam): Promise { - const { spaceUuid, communityUuid, subSpaceUuid } = params; - await this.validateCommunityAndSpace(communityUuid, spaceUuid); + const { spaceUuid, communityUuid, subSpaceUuid, projectUuid } = params; + await this.validationService.checkCommunityAndProjectSpaceExistence( + communityUuid, + projectUuid, + spaceUuid, + ); const subspace = await this.subspaceRepository.findOne({ where: { uuid: subSpaceUuid }, @@ -175,29 +275,268 @@ export class SubSpaceService { } } - private async validateCommunityAndSpace( - communityUuid: string, - spaceUuid: string, + async deleteSubspaces( + deleteDtos: DeleteSubspaceDto[], + queryRunner: QueryRunner, ) { - const community = await this.communityRepository.findOne({ - where: { uuid: communityUuid }, - }); - if (!community) { + const deleteResults: { uuid: string }[] = []; + + for (const dto of deleteDtos) { + const subspace = await this.findOne(dto.subspaceUuid); + + await queryRunner.manager.update( + this.subspaceRepository.target, + { uuid: dto.subspaceUuid }, + { disabled: true }, + ); + + if (subspace.tags?.length) { + const modifyTagDtos = subspace.tags.map((tag) => ({ + uuid: tag.uuid, + action: ModifyAction.DELETE, + })); + await this.tagService.modifyTags( + modifyTagDtos, + queryRunner, + null, + subspace, + ); + } + + if (subspace.devices) + await this.deviceService.deleteSubspaceDevices( + subspace.devices, + queryRunner, + ); + + deleteResults.push({ uuid: dto.subspaceUuid }); + } + + return deleteResults; + } + + async modifySubSpace( + subspaceDtos: ModifySubspaceDto[], + queryRunner: QueryRunner, + space?: SpaceEntity, + ) { + for (const subspace of subspaceDtos) { + switch (subspace.action) { + case ModifyAction.ADD: + await this.handleAddAction(subspace, space, queryRunner); + break; + case ModifyAction.UPDATE: + await this.handleUpdateAction(subspace, queryRunner); + break; + case ModifyAction.DELETE: + await this.handleDeleteAction(subspace, queryRunner); + break; + default: + throw new HttpException( + `Invalid action "${subspace.action}".`, + HttpStatus.BAD_REQUEST, + ); + } + } + } + + async unlinkModels( + subspaces: SubspaceEntity[], + queryRunner: QueryRunner, + ): Promise { + if (!subspaces || subspaces.length === 0) { + return; + } + try { + const allTags = subspaces.flatMap((subSpace) => { + subSpace.subSpaceModel = null; + return subSpace.tags || []; + }); + + await this.tagService.unlinkModels(allTags, queryRunner); + + await queryRunner.manager.save(subspaces); + } catch (error) { + if (error instanceof HttpException) throw error; throw new HttpException( - `Community with ID ${communityUuid} not found`, - HttpStatus.NOT_FOUND, + `Failed to unlink subspace models: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async getOne(params: GetSubSpaceParam): Promise { + await this.validationService.checkCommunityAndProjectSpaceExistence( + params.communityUuid, + params.projectUuid, + params.spaceUuid, + ); + const subspace = await this.findOne(params.subSpaceUuid); + return new SuccessResponseDto({ + message: `Successfully retrieved subspace`, + data: subspace, + }); + } + + private async handleAddAction( + subspace: ModifySubspaceDto, + space: SpaceEntity, + queryRunner: QueryRunner, + ): Promise { + const createTagDtos: CreateTagDto[] = + subspace.tags?.map((tag) => ({ + tag: tag.tag as string, + uuid: tag.uuid, + productUuid: tag.productUuid as string, + })) || []; + const subSpace = await this.createSubspacesFromDto( + [{ subspaceName: subspace.subspaceName, tags: createTagDtos }], + space, + queryRunner, + ); + return subSpace[0]; + } + + private async handleUpdateAction( + modifyDto: ModifySubspaceDto, + queryRunner: QueryRunner, + ): Promise { + const subspace = await this.findOne(modifyDto.uuid); + await this.update( + queryRunner, + subspace, + modifyDto.subspaceName, + modifyDto.tags, + ); + } + + async update( + queryRunner: QueryRunner, + subspace: SubspaceEntity, + subspaceName?: string, + modifyTagDto?: ModifyTagDto[], + ) { + await this.updateSubspaceName(queryRunner, subspace, subspaceName); + + if (modifyTagDto?.length) { + await this.tagService.modifyTags( + modifyTagDto, + queryRunner, + null, + subspace, + ); + } + } + + async handleDeleteAction( + modifyDto: ModifySubspaceDto, + queryRunner: QueryRunner, + ): Promise { + const subspace = await this.findOne(modifyDto.uuid); + + await queryRunner.manager.update( + this.subspaceRepository.target, + { uuid: subspace.uuid }, + { disabled: true }, + ); + + if (subspace.tags?.length) { + const modifyTagDtos: CreateTagDto[] = subspace.tags.map((tag) => ({ + uuid: tag.uuid, + action: ModifyAction.ADD, + tag: tag.tag, + productUuid: tag.product.uuid, + })); + await this.tagService.moveTags( + modifyTagDtos, + queryRunner, + subspace.space, + null, ); } - const space = await this.spaceRepository.findOne({ - where: { uuid: spaceUuid, community: { uuid: communityUuid } }, + if (subspace.devices.length > 0) { + await this.deviceService.deleteSubspaceDevices( + subspace.devices, + queryRunner, + ); + } + } + + private async findOne(subspaceUuid: string): Promise { + const subspace = await this.subspaceRepository.findOne({ + where: { uuid: subspaceUuid }, + relations: ['tags', 'space', 'devices', 'tags.product', 'tags.device'], }); - if (!space) { + if (!subspace) { throw new HttpException( - `Space with ID ${spaceUuid} not found`, + `SubspaceModel with UUID ${subspaceUuid} not found.`, HttpStatus.NOT_FOUND, ); } - return space; + return subspace; + } + + async updateSubspaceName( + queryRunner: QueryRunner, + subSpace: SubspaceEntity, + subspaceName?: string, + ): Promise { + if (subspaceName) { + subSpace.subspaceName = subspaceName; + await queryRunner.manager.save(subSpace); + } + } + + private async checkForDuplicateNames(names: string[]): Promise { + const seenNames = new Set(); + const duplicateNames = new Set(); + + for (const name of names) { + if (!seenNames.add(name)) { + duplicateNames.add(name); + } + } + + if (duplicateNames.size > 0) { + throw new HttpException( + `Duplicate subspace names found: ${[...duplicateNames].join(', ')}`, + HttpStatus.CONFLICT, + ); + } + } + + private async checkExistingNamesInSpace( + names: string[], + space: SpaceEntity, + ): Promise { + const existingNames = await this.subspaceRepository.find({ + select: ['subspaceName'], + where: { + subspaceName: In(names), + space: { + uuid: space.uuid, + }, + disabled: false, + }, + }); + + if (existingNames.length > 0) { + const existingNamesList = existingNames + .map((e) => e.subspaceName) + .join(', '); + throw new HttpException( + `Subspace names already exist in the space: ${existingNamesList}`, + HttpStatus.BAD_REQUEST, + ); + } + } + + private async validateName( + names: string[], + space: SpaceEntity, + ): Promise { + await this.checkForDuplicateNames(names); + await this.checkExistingNamesInSpace(names, space); } } diff --git a/src/space/services/tag/index.ts b/src/space/services/tag/index.ts new file mode 100644 index 0000000..0cbeec4 --- /dev/null +++ b/src/space/services/tag/index.ts @@ -0,0 +1 @@ +export * from './tag.service'; diff --git a/src/space/services/tag/tag.service.ts b/src/space/services/tag/tag.service.ts new file mode 100644 index 0000000..2dee0c7 --- /dev/null +++ b/src/space/services/tag/tag.service.ts @@ -0,0 +1,570 @@ +import { ModifyAction } from '@app/common/constants/modify-action.enum'; +import { + SpaceEntity, + SubspaceEntity, + TagEntity, + TagRepository, +} from '@app/common/modules/space'; +import { TagModel } from '@app/common/modules/space-model'; +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { ProductService } from 'src/product/services'; +import { CreateTagDto, ModifySubspaceDto } from 'src/space/dtos'; +import { ModifyTagDto } from 'src/space/dtos/tag/modify-tag.dto'; +import { QueryRunner } from 'typeorm'; + +@Injectable() +export class TagService { + constructor( + private readonly tagRepository: TagRepository, + private readonly productService: ProductService, + ) {} + + async createTags( + tags: CreateTagDto[], + queryRunner: QueryRunner, + space?: SpaceEntity, + subspace?: SubspaceEntity, + additionalTags?: CreateTagDto[], + tagsToDelete?: ModifyTagDto[], + ): Promise { + this.validateTagsInput(tags); + + const combinedTags = this.combineTags(tags, additionalTags); + this.ensureNoDuplicateTags(combinedTags); + + const tagEntitiesToCreate = tags.filter((tagDto) => !tagDto.uuid); + const tagEntitiesToUpdate = tags.filter((tagDto) => !!tagDto.uuid); + + try { + const createdTags = await this.bulkSaveTags( + tagEntitiesToCreate, + queryRunner, + space, + subspace, + tagsToDelete, + ); + const updatedTags = await this.moveTags( + tagEntitiesToUpdate, + queryRunner, + space, + subspace, + ); + + return [...createdTags, ...updatedTags]; + } catch (error) { + throw this.handleUnexpectedError('Failed to save tags', error); + } + } + + async bulkSaveTags( + tags: CreateTagDto[], + queryRunner: QueryRunner, + space?: SpaceEntity, + subspace?: SubspaceEntity, + tagsToDelete?: ModifyTagDto[], + ): Promise { + if (!tags.length) { + return []; + } + + const tagEntities = await Promise.all( + tags.map((tagDto) => + this.prepareTagEntity( + tagDto, + queryRunner, + space, + subspace, + tagsToDelete, + ), + ), + ); + + try { + return await queryRunner.manager.save(tagEntities); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + `Failed to save tag models due to an unexpected error: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async moveTags( + tags: CreateTagDto[], + queryRunner: QueryRunner, + space?: SpaceEntity, + subspace?: SubspaceEntity, + ): Promise { + if (!tags.length) { + return []; + } + + try { + return await Promise.all( + tags.map(async (tagDto) => { + try { + const tag = await this.getTagByUuid(tagDto.uuid); + if (!tag) { + throw new HttpException( + `Tag with UUID ${tagDto.uuid} not found.`, + HttpStatus.NOT_FOUND, + ); + } + + if (subspace && subspace.space) { + await queryRunner.manager.update( + this.tagRepository.target, + { uuid: tag.uuid }, + { subspace, space: null }, + ); + tag.subspace = subspace; + } + + if (!subspace && space) { + await queryRunner.manager.update( + this.tagRepository.target, + { uuid: tag.uuid }, + { subspace: null, space: space }, + ); + tag.subspace = null; + tag.space = space; + } + + return tag; + } catch (error) { + console.error( + `Error moving tag with UUID ${tagDto.uuid}: ${error.message}`, + ); + throw error; // Re-throw the error to propagate it to the parent Promise.all + } + }), + ); + } catch (error) { + console.error(`Error in moveTags: ${error.message}`); + throw new HttpException( + `Failed to move tags due to an unexpected error: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async createTagsFromModel( + queryRunner: QueryRunner, + tagModels: TagModel[], + space?: SpaceEntity, + subspace?: SubspaceEntity, + ): Promise { + if (!tagModels?.length) return; + + const tags = tagModels.map((model) => + queryRunner.manager.create(this.tagRepository.target, { + tag: model.tag, + space: space || undefined, + subspace: subspace || undefined, + product: model.product, + }), + ); + + await queryRunner.manager.save(tags); + } + + async updateTag( + tag: ModifyTagDto, + queryRunner: QueryRunner, + space?: SpaceEntity, + subspace?: SubspaceEntity, + ): Promise { + try { + const existingTag = await this.getTagByUuid(tag.uuid); + + const contextSpace = space ?? subspace?.space; + + if (contextSpace && tag.tag !== existingTag.tag) { + await this.checkTagReuse( + tag.tag, + existingTag.product.uuid, + contextSpace, + ); + } + + return await queryRunner.manager.save( + Object.assign(existingTag, { tag: tag.tag }), + ); + } catch (error) { + throw this.handleUnexpectedError('Failed to update tags', error); + } + } + + async updateTagsFromModel( + model: TagModel, + queryRunner: QueryRunner, + ): Promise { + try { + const tags = await this.tagRepository.find({ + where: { + model: { + uuid: model.uuid, + }, + }, + }); + + if (!tags.length) return; + + await queryRunner.manager.update( + this.tagRepository.target, + { model: { uuid: model.uuid } }, + { tag: model.tag }, + ); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + `Failed to update tags for model with UUID: ${model.uuid}. Reason: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async deleteTags(tagUuids: string[], queryRunner: QueryRunner) { + if (!tagUuids?.length) return; + + try { + await Promise.all( + tagUuids.map((id) => + queryRunner.manager.update( + this.tagRepository.target, + { uuid: id }, + { disabled: true, device: null }, + ), + ), + ); + return { message: 'Tags deleted successfully', tagUuids }; + } catch (error) { + throw this.handleUnexpectedError('Failed to delete tags', error); + } + } + + async deleteTagFromModel(modelUuid: string, queryRunner: QueryRunner) { + try { + const tags = await this.tagRepository.find({ + where: { + model: { + uuid: modelUuid, + }, + }, + }); + + if (!tags.length) return; + + await queryRunner.manager.update( + this.tagRepository.target, + { model: { uuid: modelUuid } }, + { disabled: true, device: null }, + ); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + + throw new HttpException( + `Failed to update tags for model with UUID: ${modelUuid}. Reason: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async modifyTags( + tags: ModifyTagDto[], + queryRunner: QueryRunner, + space?: SpaceEntity, + subspace?: SubspaceEntity, + ): Promise { + if (!tags?.length) return; + + try { + const tagsToDelete = tags.filter( + (tag) => tag.action === ModifyAction.DELETE, + ); + + await Promise.all( + tags.map(async (tag) => { + switch (tag.action) { + case ModifyAction.ADD: + await this.createTags( + [ + { + tag: tag.tag, + productUuid: tag.productUuid, + uuid: tag.uuid, + }, + ], + queryRunner, + space, + subspace, + null, + tagsToDelete, + ); + break; + case ModifyAction.UPDATE: + await this.updateTag(tag, queryRunner, space, subspace); + break; + case ModifyAction.DELETE: + await this.deleteTags([tag.uuid], queryRunner); + break; + default: + throw new HttpException( + `Invalid action "${tag.action}" provided.`, + HttpStatus.BAD_REQUEST, + ); + } + }), + ); + } catch (error) { + throw this.handleUnexpectedError('Failed to modify tags', error); + } + } + + async unlinkModels(tags: TagEntity[], queryRunner: QueryRunner) { + if (!tags?.length) return; + + try { + tags.forEach((tag) => { + tag.model = null; + }); + + await queryRunner.manager.save(tags); + } catch (error) { + throw this.handleUnexpectedError('Failed to unlink tag models', error); + } + } + + private findDuplicateTags(tags: CreateTagDto[]): string[] { + const seen = new Map(); + const duplicates: string[] = []; + + tags.forEach((tagDto) => { + const key = `${tagDto.productUuid}-${tagDto.tag}`; + if (seen.has(key)) { + duplicates.push(`${tagDto.tag} for Product: ${tagDto.productUuid}`); + } else { + seen.set(key, true); + } + }); + + return duplicates; + } + + private async checkTagReuse( + tag: string, + productUuid: string, + space: SpaceEntity, + tagsToDelete?: ModifyTagDto[], + ): Promise { + const { uuid: spaceUuid } = space; + + const tagExists = await this.tagRepository.find({ + where: [ + { + tag, + space: { uuid: spaceUuid }, + product: { uuid: productUuid }, + disabled: false, + }, + { + tag, + subspace: { space: { uuid: spaceUuid } }, + product: { uuid: productUuid }, + disabled: false, + }, + ], + }); + + const filteredTagExists = tagExists.filter( + (existingTag) => + !tagsToDelete?.some((deleteTag) => deleteTag.uuid === existingTag.uuid), + ); + + if (filteredTagExists.length > 0) { + throw new HttpException(`Tag can't be reused`, HttpStatus.CONFLICT); + } + } + + private async prepareTagEntity( + tagDto: CreateTagDto, + queryRunner: QueryRunner, + space?: SpaceEntity, + subspace?: SubspaceEntity, + tagsToDelete?: ModifyTagDto[], + ): Promise { + try { + const product = await this.productService.findOne(tagDto.productUuid); + + if (!product) { + throw new HttpException( + `Product with UUID ${tagDto.productUuid} not found.`, + HttpStatus.NOT_FOUND, + ); + } + + if (space) { + await this.checkTagReuse( + tagDto.tag, + tagDto.productUuid, + space, + tagsToDelete, + ); + } else if (subspace && subspace.space) { + await this.checkTagReuse( + tagDto.tag, + tagDto.productUuid, + subspace.space, + ); + } else { + throw new HttpException( + `Invalid subspace or space provided.`, + HttpStatus.BAD_REQUEST, + ); + } + + return queryRunner.manager.create(TagEntity, { + tag: tagDto.tag, + product: product.data, + space: space, + subspace: subspace, + }); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + throw new HttpException( + `An error occurred while preparing the tag entity: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + private async getTagByUuid(uuid: string): Promise { + const tag = await this.tagRepository.findOne({ + where: { uuid }, + relations: ['product'], + }); + + if (!tag) { + throw new HttpException( + `Tag with ID ${uuid} not found.`, + HttpStatus.NOT_FOUND, + ); + } + return tag; + } + + private handleUnexpectedError( + message: string, + error: unknown, + ): HttpException { + if (error instanceof HttpException) throw error; + return new HttpException( + `${message}: ${(error as Error)?.message || 'Unknown error'}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + private combineTags( + primaryTags: CreateTagDto[], + additionalTags?: CreateTagDto[], + ): CreateTagDto[] { + return additionalTags ? [...primaryTags, ...additionalTags] : primaryTags; + } + + private ensureNoDuplicateTags(tags: CreateTagDto[]): void { + const duplicates = this.findDuplicateTags(tags); + + if (duplicates.length > 0) { + throw new HttpException( + `Duplicate tags found: ${duplicates.join(', ')}`, + HttpStatus.BAD_REQUEST, + ); + } + } + + private validateTagsInput(tags: CreateTagDto[]): void { + if (!tags?.length) { + return; + } + } + + getSubspaceTagsToBeAdded( + spaceTags?: ModifyTagDto[], + subspaceModels?: ModifySubspaceDto[], + ): ModifyTagDto[] { + if (!subspaceModels || subspaceModels.length === 0) { + return spaceTags; + } + + const spaceTagsToDelete = spaceTags?.filter( + (tag) => tag.action === 'delete', + ); + + const tagsToAdd = subspaceModels.flatMap( + (subspace) => subspace.tags?.filter((tag) => tag.action === 'add') || [], + ); + + const commonTagUuids = new Set( + tagsToAdd + .filter((tagToAdd) => + spaceTagsToDelete.some( + (tagToDelete) => tagToAdd.uuid === tagToDelete.uuid, + ), + ) + .map((tag) => tag.uuid), + ); + + const remainingTags = spaceTags.filter( + (tag) => !commonTagUuids.has(tag.uuid), // Exclude tags in commonTagUuids + ); + + return remainingTags; + } + + getModifiedSubspaces( + spaceTags?: ModifyTagDto[], + subspaceModels?: ModifySubspaceDto[], + ): ModifySubspaceDto[] { + if (!subspaceModels || subspaceModels.length === 0) { + return []; + } + + // Extract tags marked for addition in spaceTags + const spaceTagsToAdd = spaceTags?.filter((tag) => tag.action === 'add'); + + // Find UUIDs of tags that are common between spaceTagsToAdd and subspace tags marked for deletion + const commonTagUuids = new Set( + spaceTagsToAdd + .flatMap((tagToAdd) => + subspaceModels.flatMap( + (subspace) => + subspace.tags?.filter( + (tagToDelete) => + tagToDelete.action === 'delete' && + tagToAdd.uuid === tagToDelete.uuid, + ) || [], + ), + ) + .map((tag) => tag.uuid), + ); + + // Modify subspaceModels by removing tags with UUIDs present in commonTagUuids + const modifiedSubspaces = subspaceModels.map((subspace) => ({ + ...subspace, + tags: subspace.tags?.filter((tag) => !commonTagUuids.has(tag.uuid)) || [], + })); + + return modifiedSubspaces; + } +} diff --git a/src/space/space.module.ts b/src/space/space.module.ts index 9c80fa1..8289fd2 100644 --- a/src/space/space.module.ts +++ b/src/space/space.module.ts @@ -12,7 +12,6 @@ import { import { SpaceDeviceService, SpaceLinkService, - SpaceProductService, SpaceSceneService, SpaceService, SpaceUserService, @@ -20,17 +19,20 @@ import { SubSpaceService, } from './services'; import { - SpaceProductRepository, SpaceRepository, - SubspaceRepository, SpaceLinkRepository, + TagRepository, + InviteSpaceRepository, } from '@app/common/modules/space/repositories'; import { CommunityRepository } from '@app/common/modules/community/repositories'; import { UserRepository, UserSpaceRepository, } from '@app/common/modules/user/repositories'; -import { DeviceRepository } from '@app/common/modules/device/repositories'; +import { + DeviceRepository, + DeviceUserPermissionRepository, +} from '@app/common/modules/device/repositories'; import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; import { ProductRepository } from '@app/common/modules/product/repositories'; import { SceneService } from '../scene/services'; @@ -42,9 +44,38 @@ import { DeviceService } from 'src/device/services'; import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service'; import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories'; import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; +import { ProjectRepository } from '@app/common/modules/project/repositiories'; +import { + SpaceModelRepository, + SubspaceModelRepository, + TagModelRepository, +} from '@app/common/modules/space-model'; +import { CommunityModule } from 'src/community/community.module'; +import { ValidationService } from './services'; +import { SubspaceRepository } from '@app/common/modules/space/repositories/subspace.repository'; +import { TagService } from './services/tag'; +import { + SpaceModelService, + SubSpaceModelService, + TagModelService, +} from 'src/space-model/services'; +import { UserService, UserSpaceService } from 'src/users/services'; +import { UserDevicePermissionService } from 'src/user-device-permission/services'; +import { PermissionTypeRepository } from '@app/common/modules/permission/repositories'; +import { CqrsModule } from '@nestjs/cqrs'; +import { DisableSpaceHandler } from './handlers'; +import { RegionRepository } from '@app/common/modules/region/repositories'; +import { TimeZoneRepository } from '@app/common/modules/timezone/repositories'; +import { + InviteUserRepository, + InviteUserSpaceRepository, +} from '@app/common/modules/Invite-user/repositiories'; +import { AutomationRepository } from '@app/common/modules/automation/repositories'; + +export const CommandHandlers = [DisableSpaceHandler]; @Module({ - imports: [ConfigModule, SpaceRepositoryModule], + imports: [ConfigModule, SpaceRepositoryModule, CommunityModule, CqrsModule], controllers: [ SpaceController, SpaceUserController, @@ -54,17 +85,21 @@ import { SceneDeviceRepository } from '@app/common/modules/scene-device/reposito SpaceSceneController, ], providers: [ + ValidationService, + TagModelRepository, + TagRepository, SpaceService, TuyaService, + TagService, ProductRepository, SubSpaceService, SpaceDeviceService, SpaceLinkService, SubspaceDeviceService, SpaceRepository, + SubspaceRepository, DeviceRepository, CommunityRepository, - SubspaceRepository, SpaceLinkRepository, UserSpaceRepository, UserRepository, @@ -77,8 +112,24 @@ import { SceneDeviceRepository } from '@app/common/modules/scene-device/reposito DeviceStatusFirebaseService, DeviceStatusLogRepository, SceneDeviceRepository, - SpaceProductService, - SpaceProductRepository, + SpaceModelService, + SubSpaceModelService, + TagModelService, + ProjectRepository, + SpaceModelRepository, + SubspaceModelRepository, + UserSpaceService, + UserDevicePermissionService, + DeviceUserPermissionRepository, + PermissionTypeRepository, + InviteSpaceRepository, + ...CommandHandlers, + UserService, + RegionRepository, + TimeZoneRepository, + InviteUserRepository, + InviteUserSpaceRepository, + AutomationRepository, ], exports: [SpaceService], }) diff --git a/src/terms-conditions/controllers/index.ts b/src/terms-conditions/controllers/index.ts new file mode 100644 index 0000000..6d48cce --- /dev/null +++ b/src/terms-conditions/controllers/index.ts @@ -0,0 +1 @@ +export * from './terms-conditions.controller'; diff --git a/src/terms-conditions/controllers/terms-conditions.controller.ts b/src/terms-conditions/controllers/terms-conditions.controller.ts new file mode 100644 index 0000000..52849ae --- /dev/null +++ b/src/terms-conditions/controllers/terms-conditions.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, HttpStatus } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; +import { ControllerRoute } from '@app/common/constants/controller-route'; // Assuming this is where the routes are defined +import { TermsAndConditionsService } from '../services'; + +@ApiTags('Terms & Conditions Module') +@Controller({ + version: EnableDisableStatusEnum.ENABLED, + path: ControllerRoute.TERMS_AND_CONDITIONS.ROUTE, // use the static route constant +}) +export class TermsConditionsController { + constructor( + private readonly termsAndConditionsService: TermsAndConditionsService, + ) {} + + @Get() + @ApiOperation({ + summary: ControllerRoute.TERMS_AND_CONDITIONS.ACTIONS.FETCH_TERMS_SUMMARY, + description: + ControllerRoute.TERMS_AND_CONDITIONS.ACTIONS.FETCH_TERMS_DESCRIPTION, + }) + async fetchTermsAndConditions() { + const htmlContent = + await this.termsAndConditionsService.fetchTermsAndConditions(); + return { + statusCode: HttpStatus.OK, + message: 'Terms and conditions fetched successfully', + data: htmlContent, + }; + } +} diff --git a/src/terms-conditions/services/index.ts b/src/terms-conditions/services/index.ts new file mode 100644 index 0000000..09d3783 --- /dev/null +++ b/src/terms-conditions/services/index.ts @@ -0,0 +1 @@ +export * from './terms-conditions.service'; diff --git a/src/terms-conditions/services/terms-conditions.service.ts b/src/terms-conditions/services/terms-conditions.service.ts new file mode 100644 index 0000000..c8adf0f --- /dev/null +++ b/src/terms-conditions/services/terms-conditions.service.ts @@ -0,0 +1,38 @@ +import { termsAndConditionsData } from '@app/common/constants/terms-condtions'; +import { Injectable } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; + +@Injectable() +export class TermsAndConditionsService { + async fetchTermsAndConditions(): Promise { + const projectRoot = process.cwd(); + + const filePath = path.join( + projectRoot, + 'libs', + 'common', + 'src', + 'constants', + 'terms-and-conditions.html', + ); + + // Ensure the file exists + if (!fs.existsSync(filePath)) { + throw new Error(`File not found: ${filePath}`); + } + + let htmlContent = fs.readFileSync(filePath, 'utf-8'); + + htmlContent = htmlContent.replace(/(\r\n|\n|\r)/gm, ''); // Removes newlines + + htmlContent = htmlContent + .replace(/{{lastUpdated}}/g, termsAndConditionsData.lastUpdated) + .replace(/{{websiteUrl}}/g, termsAndConditionsData.websiteUrl) + .replace(/{{mobileApp}}/g, termsAndConditionsData.mobileApp) + .replace(/{{companyName}}/g, termsAndConditionsData.companyName) + .replace(/{{contactEmail}}/g, termsAndConditionsData.contactEmail); + + return htmlContent; + } +} diff --git a/src/terms-conditions/terms-conditions.module.ts b/src/terms-conditions/terms-conditions.module.ts new file mode 100644 index 0000000..6dbf974 --- /dev/null +++ b/src/terms-conditions/terms-conditions.module.ts @@ -0,0 +1,12 @@ +import { DeviceRepositoryModule } from '@app/common/modules/device'; +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { TermsConditionsController } from './controllers'; +import { TermsAndConditionsService } from './services'; + +@Module({ + imports: [ConfigModule, DeviceRepositoryModule], + controllers: [TermsConditionsController], + providers: [TermsAndConditionsService], +}) +export class TermsConditionsModule {} diff --git a/src/users/controllers/user-space.controller.ts b/src/users/controllers/user-space.controller.ts index aed945a..519a033 100644 --- a/src/users/controllers/user-space.controller.ts +++ b/src/users/controllers/user-space.controller.ts @@ -1,20 +1,11 @@ import { ControllerRoute } from '@app/common/constants/controller-route'; import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; -import { - Body, - Controller, - Get, - HttpException, - HttpStatus, - Param, - Post, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, Param, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { UserSpaceService } from '../services'; import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { BaseResponseDto } from '@app/common/dto/base.response.dto'; -import { AddUserSpaceUsingCodeDto, UserParamDto } from '../dtos'; +import { UserParamDto } from '../dtos'; @ApiTags('User Module') @Controller({ @@ -36,36 +27,4 @@ export class UserSpaceController { ): Promise { return this.userSpaceService.getSpacesForUser(params.userUuid); } - - @ApiBearerAuth() - @UseGuards(JwtAuthGuard) - @Post('/verify-code') - @ApiOperation({ - summary: - ControllerRoute.USER_SPACE.ACTIONS.VERIFY_CODE_AND_ADD_USER_SPACE_SUMMARY, - description: - ControllerRoute.USER_SPACE.ACTIONS - .VERIFY_CODE_AND_ADD_USER_SPACE_DESCRIPTION, - }) - 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, - ); - } - } } diff --git a/src/users/controllers/user.controller.ts b/src/users/controllers/user.controller.ts index 6f0eded..ce9991a 100644 --- a/src/users/controllers/user.controller.ts +++ b/src/users/controllers/user.controller.ts @@ -5,6 +5,7 @@ import { Get, HttpStatus, Param, + Patch, Put, UseGuards, } from '@nestjs/common'; @@ -21,6 +22,7 @@ import { CheckProfilePictureGuard } from 'src/guards/profile.picture.guard'; import { SuperAdminRoleGuard } from 'src/guards/super.admin.role.guard'; import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; import { ControllerRoute } from '@app/common/constants/controller-route'; +import { BaseResponseDto } from '@app/common/dto/base.response.dto'; @ApiTags('User Module') @Controller({ @@ -151,4 +153,18 @@ export class UserController { message: 'User deleted successfully', }; } + + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Patch('agreements/web/:userUuid') + @ApiOperation({ + summary: ControllerRoute.USER.ACTIONS.UPDATE_USER_WEB_AGREEMENT_SUMMARY, + description: + ControllerRoute.USER.ACTIONS.UPDATE_USER_WEB_AGREEMENT_DESCRIPTION, + }) + async acceptWebAgreement( + @Param('userUuid') userUuid: string, + ): Promise { + return this.userService.acceptWebAgreement(userUuid); + } } diff --git a/src/users/services/user-space.service.ts b/src/users/services/user-space.service.ts index 1c7ac6e..e06bbd5 100644 --- a/src/users/services/user-space.service.ts +++ b/src/users/services/user-space.service.ts @@ -1,19 +1,38 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { + BadRequestException, + HttpException, + HttpStatus, + Injectable, +} from '@nestjs/common'; import { UserSpaceRepository } from '@app/common/modules/user/repositories'; import { SuccessResponseDto } from '@app/common/dto/success.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 { + InviteSpaceRepository, + 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'; +import { InviteSpaceEntity } from '@app/common/modules/space/entities/invite-space.entity'; +import { UserService } from './user.service'; +import { RoleType } from '@app/common/constants/role.type.enum'; +import { + InviteUserRepository, + InviteUserSpaceRepository, +} from '@app/common/modules/Invite-user/repositiories'; +import { UserStatusEnum } from '@app/common/constants/user-status.enum'; @Injectable() export class UserSpaceService { constructor( private readonly userSpaceRepository: UserSpaceRepository, private readonly spaceRepository: SpaceRepository, + private readonly inviteSpaceRepository: InviteSpaceRepository, + private readonly userService: UserService, + private readonly inviteUserRepository: InviteUserRepository, + private readonly inviteUserSpaceRepository: InviteUserSpaceRepository, private readonly userDevicePermissionService: UserDevicePermissionService, ) {} @@ -37,16 +56,22 @@ export class UserSpaceService { params: AddUserSpaceUsingCodeDto, userUuid: string, ) { + const { inviteCode } = params; try { - const space = await this.findSpaceByInviteCode(params.inviteCode); + const inviteSpace = await this.findInviteSpaceByInviteCode(inviteCode); + const user = await this.userService.getUserDetailsByUserUuid(userUuid); + await this.checkSpaceMemberRole(user); + await this.addUserToSpace(userUuid, inviteSpace.space.uuid); - await this.addUserToSpace(userUuid, space.uuid); - - await this.clearUnitInvitationCode(space.uuid); - - const deviceUUIDs = await this.getDeviceUUIDsForSpace(space.uuid); + const deviceUUIDs = await this.getDeviceUUIDsForSpace(inviteSpace.uuid); await this.addUserPermissionsToDevices(userUuid, deviceUUIDs); + await this.addUserAsActiveInvitation( + user, + inviteSpace.space.uuid, + inviteCode, + ); + await this.clearSpaceInvitationCode(inviteSpace.uuid); } catch (err) { if (err instanceof HttpException) { throw err; @@ -58,25 +83,107 @@ export class UserSpaceService { } } } - - private async findSpaceByInviteCode( - inviteCode: string, - ): Promise { + private async checkSpaceMemberRole(user: any) { try { - const space = await this.spaceRepository.findOneOrFail({ - where: { - invitationCode: inviteCode, - }, - }); - return space; - } catch (error) { + if (user.role.type !== RoleType.SPACE_MEMBER) { + throw new BadRequestException( + 'You have to be a space member to join this space', + ); + } + } catch (err) { throw new HttpException( - 'Space with the provided invite code not found', - HttpStatus.NOT_FOUND, + err.message || 'User not found', + err.status || HttpStatus.NOT_FOUND, ); } } + private async findInviteSpaceByInviteCode( + inviteCode: string, + ): Promise { + try { + const inviteSpace = await this.inviteSpaceRepository.findOneOrFail({ + where: { + invitationCode: inviteCode, + isActive: true, + }, + relations: ['space'], + }); + return inviteSpace; + } catch (error) { + throw new HttpException( + 'Invalid invitation code', + HttpStatus.BAD_REQUEST, + ); + } + } + private async clearSpaceInvitationCode(inviteSpaceUuid: string) { + await this.inviteSpaceRepository.update( + { uuid: inviteSpaceUuid }, + { isActive: false }, + ); + } + async getProjectBySpaceUuid(spaceUuid: string) { + try { + const project = await this.spaceRepository.findOne({ + where: { + uuid: spaceUuid, + }, + relations: ['community.project'], + }); + return project; + } catch (error) { + throw new HttpException('Space not found', HttpStatus.NOT_FOUND); + } + } + private async addUserAsActiveInvitation( + user: any, + spaceUuid: string, + inviteCode: string, + ) { + try { + const space = await this.getProjectBySpaceUuid(spaceUuid); + const invitedUserData = await this.inviteUserRepository.findOne({ + where: { + email: user.email, + project: { uuid: space.community.project.uuid }, + }, + }); + if (!invitedUserData) { + const inviteUser = this.inviteUserRepository.create({ + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + jobTitle: null, + phoneNumber: null, + roleType: { uuid: user.role.uuid }, + status: UserStatusEnum.ACTIVE, + invitationCode: inviteCode, + invitedBy: RoleType.SPACE_OWNER, + project: { uuid: space.community.project.uuid }, + user: { uuid: user.uuid }, + }); + const invitedUser = await this.inviteUserRepository.save(inviteUser); + const inviteUserSpace = this.inviteUserSpaceRepository.create({ + inviteUser: { uuid: invitedUser.uuid }, + space: { uuid: spaceUuid }, + }); + await this.inviteUserSpaceRepository.save(inviteUserSpace); + } else { + const inviteUserSpace = this.inviteUserSpaceRepository.create({ + inviteUser: { uuid: invitedUserData.uuid }, + space: { uuid: spaceUuid }, + }); + + await this.inviteUserSpaceRepository.save(inviteUserSpace); + } + } catch (err) { + throw new HttpException( + err.message || 'Failed to add user as an active invitation.', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } private async addUserToSpace(userUuid: string, spaceUuid: string) { try { const user = await this.addUserSpace({ userUuid, spaceUuid }); @@ -110,16 +217,7 @@ export class UserSpaceService { } } - private async clearUnitInvitationCode(spaceUuid: string) { - await this.spaceRepository.update( - { uuid: spaceUuid }, - { invitationCode: null }, - ); - } - - private async getDeviceUUIDsForSpace( - unitUuid: string, - ): Promise<{ uuid: string }[]> { + async getDeviceUUIDsForSpace(unitUuid: string): Promise<{ uuid: string }[]> { const devices = await this.spaceRepository.find({ where: { uuid: unitUuid }, relations: ['devices', 'devices.productDevice'], @@ -130,7 +228,7 @@ export class UserSpaceService { return allDevices.map((device) => ({ uuid: device.uuid })); } - private async addUserPermissionsToDevices( + async addUserPermissionsToDevices( userUuid: string, deviceUUIDs: { uuid: string }[], ): Promise { @@ -150,4 +248,21 @@ export class UserSpaceService { await Promise.all(permissionPromises); } + + async deleteUserSpace(spaceUuid: string) { + try { + await this.userSpaceRepository + .createQueryBuilder('userSpace') + .leftJoin('userSpace.space', 'space') + .delete() + .where('space.uuid = :spaceUuid', { spaceUuid }) + .execute(); + } catch (error) { + console.error(`Error deleting user-space associations: ${error.message}`); + throw new HttpException( + `Failed to delete user-space associations: ${error.message}`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } } diff --git a/src/users/services/user.service.ts b/src/users/services/user.service.ts index 80dad24..268dc7c 100644 --- a/src/users/services/user.service.ts +++ b/src/users/services/user.service.ts @@ -15,6 +15,7 @@ import { RegionRepository } from '@app/common/modules/region/repositories'; import { TimeZoneRepository } from '@app/common/modules/timezone/repositories'; import { removeBase64Prefix } from '@app/common/helper/removeBase64Prefix'; import { UserEntity } from '@app/common/modules/user/entities'; +import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; @Injectable() export class UserService { @@ -29,7 +30,7 @@ export class UserService { where: { uuid: userUuid, }, - relations: ['region', 'timezone'], + relations: ['region', 'timezone', 'roleType'], }); if (!user) { throw new BadRequestException('Invalid room UUID'); @@ -39,13 +40,18 @@ export class UserService { const cleanedProfilePicture = removeBase64Prefix(user.profilePicture); return { - uuid: user.uuid, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, + uuid: user?.uuid, + email: user?.email, + firstName: user?.firstName, + lastName: user?.lastName, profilePicture: cleanedProfilePicture, - region: user.region, - timeZone: user.timezone, + region: user?.region, + timeZone: user?.timezone, + hasAcceptedWebAgreement: user?.hasAcceptedWebAgreement, + webAgreementAcceptedAt: user?.webAgreementAcceptedAt, + hasAcceptedAppAgreement: user?.hasAcceptedAppAgreement, + appAgreementAcceptedAt: user?.appAgreementAcceptedAt, + role: user?.roleType, }; } catch (err) { if (err instanceof BadRequestException) { @@ -238,6 +244,20 @@ export class UserService { ); } } + async acceptWebAgreement(userUuid: string) { + await this.userRepository.update( + { uuid: userUuid }, + { + hasAcceptedWebAgreement: true, + webAgreementAcceptedAt: new Date(), + }, + ); + return new SuccessResponseDto({ + statusCode: HttpStatus.OK, + success: true, + message: 'Web agreement accepted successfully', + }); + } async findOneById(id: string): Promise { return await this.userRepository.findOne({ where: { uuid: id } }); } diff --git a/src/users/user.module.ts b/src/users/user.module.ts index 20bd06f..004ac32 100644 --- a/src/users/user.module.ts +++ b/src/users/user.module.ts @@ -12,10 +12,17 @@ import { UserSpaceController } from './controllers'; import { CommunityModule } from 'src/community/community.module'; import { UserSpaceService } from './services'; import { CommunityRepository } from '@app/common/modules/community/repositories'; -import { SpaceRepository } from '@app/common/modules/space/repositories'; +import { + InviteSpaceRepository, + 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'; +import { + InviteUserRepository, + InviteUserSpaceRepository, +} from '@app/common/modules/Invite-user/repositiories'; @Module({ imports: [ConfigModule, CommunityModule], @@ -32,6 +39,9 @@ import { PermissionTypeRepository } from '@app/common/modules/permission/reposit DeviceUserPermissionRepository, PermissionTypeRepository, UserSpaceService, + InviteSpaceRepository, + InviteUserRepository, + InviteUserSpaceRepository, ], exports: [UserService], }) diff --git a/src/vistor-password/controllers/visitor-password.controller.ts b/src/vistor-password/controllers/visitor-password.controller.ts index 55276be..a298114 100644 --- a/src/vistor-password/controllers/visitor-password.controller.ts +++ b/src/vistor-password/controllers/visitor-password.controller.ts @@ -15,9 +15,10 @@ import { AddDoorLockOnlineMultipleDto, AddDoorLockOnlineOneTimeDto, } from '../dtos/temp-pass.dto'; -import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard'; import { EnableDisableStatusEnum } from '@app/common/constants/days.enum'; import { ControllerRoute } from '@app/common/constants/controller-route'; +import { PermissionsGuard } from 'src/guards/permissions.guard'; +import { Permissions } from 'src/decorators/permissions.decorator'; @ApiTags('Visitor Password Module') @Controller({ @@ -30,7 +31,8 @@ export class VisitorPasswordController { ) {} @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('VISITOR_PASSWORD_ADD') @Post('temporary-password/online/multiple-time') @ApiOperation({ summary: @@ -58,7 +60,8 @@ export class VisitorPasswordController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('VISITOR_PASSWORD_ADD') @Post('temporary-password/online/one-time') @ApiOperation({ summary: @@ -86,7 +89,8 @@ export class VisitorPasswordController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('VISITOR_PASSWORD_ADD') @Post('temporary-password/offline/one-time') @ApiOperation({ summary: @@ -114,7 +118,8 @@ export class VisitorPasswordController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('VISITOR_PASSWORD_ADD') @Post('temporary-password/offline/multiple-time') @ApiOperation({ summary: @@ -143,7 +148,8 @@ export class VisitorPasswordController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('VISITOR_PASSWORD_VIEW') @Get() @ApiOperation({ summary: @@ -156,7 +162,8 @@ export class VisitorPasswordController { } @ApiBearerAuth() - @UseGuards(JwtAuthGuard) + @UseGuards(PermissionsGuard) + @Permissions('VISITOR_PASSWORD_VIEW') @Get('/devices') @ApiOperation({ summary: diff --git a/src/vistor-password/visitor-password.module.ts b/src/vistor-password/visitor-password.module.ts index e768ad5..3aaf3a5 100644 --- a/src/vistor-password/visitor-password.module.ts +++ b/src/vistor-password/visitor-password.module.ts @@ -20,6 +20,7 @@ import { SceneRepository, } from '@app/common/modules/scene/repositories'; import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; +import { AutomationRepository } from '@app/common/modules/automation/repositories'; @Module({ imports: [ConfigModule, DeviceRepositoryModule, DoorLockModule], controllers: [VisitorPasswordController], @@ -39,6 +40,7 @@ import { SceneDeviceRepository } from '@app/common/modules/scene-device/reposito SceneIconRepository, SceneRepository, SceneDeviceRepository, + AutomationRepository, ], exports: [VisitorPasswordService], })