Compare commits

...

11 Commits

Author SHA1 Message Date
e5970c02c1 SP-1757, SP-1758, SP-1809, SP-1810: Feat/implement booking (#469)
* fix: commission device API

* task: add create booking API

* add get All api for dashboard & mobile

* add Find APIs for bookings

* implement sending email updates on update bookable space

* move email interfaces to separate files
2025-07-15 10:11:36 +03:00
a9cb1b6704 SP-1830: add company name to invite user entity (#468) 2025-07-10 15:41:46 +03:00
a17a271213 add validation checks (#466) 2025-07-10 14:41:50 +03:00
712b7443ac fix: prevent conditions overlapping by adding parenthesis to search condition (#464) 2025-07-10 11:34:50 +03:00
945328c0ce fix: commission device API (#465) 2025-07-10 11:33:55 +03:00
009deaf403 SP-1812: fix: update bookable space (#467) 2025-07-10 11:11:30 +03:00
3cfed2b452 fix: check if subspaces not exists in update space (#463) 2025-07-10 10:56:27 +03:00
09322c5b80 add booking points to user table (#461) 2025-07-09 14:25:42 +03:00
74d3620d0e Chore/space link tag cleanup (#462)
* chore: remove unused imports and dead code for space link

* chore: remove unused imports and dead code

* chore: remove unused imports and dead code of tag service
2025-07-09 14:25:26 +03:00
83be80d9f6 add order space API (#459) 2025-07-09 11:44:14 +03:00
2589e391ed fix: add unique validation on subspaces in update space dto (#460) 2025-07-09 11:33:04 +03:00
63 changed files with 1792 additions and 779 deletions

View File

@ -1,5 +1,6 @@
import { PlatformType } from '@app/common/constants/platform-type.enum'; import { PlatformType } from '@app/common/constants/platform-type.enum';
import { RoleType } from '@app/common/constants/role.type.enum'; import { RoleType } from '@app/common/constants/role.type.enum';
import { UserEntity } from '@app/common/modules/user/entities';
import { import {
BadRequestException, BadRequestException,
Injectable, Injectable,
@ -32,7 +33,7 @@ export class AuthService {
pass: string, pass: string,
regionUuid?: string, regionUuid?: string,
platform?: PlatformType, platform?: PlatformType,
): Promise<any> { ): Promise<Omit<UserEntity, 'password'>> {
const user = await this.userRepository.findOne({ const user = await this.userRepository.findOne({
where: { where: {
email, email,
@ -70,8 +71,9 @@ export class AuthService {
} }
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { password, ...result } = user; // const { password, ...result } = user;
return result; delete user.password;
return user;
} }
async createSession(data): Promise<UserSessionEntity> { async createSession(data): Promise<UserSessionEntity> {
@ -114,6 +116,7 @@ export class AuthService {
hasAcceptedWebAgreement: user.hasAcceptedWebAgreement, hasAcceptedWebAgreement: user.hasAcceptedWebAgreement,
hasAcceptedAppAgreement: user.hasAcceptedAppAgreement, hasAcceptedAppAgreement: user.hasAcceptedAppAgreement,
project: user?.project, project: user?.project,
bookingPoints: user?.bookingPoints,
}; };
if (payload.googleCode) { if (payload.googleCode) {
const profile = await this.getProfile(payload.googleCode); const profile = await this.getProfile(payload.googleCode);

View File

@ -1,12 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ErrorMessageService } from 'src/error-message/error-message.service';
import { AuthModule } from './auth/auth.module';
import { CommonService } from './common.service'; import { CommonService } from './common.service';
import config from './config';
import { DatabaseModule } from './database/database.module'; import { DatabaseModule } from './database/database.module';
import { HelperModule } from './helper/helper.module'; import { HelperModule } from './helper/helper.module';
import { AuthModule } from './auth/auth.module';
import { ConfigModule } from '@nestjs/config';
import config from './config';
import { EmailService } from './util/email.service';
import { ErrorMessageService } from 'src/error-message/error-message.service';
import { TuyaService } from './integrations/tuya/services/tuya.service'; import { TuyaService } from './integrations/tuya/services/tuya.service';
import { SceneDeviceRepository } from './modules/scene-device/repositories'; import { SceneDeviceRepository } from './modules/scene-device/repositories';
import { SpaceRepository } from './modules/space'; import { SpaceRepository } from './modules/space';
@ -15,6 +14,7 @@ import {
SubspaceModelRepository, SubspaceModelRepository,
} from './modules/space-model'; } from './modules/space-model';
import { SubspaceRepository } from './modules/space/repositories/subspace.repository'; import { SubspaceRepository } from './modules/space/repositories/subspace.repository';
import { EmailService } from './util/email/email.service';
@Module({ @Module({
providers: [ providers: [
CommonService, CommonService,

View File

@ -10,6 +10,8 @@ export default registerAs(
SMTP_USER: process.env.SMTP_USER, SMTP_USER: process.env.SMTP_USER,
SMTP_SENDER: process.env.SMTP_SENDER, SMTP_SENDER: process.env.SMTP_SENDER,
SMTP_PASSWORD: process.env.SMTP_PASSWORD, SMTP_PASSWORD: process.env.SMTP_PASSWORD,
BATCH_EMAIL_API_URL: process.env.BATCH_EMAIL_API_URL,
SEND_EMAIL_API_URL: process.env.SEND_EMAIL_API_URL,
MAILTRAP_API_TOKEN: process.env.MAILTRAP_API_TOKEN, MAILTRAP_API_TOKEN: process.env.MAILTRAP_API_TOKEN,
MAILTRAP_INVITATION_TEMPLATE_UUID: MAILTRAP_INVITATION_TEMPLATE_UUID:
process.env.MAILTRAP_INVITATION_TEMPLATE_UUID, process.env.MAILTRAP_INVITATION_TEMPLATE_UUID,
@ -21,5 +23,9 @@ export default registerAs(
process.env.MAILTRAP_EDIT_USER_TEMPLATE_UUID, process.env.MAILTRAP_EDIT_USER_TEMPLATE_UUID,
MAILTRAP_SEND_OTP_TEMPLATE_UUID: MAILTRAP_SEND_OTP_TEMPLATE_UUID:
process.env.MAILTRAP_SEND_OTP_TEMPLATE_UUID, process.env.MAILTRAP_SEND_OTP_TEMPLATE_UUID,
MAILTRAP_SEND_BOOKING_AVAILABILITY_UPDATE_TEMPLATE_UUID:
process.env.MAILTRAP_SEND_BOOKING_AVAILABILITY_UPDATE_TEMPLATE_UUID,
MAILTRAP_SEND_BOOKING_TIMING_UPDATE_TEMPLATE_UUID:
process.env.MAILTRAP_SEND_BOOKING_TIMING_UPDATE_TEMPLATE_UUID,
}), }),
); );

View File

@ -91,6 +91,25 @@ export class ControllerRoute {
'This endpoint allows you to update existing bookable spaces by providing the required details.'; 'This endpoint allows you to update existing bookable spaces by providing the required details.';
}; };
}; };
static BOOKING = class {
public static readonly ROUTE = 'bookings';
static ACTIONS = class {
public static readonly ADD_BOOKING_SUMMARY = 'Add new booking';
public static readonly ADD_BOOKING_DESCRIPTION =
'This endpoint allows you to add new booking by providing the required details.';
public static readonly GET_ALL_BOOKINGS_SUMMARY = 'Get all bookings';
public static readonly GET_ALL_BOOKINGS_DESCRIPTION =
'This endpoint retrieves all bookings.';
public static readonly GET_MY_BOOKINGS_SUMMARY = 'Get my bookings';
public static readonly GET_MY_BOOKINGS_DESCRIPTION =
'This endpoint retrieves all bookings for the authenticated user.';
};
};
static COMMUNITY = class { static COMMUNITY = class {
public static readonly ROUTE = '/projects/:projectUuid/communities'; public static readonly ROUTE = '/projects/:projectUuid/communities';
static ACTIONS = class { static ACTIONS = class {
@ -220,6 +239,11 @@ export class ControllerRoute {
public static readonly UPDATE_SPACE_DESCRIPTION = public static readonly UPDATE_SPACE_DESCRIPTION =
'Updates a space by its UUID and community ID. You can update the name, parent space, and other properties. If a parent space is provided and not already a parent, its `isParent` flag will be set to true.'; 'Updates a space by its UUID and community ID. You can update the name, parent space, and other properties. If a parent space is provided and not already a parent, its `isParent` flag will be set to true.';
public static readonly UPDATE_CHILDREN_SPACES_ORDER_OF_A_SPACE_SUMMARY =
'Update the order of child spaces under a specific parent space';
public static readonly UPDATE_CHILDREN_SPACES_ORDER_OF_A_SPACE_DESCRIPTION =
'Updates the order of child spaces under a specific parent space. You can provide a new order for the child spaces.';
public static readonly GET_HEIRARCHY_SUMMARY = 'Get space hierarchy'; public static readonly GET_HEIRARCHY_SUMMARY = 'Get space hierarchy';
public static readonly GET_HEIRARCHY_DESCRIPTION = public static readonly GET_HEIRARCHY_DESCRIPTION =
'This endpoint retrieves the hierarchical structure of spaces under a given space ID. It returns all the child spaces nested within the specified space, organized by their parent-child relationships. '; 'This endpoint retrieves the hierarchical structure of spaces under a given space ID. It returns all the child spaces nested within the specified space, organized by their parent-child relationships. ';

View File

@ -1,3 +0,0 @@
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';

View File

@ -14,6 +14,8 @@ import { createLogger } from 'winston';
import { winstonLoggerOptions } from '../logger/services/winston.logger'; import { winstonLoggerOptions } from '../logger/services/winston.logger';
import { AqiSpaceDailyPollutantStatsEntity } from '../modules/aqi/entities'; import { AqiSpaceDailyPollutantStatsEntity } from '../modules/aqi/entities';
import { AutomationEntity } from '../modules/automation/entities'; import { AutomationEntity } from '../modules/automation/entities';
import { BookableSpaceEntity } from '../modules/booking/entities/bookable-space.entity';
import { BookingEntity } from '../modules/booking/entities/booking.entity';
import { ClientEntity } from '../modules/client/entities'; import { ClientEntity } from '../modules/client/entities';
import { CommunityEntity } from '../modules/community/entities'; import { CommunityEntity } from '../modules/community/entities';
import { DeviceStatusLogEntity } from '../modules/device-status-log/entities'; import { DeviceStatusLogEntity } from '../modules/device-status-log/entities';
@ -58,7 +60,6 @@ import {
UserSpaceEntity, UserSpaceEntity,
} from '../modules/user/entities'; } from '../modules/user/entities';
import { VisitorPasswordEntity } from '../modules/visitor-password/entities'; import { VisitorPasswordEntity } from '../modules/visitor-password/entities';
import { BookableSpaceEntity } from '../modules/booking/entities';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
@ -119,6 +120,7 @@ import { BookableSpaceEntity } from '../modules/booking/entities';
AqiSpaceDailyPollutantStatsEntity, AqiSpaceDailyPollutantStatsEntity,
SpaceDailyOccupancyDurationEntity, SpaceDailyOccupancyDurationEntity,
BookableSpaceEntity, BookableSpaceEntity,
BookingEntity,
], ],
namingStrategy: new SnakeNamingStrategy(), namingStrategy: new SnakeNamingStrategy(),
synchronize: Boolean(JSON.parse(configService.get('DB_SYNC'))), synchronize: Boolean(JSON.parse(configService.get('DB_SYNC'))),

View File

@ -1,6 +1,6 @@
import { RoleType } from '@app/common/constants/role.type.enum'; import { RoleType } from '@app/common/constants/role.type.enum';
import { UserStatusEnum } from '@app/common/constants/user-status.enum'; import { UserStatusEnum } from '@app/common/constants/user-status.enum';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class InviteUserDto { export class InviteUserDto {
@IsString() @IsString()
@ -12,8 +12,12 @@ export class InviteUserDto {
public email: string; public email: string;
@IsString() @IsString()
@IsNotEmpty() @IsOptional()
public jobTitle: string; public jobTitle?: string;
@IsString()
@IsOptional()
public companyName?: string;
@IsEnum(UserStatusEnum) @IsEnum(UserStatusEnum)
@IsNotEmpty() @IsNotEmpty()

View File

@ -37,6 +37,11 @@ export class InviteUserEntity extends AbstractEntity<InviteUserDto> {
}) })
jobTitle: string; jobTitle: string;
@Column({
nullable: true,
})
companyName: string;
@Column({ @Column({
nullable: false, nullable: false,
enum: Object.values(UserStatusEnum), enum: Object.values(UserStatusEnum),

View File

@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { BookableSpaceEntity } from './entities/bookable-space.entity'; import { BookableSpaceEntity } from './entities/bookable-space.entity';
import { BookingEntity } from './entities/booking.entity';
@Module({ @Module({
providers: [], providers: [],
exports: [], exports: [],
controllers: [], controllers: [],
imports: [TypeOrmModule.forFeature([BookableSpaceEntity])], imports: [TypeOrmModule.forFeature([BookableSpaceEntity, BookingEntity])],
}) })
export class BookableRepositoryModule {} export class BookingRepositoryModule {}

View File

@ -0,0 +1,44 @@
import {
Column,
CreateDateColumn,
Entity,
ManyToOne,
UpdateDateColumn,
} from 'typeorm';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { SpaceEntity } from '../../space/entities/space.entity';
import { UserEntity } from '../../user/entities';
@Entity('booking')
export class BookingEntity extends AbstractEntity {
@Column({
type: 'uuid',
default: () => 'gen_random_uuid()',
nullable: false,
})
public uuid: string;
@ManyToOne(() => SpaceEntity, (space) => space.bookableConfig)
space: SpaceEntity;
@ManyToOne(() => UserEntity, (user) => user.bookings)
user: UserEntity;
@Column({ type: Date, nullable: false })
date: Date;
@Column({ type: 'time' })
startTime: string;
@Column({ type: 'time' })
endTime: string;
@Column({ type: 'int', default: null })
cost?: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -1 +0,0 @@
export * from './bookable-space.entity';

View File

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

View File

@ -1,10 +1,10 @@
import { DataSource, Repository } from 'typeorm';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BookableSpaceEntity } from '../entities/bookable-space.entity'; import { DataSource, Repository } from 'typeorm';
import { BookingEntity } from '../entities/booking.entity';
@Injectable() @Injectable()
export class BookableSpaceEntityRepository extends Repository<BookableSpaceEntity> { export class BookingEntityRepository extends Repository<BookingEntity> {
constructor(private dataSource: DataSource) { constructor(private dataSource: DataSource) {
super(BookableSpaceEntity, dataSource.createEntityManager()); super(BookingEntity, dataSource.createEntityManager());
} }
} }

View File

@ -1 +0,0 @@
export * from './booking.repository';

View File

@ -1,3 +0,0 @@
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
export class SpaceLinkEntity extends AbstractEntity {}

View File

@ -6,9 +6,9 @@ import {
OneToMany, OneToMany,
OneToOne, OneToOne,
} from 'typeorm'; } from 'typeorm';
import { SpaceDto } from '../dtos';
import { AbstractEntity } from '../../abstract/entities/abstract.entity'; import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { AqiSpaceDailyPollutantStatsEntity } from '../../aqi/entities'; import { AqiSpaceDailyPollutantStatsEntity } from '../../aqi/entities';
import { BookableSpaceEntity } from '../../booking/entities/bookable-space.entity';
import { CommunityEntity } from '../../community/entities'; import { CommunityEntity } from '../../community/entities';
import { DeviceEntity } from '../../device/entities'; import { DeviceEntity } from '../../device/entities';
import { InviteUserSpaceEntity } from '../../Invite-user/entities'; import { InviteUserSpaceEntity } from '../../Invite-user/entities';
@ -17,9 +17,10 @@ import { PresenceSensorDailySpaceEntity } from '../../presence-sensor/entities';
import { SceneEntity } from '../../scene/entities'; import { SceneEntity } from '../../scene/entities';
import { SpaceModelEntity } from '../../space-model'; import { SpaceModelEntity } from '../../space-model';
import { UserSpaceEntity } from '../../user/entities'; import { UserSpaceEntity } from '../../user/entities';
import { SpaceDto } from '../dtos';
import { SpaceProductAllocationEntity } from './space-product-allocation.entity'; import { SpaceProductAllocationEntity } from './space-product-allocation.entity';
import { SubspaceEntity } from './subspace/subspace.entity'; import { SubspaceEntity } from './subspace/subspace.entity';
import { BookableSpaceEntity } from '../../booking/entities'; import { BookingEntity } from '../../booking/entities/booking.entity';
@Entity({ name: 'space' }) @Entity({ name: 'space' })
export class SpaceEntity extends AbstractEntity<SpaceDto> { export class SpaceEntity extends AbstractEntity<SpaceDto> {
@ -64,6 +65,12 @@ export class SpaceEntity extends AbstractEntity<SpaceDto> {
}) })
public disabled: boolean; public disabled: boolean;
@Column({
nullable: true,
type: Number,
})
public order?: number;
@OneToMany(() => SubspaceEntity, (subspace) => subspace.space, { @OneToMany(() => SubspaceEntity, (subspace) => subspace.space, {
nullable: true, nullable: true,
}) })
@ -126,6 +133,9 @@ export class SpaceEntity extends AbstractEntity<SpaceDto> {
@OneToOne(() => BookableSpaceEntity, (bookable) => bookable.space) @OneToOne(() => BookableSpaceEntity, (bookable) => bookable.space)
bookableConfig: BookableSpaceEntity; bookableConfig: BookableSpaceEntity;
@OneToMany(() => BookingEntity, (booking) => booking.space)
bookings: BookingEntity[];
constructor(partial: Partial<SpaceEntity>) { constructor(partial: Partial<SpaceEntity>) {
super(); super();
Object.assign(this, partial); Object.assign(this, partial);

View File

@ -11,9 +11,6 @@ export class SpaceRepository extends Repository<SpaceEntity> {
} }
} }
@Injectable()
export class SpaceLinkRepository {}
@Injectable() @Injectable()
export class InviteSpaceRepository extends Repository<InviteSpaceEntity> { export class InviteSpaceRepository extends Repository<InviteSpaceEntity> {
constructor(private dataSource: DataSource) { constructor(private dataSource: DataSource) {

View File

@ -29,6 +29,7 @@ import {
UserOtpDto, UserOtpDto,
UserSpaceDto, UserSpaceDto,
} from '../dtos'; } from '../dtos';
import { BookingEntity } from '../../booking/entities/booking.entity';
@Entity({ name: 'user' }) @Entity({ name: 'user' })
export class UserEntity extends AbstractEntity<UserDto> { export class UserEntity extends AbstractEntity<UserDto> {
@ -82,6 +83,12 @@ export class UserEntity extends AbstractEntity<UserDto> {
}) })
public isActive: boolean; public isActive: boolean;
@Column({
nullable: true,
type: Number,
})
public bookingPoints?: number;
@Column({ default: false }) @Column({ default: false })
hasAcceptedWebAgreement: boolean; hasAcceptedWebAgreement: boolean;
@ -115,6 +122,9 @@ export class UserEntity extends AbstractEntity<UserDto> {
) )
deviceUserNotification: DeviceNotificationEntity[]; deviceUserNotification: DeviceNotificationEntity[];
@OneToMany(() => BookingEntity, (booking) => booking.user)
bookings: BookingEntity[];
@ManyToOne(() => RegionEntity, (region) => region.users, { nullable: true }) @ManyToOne(() => RegionEntity, (region) => region.users, { nullable: true })
region: RegionEntity; region: RegionEntity;
@ManyToOne(() => TimeZoneEntity, (timezone) => timezone.users, { @ManyToOne(() => TimeZoneEntity, (timezone) => timezone.users, {

View File

@ -0,0 +1,8 @@
export interface BatchEmailData {
base: { from: { email: string }; template_uuid: string };
requests: Array<{
to: { email: string }[];
template_variables: Record<string, any>;
}>;
isBatch: true;
}

View File

@ -1,15 +1,17 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import axios from 'axios'; import axios from 'axios';
import * as nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { import Mail from 'nodemailer/lib/mailer';
SEND_EMAIL_API_URL_DEV, import { BatchEmailData } from './batch-email.interface';
SEND_EMAIL_API_URL_PROD, import { SingleEmailData } from './single-email.interface';
} from '../constants/mail-trap';
@Injectable() @Injectable()
export class EmailService { export class EmailService {
private smtpConfig: any; private smtpConfig: any;
private API_TOKEN: string;
private SEND_EMAIL_API_URL: string;
private BATCH_EMAIL_API_URL: string;
constructor(private readonly configService: ConfigService) { constructor(private readonly configService: ConfigService) {
this.smtpConfig = { this.smtpConfig = {
@ -22,6 +24,15 @@ export class EmailService {
pass: this.configService.get<string>('email-config.SMTP_PASSWORD'), pass: this.configService.get<string>('email-config.SMTP_PASSWORD'),
}, },
}; };
this.API_TOKEN = this.configService.get<string>(
'email-config.MAILTRAP_API_TOKEN',
);
this.SEND_EMAIL_API_URL = this.configService.get<string>(
'email-config.SEND_EMAIL_API_URL',
);
this.BATCH_EMAIL_API_URL = this.configService.get<string>(
'email-config.BATCH_EMAIL_API_URL',
);
} }
async sendEmail( async sendEmail(
@ -31,7 +42,7 @@ export class EmailService {
): Promise<void> { ): Promise<void> {
const transporter = nodemailer.createTransport(this.smtpConfig); const transporter = nodemailer.createTransport(this.smtpConfig);
const mailOptions = { const mailOptions: Mail.Options = {
from: this.smtpConfig.sender, from: this.smtpConfig.sender,
to: email, to: email,
subject, subject,
@ -44,13 +55,6 @@ export class EmailService {
email: string, email: string,
emailInvitationData: any, emailInvitationData: any,
): Promise<void> { ): Promise<void> {
const isProduction = process.env.NODE_ENV === 'production';
const API_TOKEN = this.configService.get<string>(
'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<string>( const TEMPLATE_UUID = this.configService.get<string>(
'email-config.MAILTRAP_INVITATION_TEMPLATE_UUID', 'email-config.MAILTRAP_INVITATION_TEMPLATE_UUID',
); );
@ -68,21 +72,12 @@ export class EmailService {
template_variables: emailInvitationData, template_variables: emailInvitationData,
}; };
try { return this.sendEmailWithTemplateV2({
await axios.post(API_URL, emailData, { ...emailData,
headers: { isBatch: false,
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({ async sendEmailWithTemplate({
email, email,
name, name,
@ -94,14 +89,6 @@ export class EmailService {
isEnable: boolean; isEnable: boolean;
isDelete: boolean; isDelete: boolean;
}): Promise<void> { }): Promise<void> {
const isProduction = process.env.NODE_ENV === 'production';
const API_TOKEN = this.configService.get<string>(
'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 // Determine the template UUID based on the arguments
const templateUuid = isDelete const templateUuid = isDelete
? this.configService.get<string>( ? this.configService.get<string>(
@ -128,32 +115,16 @@ export class EmailService {
}, },
}; };
try { return this.sendEmailWithTemplateV2({
await axios.post(API_URL, emailData, { ...emailData,
headers: { isBatch: false,
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( async sendEditUserEmailWithTemplate(
email: string, email: string,
emailEditData: any, emailEditData: any,
): Promise<void> { ): Promise<void> {
const isProduction = process.env.NODE_ENV === 'production';
const API_TOKEN = this.configService.get<string>(
'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<string>( const TEMPLATE_UUID = this.configService.get<string>(
'email-config.MAILTRAP_EDIT_USER_TEMPLATE_UUID', 'email-config.MAILTRAP_EDIT_USER_TEMPLATE_UUID',
); );
@ -171,32 +142,15 @@ export class EmailService {
template_variables: emailEditData, template_variables: emailEditData,
}; };
try { return this.sendEmailWithTemplateV2({
await axios.post(API_URL, emailData, { ...emailData,
headers: { isBatch: false,
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 sendOtpEmailWithTemplate( async sendOtpEmailWithTemplate(
email: string, email: string,
emailEditData: any, emailEditData: any,
): Promise<void> { ): Promise<void> {
const isProduction = process.env.NODE_ENV === 'production';
const API_TOKEN = this.configService.get<string>(
'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<string>( const TEMPLATE_UUID = this.configService.get<string>(
'email-config.MAILTRAP_SEND_OTP_TEMPLATE_UUID', 'email-config.MAILTRAP_SEND_OTP_TEMPLATE_UUID',
); );
@ -214,20 +168,84 @@ export class EmailService {
template_variables: emailEditData, template_variables: emailEditData,
}; };
try { return this.sendEmailWithTemplateV2({
await axios.post(API_URL, emailData, { ...emailData,
headers: { isBatch: false,
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 sendUpdateBookingTimingEmailWithTemplate(
emails: {
email: string;
name: string;
bookings: {
date: string;
start_time: string;
end_time: string;
}[];
}[],
emailVariables: {
space_name: string;
days: string;
start_time: string;
end_time: string;
},
): Promise<void> {
const TEMPLATE_UUID = this.configService.get<string>(
'email-config.MAILTRAP_SEND_BOOKING_TIMING_UPDATE_TEMPLATE_UUID',
);
const emailData = {
base: {
from: {
email: this.smtpConfig.sender,
},
template_uuid: TEMPLATE_UUID,
},
requests: emails.map(({ email, name, bookings }) => ({
to: [{ email }],
template_variables: { ...emailVariables, name, bookings },
})),
};
return this.sendEmailWithTemplateV2({
...emailData,
isBatch: true,
});
}
async sendUpdateBookingAvailabilityEmailWithTemplate(
emails: { email: string; name: string }[],
emailVariables: {
space_name: string;
availability: string;
isAvailable: boolean;
},
): Promise<void> {
const TEMPLATE_UUID = this.configService.get<string>(
'email-config.MAILTRAP_SEND_BOOKING_AVAILABILITY_UPDATE_TEMPLATE_UUID',
);
const emailData = {
base: {
from: {
email: this.smtpConfig.sender,
},
template_uuid: TEMPLATE_UUID,
},
requests: emails.map(({ email, name }) => ({
to: [{ email }],
template_variables: {
...emailVariables,
name,
},
})),
};
return this.sendEmailWithTemplateV2({
...emailData,
isBatch: true,
});
} }
generateUserChangesEmailBody( generateUserChangesEmailBody(
addedSpaceNames: string[], addedSpaceNames: string[],
@ -264,4 +282,30 @@ export class EmailService {
nameChanged, nameChanged,
}; };
} }
private async sendEmailWithTemplateV2({
isBatch,
...emailData
}: BatchEmailData | SingleEmailData): Promise<void> {
try {
await axios.post(
isBatch ? this.BATCH_EMAIL_API_URL : this.SEND_EMAIL_API_URL,
{
...emailData,
},
{
headers: {
Authorization: `Bearer ${this.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,
);
}
}
} }

View File

@ -0,0 +1,7 @@
export interface SingleEmailData {
from: { email: string };
to: { email: string }[];
template_uuid: string;
template_variables?: Record<string, any>;
isBatch: false;
}

View File

@ -0,0 +1,7 @@
import { format, parse } from 'date-fns';
export function to12HourFormat(timeString: string): string {
timeString = timeString.padEnd(8, ':00');
const parsedTime = parse(timeString, 'HH:mm:ss', new Date());
return format(parsedTime, 'hh:mm a');
}

52
package-lock.json generated
View File

@ -32,6 +32,7 @@
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"csv-parser": "^3.2.0", "csv-parser": "^3.2.0",
"date-fns": "^4.1.0",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"firebase": "^10.12.5", "firebase": "^10.12.5",
"google-auth-library": "^9.14.1", "google-auth-library": "^9.14.1",
@ -40,7 +41,7 @@
"morgan": "^1.10.0", "morgan": "^1.10.0",
"nest-winston": "^1.10.2", "nest-winston": "^1.10.2",
"node-cache": "^5.1.2", "node-cache": "^5.1.2",
"nodemailer": "^6.9.10", "nodemailer": "^7.0.5",
"onesignal-node": "^3.4.0", "onesignal-node": "^3.4.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.11.3", "pg": "^8.11.3",
@ -60,6 +61,7 @@
"@types/jest": "^29.5.2", "@types/jest": "^29.5.2",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@types/nodemailer": "^6.4.17",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^6.0.0",
@ -3366,6 +3368,15 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "6.4.17",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz",
"integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@ -5115,6 +5126,22 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1" "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
} }
}, },
"node_modules/concurrently/node_modules/date-fns": {
"version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"dev": true,
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/concurrently/node_modules/supports-color": { "node_modules/concurrently/node_modules/supports-color": {
"version": "8.1.1", "version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@ -5360,19 +5387,12 @@
} }
}, },
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "2.30.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"dev": true,
"dependencies": {
"@babel/runtime": "^7.21.0"
},
"engines": {
"node": ">=0.11"
},
"funding": { "funding": {
"type": "opencollective", "type": "github",
"url": "https://opencollective.com/date-fns" "url": "https://github.com/sponsors/kossnocorp"
} }
}, },
"node_modules/dayjs": { "node_modules/dayjs": {
@ -9784,9 +9804,9 @@
"dev": true "dev": true
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "6.10.1", "version": "7.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", "integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
} }

View File

@ -44,6 +44,7 @@
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"csv-parser": "^3.2.0", "csv-parser": "^3.2.0",
"date-fns": "^4.1.0",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"firebase": "^10.12.5", "firebase": "^10.12.5",
"google-auth-library": "^9.14.1", "google-auth-library": "^9.14.1",
@ -52,7 +53,7 @@
"morgan": "^1.10.0", "morgan": "^1.10.0",
"nest-winston": "^1.10.2", "nest-winston": "^1.10.2",
"node-cache": "^5.1.2", "node-cache": "^5.1.2",
"nodemailer": "^6.9.10", "nodemailer": "^7.0.5",
"onesignal-node": "^3.4.0", "onesignal-node": "^3.4.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.11.3", "pg": "^8.11.3",
@ -72,6 +73,7 @@
"@types/jest": "^29.5.2", "@types/jest": "^29.5.2",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@types/nodemailer": "^6.4.17",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^6.0.0",

View File

@ -1,15 +1,17 @@
import { AuthService } from '@app/common/auth/services/auth.service';
import { RoleTypeRepository } from '@app/common/modules/role-type/repositories';
import { UserSessionRepository } from '@app/common/modules/session/repositories/session.repository';
import {
UserOtpRepository,
UserRepository,
} from '@app/common/modules/user/repositories';
import { EmailService } from '@app/common/util/email/email.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { UserRepository } from '@app/common/modules/user/repositories'; import { JwtService } from '@nestjs/jwt';
import { UserSessionRepository } from '@app/common/modules/session/repositories/session.repository';
import { UserOtpRepository } from '@app/common/modules/user/repositories';
import { RoleTypeRepository } from '@app/common/modules/role-type/repositories';
import { RoleService } from 'src/role/services'; import { RoleService } from 'src/role/services';
import { UserAuthController } from './controllers'; import { UserAuthController } from './controllers';
import { UserAuthService } from './services'; import { UserAuthService } from './services';
import { AuthService } from '@app/common/auth/services/auth.service';
import { EmailService } from '@app/common/util/email.service';
import { JwtService } from '@nestjs/jwt';
@Module({ @Module({
imports: [ConfigModule], imports: [ConfigModule],

View File

@ -1,25 +1,25 @@
import { UserRepository } from '../../../libs/common/src/modules/user/repositories'; import { RoleType } from '@app/common/constants/role.type.enum';
import { differenceInSeconds } from '@app/common/helper/differenceInSeconds';
import { import {
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
} from '@nestjs/common'; } from '@nestjs/common';
import { UserSignUpDto } from '../dtos/user-auth.dto';
import { HelperHashService } from '../../../libs/common/src/helper/services';
import { UserLoginDto } from '../dtos/user-login.dto';
import { AuthService } from '../../../libs/common/src/auth/services/auth.service';
import { UserSessionRepository } from '../../../libs/common/src/modules/session/repositories/session.repository';
import { UserOtpRepository } from '../../../libs/common/src/modules/user/repositories/user.repository';
import { ForgetPasswordDto, UserOtpDto, VerifyOtpDto } from '../dtos';
import { EmailService } from '../../../libs/common/src/util/email.service';
import { OtpType } from '../../../libs/common/src/constants/otp-type.enum';
import { UserEntity } from '../../../libs/common/src/modules/user/entities/user.entity';
import * as argon2 from 'argon2';
import { differenceInSeconds } from '@app/common/helper/differenceInSeconds';
import { LessThan, MoreThan } from 'typeorm';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
import { RoleService } from 'src/role/services'; import { RoleService } from 'src/role/services';
import { RoleType } from '@app/common/constants/role.type.enum'; import { LessThan, MoreThan } from 'typeorm';
import { AuthService } from '../../../libs/common/src/auth/services/auth.service';
import { OtpType } from '../../../libs/common/src/constants/otp-type.enum';
import { HelperHashService } from '../../../libs/common/src/helper/services';
import { UserSessionRepository } from '../../../libs/common/src/modules/session/repositories/session.repository';
import { UserEntity } from '../../../libs/common/src/modules/user/entities/user.entity';
import { UserRepository } from '../../../libs/common/src/modules/user/repositories';
import { UserOtpRepository } from '../../../libs/common/src/modules/user/repositories/user.repository';
import { EmailService } from '../../../libs/common/src/util/email/email.service';
import { ForgetPasswordDto, UserOtpDto, VerifyOtpDto } from '../dtos';
import { UserSignUpDto } from '../dtos/user-auth.dto';
import { UserLoginDto } from '../dtos/user-login.dto';
@Injectable() @Injectable()
export class UserAuthService { export class UserAuthService {
@ -108,7 +108,7 @@ export class UserAuthService {
async userLogin(data: UserLoginDto) { async userLogin(data: UserLoginDto) {
try { try {
let user; let user: Omit<UserEntity, 'password'>;
if (data.googleCode) { if (data.googleCode) {
const googleUserData = await this.authService.login({ const googleUserData = await this.authService.login({
googleCode: data.googleCode, googleCode: data.googleCode,
@ -145,7 +145,7 @@ export class UserAuthService {
} }
const session = await Promise.all([ const session = await Promise.all([
await this.sessionRepository.update( await this.sessionRepository.update(
{ userId: user.id }, { userId: user?.['id'] },
{ {
isLoggedOut: true, isLoggedOut: true,
}, },
@ -166,6 +166,7 @@ export class UserAuthService {
hasAcceptedAppAgreement: user.hasAcceptedAppAgreement, hasAcceptedAppAgreement: user.hasAcceptedAppAgreement,
project: user.project, project: user.project,
sessionId: session[1].uuid, sessionId: session[1].uuid,
bookingPoints: user.bookingPoints,
}); });
return res; return res;
} catch (error) { } catch (error) {
@ -347,6 +348,7 @@ export class UserAuthService {
userId: user.uuid, userId: user.uuid,
uuid: user.uuid, uuid: user.uuid,
type, type,
bookingPoints: user.bookingPoints,
sessionId, sessionId,
}); });
await this.authService.updateRefreshToken(user.uuid, tokens.refreshToken); await this.authService.updateRefreshToken(user.uuid, tokens.refreshToken);

View File

@ -1,17 +1,29 @@
import { Global, Module } from '@nestjs/common'; import { BookingRepositoryModule } from '@app/common/modules/booking/booking.repository.module';
import { BookableSpaceController } from './controllers'; import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories/bookable-space.repository';
import { BookableSpaceService } from './services'; import { BookingEntityRepository } from '@app/common/modules/booking/repositories/booking.repository';
import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories';
import { SpaceRepository } from '@app/common/modules/space'; import { SpaceRepository } from '@app/common/modules/space';
import { UserRepository } from '@app/common/modules/user/repositories';
import { EmailService } from '@app/common/util/email/email.service';
import { Global, Module } from '@nestjs/common';
import { BookableSpaceController } from './controllers/bookable-space.controller';
import { BookingController } from './controllers/booking.controller';
import { BookableSpaceService } from './services/bookable-space.service';
import { BookingService } from './services/booking.service';
@Global() @Global()
@Module({ @Module({
controllers: [BookableSpaceController], imports: [BookingRepositoryModule],
controllers: [BookableSpaceController, BookingController],
providers: [ providers: [
BookableSpaceService, BookableSpaceService,
BookingService,
EmailService,
BookableSpaceEntityRepository, BookableSpaceEntityRepository,
BookingEntityRepository,
SpaceRepository, SpaceRepository,
UserRepository,
], ],
exports: [BookableSpaceService], exports: [BookableSpaceService, BookingService],
}) })
export class BookingModule {} export class BookingModule {}

View File

@ -7,6 +7,7 @@ import {
Controller, Controller,
Get, Get,
Param, Param,
ParseUUIDPipe,
Post, Post,
Put, Put,
Query, Query,
@ -18,11 +19,11 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { PageResponse } from '@app/common/dto/pagination.response.dto'; import { PageResponse } from '@app/common/dto/pagination.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { CreateBookableSpaceDto } from '../dtos';
import { BookableSpaceRequestDto } from '../dtos/bookable-space-request.dto'; import { BookableSpaceRequestDto } from '../dtos/bookable-space-request.dto';
import { BookableSpaceResponseDto } from '../dtos/bookable-space-response.dto'; import { BookableSpaceResponseDto } from '../dtos/bookable-space-response.dto';
import { CreateBookableSpaceDto } from '../dtos/create-bookable-space.dto';
import { UpdateBookableSpaceDto } from '../dtos/update-bookable-space.dto'; import { UpdateBookableSpaceDto } from '../dtos/update-bookable-space.dto';
import { BookableSpaceService } from '../services'; import { BookableSpaceService } from '../services/bookable-space.service';
@ApiTags('Booking Module') @ApiTags('Booking Module')
@Controller({ @Controller({
@ -94,7 +95,7 @@ export class BookableSpaceController {
.UPDATE_BOOKABLE_SPACES_DESCRIPTION, .UPDATE_BOOKABLE_SPACES_DESCRIPTION,
}) })
async update( async update(
@Param('spaceUuid') spaceUuid: string, @Param('spaceUuid', ParseUUIDPipe) spaceUuid: string,
@Body() dto: UpdateBookableSpaceDto, @Body() dto: UpdateBookableSpaceDto,
): Promise<BaseResponseDto> { ): Promise<BaseResponseDto> {
const result = await this.bookableSpaceService.update(spaceUuid, dto); const result = await this.bookableSpaceService.update(spaceUuid, dto);

View File

@ -0,0 +1,107 @@
import { ControllerRoute } from '@app/common/constants/controller-route';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import {
Body,
Controller,
Get,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { plainToInstance } from 'class-transformer';
import { AdminRoleGuard } from 'src/guards/admin.role.guard';
import { BookingRequestDto } from '../dtos/booking-request.dto';
import { BookingResponseDto } from '../dtos/booking-response.dto';
import { CreateBookingDto } from '../dtos/create-booking.dto';
import { MyBookingRequestDto } from '../dtos/my-booking-request.dto';
import { BookingService } from '../services/booking.service';
@ApiTags('Booking Module')
@Controller({
version: EnableDisableStatusEnum.ENABLED,
path: ControllerRoute.BOOKING.ROUTE,
})
export class BookingController {
constructor(private readonly bookingService: BookingService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post()
@ApiOperation({
summary: ControllerRoute.BOOKING.ACTIONS.ADD_BOOKING_SUMMARY,
description: ControllerRoute.BOOKING.ACTIONS.ADD_BOOKING_DESCRIPTION,
})
async create(
@Body() dto: CreateBookingDto,
@Req() req: Request,
): Promise<BaseResponseDto> {
const userUuid = req['user']?.uuid;
if (!userUuid) {
throw new Error('User UUID is required in the request');
}
const result = await this.bookingService.create(userUuid, dto);
return new SuccessResponseDto({
data: result,
message: 'Successfully created booking',
});
}
@ApiBearerAuth()
@UseGuards(AdminRoleGuard)
@Get()
@ApiOperation({
summary: ControllerRoute.BOOKING.ACTIONS.GET_ALL_BOOKINGS_SUMMARY,
description: ControllerRoute.BOOKING.ACTIONS.GET_ALL_BOOKINGS_DESCRIPTION,
})
async findAll(
@Query() query: BookingRequestDto,
@Req() req: Request,
): Promise<BaseResponseDto> {
const project = req['user']?.project?.uuid;
if (!project) {
throw new Error('Project UUID is required in the request');
}
const result = await this.bookingService.findAll(query, project);
return new SuccessResponseDto({
data: plainToInstance(BookingResponseDto, result, {
excludeExtraneousValues: true,
}),
message: 'Successfully fetched all bookings',
});
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('my-bookings')
@ApiOperation({
summary: ControllerRoute.BOOKING.ACTIONS.GET_MY_BOOKINGS_SUMMARY,
description: ControllerRoute.BOOKING.ACTIONS.GET_MY_BOOKINGS_DESCRIPTION,
})
async findMyBookings(
@Query() query: MyBookingRequestDto,
@Req() req: Request,
): Promise<BaseResponseDto> {
const userUuid = req['user']?.uuid;
const project = req['user']?.project?.uuid;
if (!project) {
throw new Error('Project UUID is required in the request');
}
const result = await this.bookingService.findMyBookings(
query,
userUuid,
project,
);
return new SuccessResponseDto({
data: plainToInstance(BookingResponseDto, result, {
excludeExtraneousValues: true,
}),
message: 'Successfully fetched all bookings',
});
}
}

View File

@ -1 +0,0 @@
export * from './bookable-space.controller';

View File

@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, Matches } from 'class-validator';
export class BookingRequestDto {
@ApiProperty({
description: 'Month in MM/YYYY format',
example: '07/2025',
})
@IsNotEmpty()
@Matches(/^(0[1-9]|1[0-2])\/\d{4}$/, {
message: 'Date must be in MM/YYYY format',
})
month: string;
}

View File

@ -0,0 +1,88 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Transform, Type } from 'class-transformer';
export class BookingUserResponseDto {
@ApiProperty()
@Expose()
uuid: string;
@ApiProperty()
@Expose()
firstName: string;
@ApiProperty()
@Expose()
lastName: string;
@ApiProperty({
type: String,
nullable: true,
})
@Expose()
email: string;
@ApiProperty({
type: String,
nullable: true,
})
@Expose()
@Transform(({ obj }) => obj.inviteUser?.companyName || null)
companyName: string;
@ApiProperty({
type: String,
nullable: true,
})
@Expose()
phoneNumber: string;
}
export class BookingSpaceResponseDto {
@ApiProperty()
@Expose()
uuid: string;
@ApiProperty()
@Expose()
spaceName: string;
}
export class BookingResponseDto {
@ApiProperty()
@Expose()
uuid: string;
@ApiProperty({
type: Date,
})
@Expose()
date: Date;
@ApiProperty()
@Expose()
startTime: string;
@ApiProperty()
@Expose()
endTime: string;
@ApiProperty({
type: Number,
})
@Expose()
cost: number;
@ApiProperty({
type: BookingUserResponseDto,
})
@Type(() => BookingUserResponseDto)
@Expose()
user: BookingUserResponseDto;
@ApiProperty({
type: BookingSpaceResponseDto,
})
@Type(() => BookingSpaceResponseDto)
@Expose()
space: BookingSpaceResponseDto;
}

View File

@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDate, IsNotEmpty, IsString, IsUUID, Matches } from 'class-validator';
export class CreateBookingDto {
@ApiProperty({
type: 'string',
example: '4fa85f64-5717-4562-b3fc-2c963f66afa7',
})
@IsNotEmpty()
@IsUUID('4', { message: 'Invalid space UUID provided' })
spaceUuid: string;
@ApiProperty({
type: Date,
})
@IsNotEmpty()
@IsDate()
date: Date;
@ApiProperty({ example: '09:00' })
@IsString()
@IsNotEmpty({ message: 'Start time cannot be empty' })
@Matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, {
message: 'Start time must be in HH:mm format (24-hour)',
})
startTime: string;
@ApiProperty({ example: '17:00' })
@IsString()
@IsNotEmpty({ message: 'End time cannot be empty' })
@Matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, {
message: 'End time must be in HH:mm format (24-hour)',
})
endTime: string;
}

View File

@ -1 +0,0 @@
export * from './create-bookable-space.dto';

View File

@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
export class MyBookingRequestDto {
@ApiProperty({
description: 'Filter bookings by time period',
example: 'past',
enum: ['past', 'future'],
required: false,
})
@IsOptional()
@IsIn(['past', 'future'])
when?: 'past' | 'future';
}

View File

@ -2,24 +2,30 @@ import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { PageResponseDto } from '@app/common/dto/pagination.response.dto'; import { PageResponseDto } from '@app/common/dto/pagination.response.dto';
import { timeToMinutes } from '@app/common/helper/timeToMinutes'; import { timeToMinutes } from '@app/common/helper/timeToMinutes';
import { TypeORMCustomModel } from '@app/common/models/typeOrmCustom.model'; import { TypeORMCustomModel } from '@app/common/models/typeOrmCustom.model';
import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories'; import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories/bookable-space.repository';
import { BookingEntityRepository } from '@app/common/modules/booking/repositories/booking.repository';
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity'; import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
import { SpaceRepository } from '@app/common/modules/space/repositories/space.repository'; import { SpaceRepository } from '@app/common/modules/space/repositories/space.repository';
import { EmailService } from '@app/common/util/email/email.service';
import { to12HourFormat } from '@app/common/util/time-to-12-hours-convetion';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { In } from 'typeorm'; import { format } from 'date-fns';
import { CreateBookableSpaceDto } from '../dtos'; import { Brackets, In } from 'typeorm';
import { BookableSpaceRequestDto } from '../dtos/bookable-space-request.dto'; import { BookableSpaceRequestDto } from '../dtos/bookable-space-request.dto';
import { CreateBookableSpaceDto } from '../dtos/create-bookable-space.dto';
import { UpdateBookableSpaceDto } from '../dtos/update-bookable-space.dto'; import { UpdateBookableSpaceDto } from '../dtos/update-bookable-space.dto';
@Injectable() @Injectable()
export class BookableSpaceService { export class BookableSpaceService {
constructor( constructor(
private readonly emailService: EmailService,
private readonly bookableSpaceEntityRepository: BookableSpaceEntityRepository, private readonly bookableSpaceEntityRepository: BookableSpaceEntityRepository,
private readonly bookingEntityRepository: BookingEntityRepository,
private readonly spaceRepository: SpaceRepository, private readonly spaceRepository: SpaceRepository,
) {} ) {}
@ -52,7 +58,7 @@ export class BookableSpaceService {
if (search) { if (search) {
qb = qb.andWhere( qb = qb.andWhere(
'space.spaceName ILIKE :search OR community.name ILIKE :search OR parentSpace.spaceName ILIKE :search', '(space.spaceName ILIKE :search OR community.name ILIKE :search OR parentSpace.spaceName ILIKE :search)',
{ search: `%${search}%` }, { search: `%${search}%` },
); );
} }
@ -68,7 +74,6 @@ export class BookableSpaceService {
.leftJoinAndSelect('space.bookableConfig', 'bookableConfig') .leftJoinAndSelect('space.bookableConfig', 'bookableConfig')
.andWhere('bookableConfig.uuid IS NULL'); .andWhere('bookableConfig.uuid IS NULL');
} }
const customModel = TypeORMCustomModel(this.spaceRepository); const customModel = TypeORMCustomModel(this.spaceRepository);
const { baseResponseDto, paginationResponseDto } = const { baseResponseDto, paginationResponseDto } =
@ -85,8 +90,7 @@ export class BookableSpaceService {
} }
/** /**
* todo: if updating availability, send to the ones who have access to this space * Update bookable space configuration
* todo: if updating other fields, just send emails to all users who's bookings might be affected
*/ */
async update(spaceUuid: string, dto: UpdateBookableSpaceDto) { async update(spaceUuid: string, dto: UpdateBookableSpaceDto) {
// fetch spaces exist // fetch spaces exist
@ -103,11 +107,179 @@ export class BookableSpaceService {
dto.startTime || space.bookableConfig.startTime, dto.startTime || space.bookableConfig.startTime,
dto.endTime || space.bookableConfig.endTime, dto.endTime || space.bookableConfig.endTime,
); );
if (
dto.startTime != space.bookableConfig.startTime ||
dto.endTime != space.bookableConfig.endTime ||
dto.daysAvailable != space.bookableConfig.daysAvailable
) {
this.handleTimingUpdate(
{
daysAvailable:
dto.daysAvailable || space.bookableConfig.daysAvailable,
startTime: dto.startTime || space.bookableConfig.startTime,
endTime: dto.endTime || space.bookableConfig.endTime,
},
space,
);
} }
}
if (
dto.active !== undefined &&
dto.active !== space.bookableConfig.active
) {
this.handleAvailabilityUpdate(dto.active, space);
}
Object.assign(space.bookableConfig, dto); Object.assign(space.bookableConfig, dto);
return this.bookableSpaceEntityRepository.save(space.bookableConfig); return this.bookableSpaceEntityRepository.save(space.bookableConfig);
} }
private async handleTimingUpdate(
dto: UpdateBookableSpaceDto,
space: SpaceEntity,
): Promise<void> {
const affectedUsers = await this.getAffectedBookings(space.uuid);
if (!affectedUsers.length) return;
const groupedParams = this.groupBookingsByUser(affectedUsers);
return this.emailService.sendUpdateBookingTimingEmailWithTemplate(
groupedParams,
{
space_name: space.spaceName,
start_time: to12HourFormat(dto.startTime),
end_time: to12HourFormat(dto.endTime),
days: dto.daysAvailable.join(', '),
},
);
}
private async getAffectedBookings(spaceUuid: string) {
const today = new Date();
const nowTime = format(today, 'HH:mm');
const bookingWithDayCte = this.bookingEntityRepository
.createQueryBuilder('b')
.select('b.*')
.addSelect(
`
CASE EXTRACT(DOW FROM b.date)
WHEN 0 THEN 'Sun'
WHEN 1 THEN 'Mon'
WHEN 2 THEN 'Tue'
WHEN 3 THEN 'Wed'
WHEN 4 THEN 'Thu'
WHEN 5 THEN 'Fri'
WHEN 6 THEN 'Sat'
END::"bookable-space_days_available_enum"
`,
'booking_day',
)
.where(
`(DATE(b.date) > :today OR (DATE(b.date) = :today AND b.startTime >= :nowTime))`,
{ today, nowTime },
)
.andWhere('b.space_uuid = :spaceUuid', { spaceUuid });
const query = this.bookableSpaceEntityRepository
.createQueryBuilder('bs')
.distinct(true)
.addCommonTableExpression(bookingWithDayCte, 'booking_with_day')
.select('u.first_name', 'name')
.addSelect('u.email', 'email')
.addSelect('DATE(bwd.date)', 'date')
.addSelect('bwd.start_time', 'start_time')
.addSelect('bwd.end_time', 'end_time')
.from('booking_with_day', 'bwd')
.innerJoin('user', 'u', 'u.uuid = bwd.user_uuid')
.where('bs.space_uuid = :spaceUuid', { spaceUuid })
.andWhere(
new Brackets((qb) => {
qb.where('NOT (bwd.booking_day = ANY(bs.days_available))')
.orWhere('bwd.start_time < bs.start_time')
.orWhere('bwd.end_time > bs.end_time');
}),
);
return query.getRawMany<{
name: string;
email: string;
date: string;
start_time: string;
end_time: string;
}>();
}
private groupBookingsByUser(
bookings: {
name: string;
email: string;
date: string;
start_time: string;
end_time: string;
}[],
): {
name: string;
email: string;
bookings: { date: string; start_time: string; end_time: string }[];
}[] {
const grouped: Record<
string,
{
name: string;
email: string;
bookings: { date: string; start_time: string; end_time: string }[];
}
> = {};
for (const { name, email, date, start_time, end_time } of bookings) {
const formattedDate = format(new Date(date), 'yyyy-MM-dd');
const formattedStartTime = to12HourFormat(start_time);
const formattedEndTime = to12HourFormat(end_time);
if (!grouped[email]) {
grouped[email] = {
name,
email,
bookings: [],
};
}
grouped[email].bookings.push({
date: formattedDate,
start_time: formattedStartTime,
end_time: formattedEndTime,
});
}
return Object.values(grouped);
}
private async handleAvailabilityUpdate(
active: boolean,
space: SpaceEntity,
): Promise<void> {
space = await this.spaceRepository.findOne({
where: { uuid: space.uuid },
relations: ['userSpaces', 'userSpaces.user'],
});
const emails = space.userSpaces.map((userSpace) => ({
email: userSpace.user.email,
name: userSpace.user.firstName,
}));
if (!emails.length) return Promise.resolve();
return this.emailService.sendUpdateBookingAvailabilityEmailWithTemplate(
emails,
{
availability: active ? 'Available' : 'Unavailable',
space_name: space.spaceName,
isAvailable: active,
},
);
}
/** /**
* Fetch spaces by UUIDs and throw an error if any are missing * Fetch spaces by UUIDs and throw an error if any are missing
*/ */

View File

@ -0,0 +1,215 @@
import { DaysEnum } from '@app/common/constants/days.enum';
import { timeToMinutes } from '@app/common/helper/timeToMinutes';
import { BookingEntityRepository } from '@app/common/modules/booking/repositories/booking.repository';
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
import { SpaceRepository } from '@app/common/modules/space/repositories/space.repository';
import { UserRepository } from '@app/common/modules/user/repositories/user.repository';
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { format } from 'date-fns';
import { Between } from 'typeorm/find-options/operator/Between';
import { BookingRequestDto } from '../dtos/booking-request.dto';
import { CreateBookingDto } from '../dtos/create-booking.dto';
import { MyBookingRequestDto } from '../dtos/my-booking-request.dto';
@Injectable()
export class BookingService {
constructor(
private readonly bookingEntityRepository: BookingEntityRepository,
private readonly spaceRepository: SpaceRepository,
private readonly userRepository: UserRepository,
) {}
async create(userUuid: string, dto: CreateBookingDto) {
console.log(userUuid);
const user = await this.userRepository.findOne({
where: { uuid: userUuid },
relations: ['userSpaces', 'userSpaces.space'],
});
console.log(user.userSpaces);
if (!user.userSpaces.some(({ space }) => space.uuid === dto.spaceUuid)) {
throw new ForbiddenException(
`User does not have permission to book this space: ${dto.spaceUuid}`,
);
}
// Validate time slots first
this.validateTimeSlot(dto.startTime, dto.endTime);
// fetch spaces exist
const space = await this.getSpaceConfigurationAndBookings(dto.spaceUuid);
// Validate booking availability
this.validateBookingAvailability(space, dto);
// Create and save booking
return this.createBookings(space, userUuid, dto);
}
async findAll({ month }: BookingRequestDto, project: string) {
const [monthNumber, year] = month.split('/').map(Number);
const fromDate = new Date(year, monthNumber - 1, 1);
const toDate = new Date(year, monthNumber, 0, 23, 59, 59);
return this.bookingEntityRepository.find({
where: {
space: { community: { project: { uuid: project } } },
date: Between(fromDate, toDate),
},
relations: ['space', 'user', 'user.inviteUser'],
order: { date: 'DESC' },
});
}
async findMyBookings(
{ when }: MyBookingRequestDto,
userUuid: string,
project: string,
) {
const now = new Date();
const nowTime = format(now, 'HH:mm');
const query = this.bookingEntityRepository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.space', 'space')
.innerJoin(
'space.community',
'community',
'community.project = :project',
{ project },
)
.leftJoinAndSelect('booking.user', 'user')
.where('user.uuid = :userUuid', { userUuid });
if (when === 'past') {
query.andWhere(
`(DATE(booking.date) < :today OR (DATE(booking.date) = :today AND booking.startTime < :nowTime))`,
{ today: now, nowTime },
);
} else if (when === 'future') {
query.andWhere(
`(DATE(booking.date) > :today OR (DATE(booking.date) = :today AND booking.startTime >= :nowTime))`,
{ today: now, nowTime },
);
}
query.orderBy({
'DATE(booking.date)': 'DESC',
'booking.startTime': 'DESC',
});
return query.getMany();
}
/**
* Fetch space by UUID and throw an error if not found or if not configured for booking
*/
private async getSpaceConfigurationAndBookings(
spaceUuid: string,
): Promise<SpaceEntity> {
const space = await this.spaceRepository.findOne({
where: { uuid: spaceUuid },
relations: ['bookableConfig', 'bookings'],
});
if (!space) {
throw new NotFoundException(`Space not found: ${spaceUuid}`);
}
if (!space.bookableConfig) {
throw new NotFoundException(
`This space is not configured for booking: ${spaceUuid}`,
);
}
return space;
}
/**
* Ensure the slot start time is before the end time
*/
private validateTimeSlot(startTime: string, endTime: string): void {
const start = timeToMinutes(startTime);
const end = timeToMinutes(endTime);
if (start >= end) {
throw new BadRequestException(
`End time must be after start time for slot: ${startTime}-${endTime}`,
);
}
}
/**
* check if the space is available for booking on the requested day
* and if the requested time slot is within the available hours
*/
private validateBookingAvailability(
space: SpaceEntity,
dto: CreateBookingDto,
): void {
// Check if the space is available for booking on the requested day
const availableDays = space.bookableConfig?.daysAvailable || [];
const requestedDay = new Date(dto.date).toLocaleDateString('en-US', {
weekday: 'short',
}) as DaysEnum;
if (!availableDays.includes(requestedDay)) {
const dayFullName = new Date(dto.date).toLocaleDateString('en-US', {
weekday: 'long',
});
throw new BadRequestException(
`Space is not available for booking on ${dayFullName}s`,
);
}
const dtoStartTimeInMinutes = timeToMinutes(dto.startTime);
const dtoEndTimeInMinutes = timeToMinutes(dto.endTime);
if (
dtoStartTimeInMinutes < timeToMinutes(space.bookableConfig.startTime) ||
dtoEndTimeInMinutes > timeToMinutes(space.bookableConfig.endTime)
) {
throw new BadRequestException(
`Booking time must be within the available hours for space: ${space.spaceName}`,
);
}
const previousBookings = space.bookings.filter(
(booking) =>
timeToMinutes(booking.startTime) < dtoEndTimeInMinutes &&
timeToMinutes(booking.endTime) > dtoStartTimeInMinutes &&
format(new Date(booking.date), 'yyyy-MM-dd') ===
format(new Date(dto.date), 'yyyy-MM-dd'),
);
if (previousBookings.length > 0) {
// tell the user what time is unavailable
const unavailableTimes = previousBookings.map((booking) => {
return `${booking.startTime}-${booking.endTime}`;
});
throw new ConflictException(
`Space is already booked during this times: ${unavailableTimes.join(', ')}`,
);
}
}
/**
* Create bookable space entries after all validations pass
*/
private async createBookings(
space: SpaceEntity,
user: string,
{ spaceUuid, date, ...dto }: CreateBookingDto,
) {
const entry = this.bookingEntityRepository.create({
space: { uuid: spaceUuid },
user: { uuid: user },
...dto,
date: new Date(date),
cost: space.bookableConfig?.points || null,
});
return this.bookingEntityRepository.save(entry);
}
}

View File

@ -1 +0,0 @@
export * from './bookable-space.service';

View File

@ -5,7 +5,6 @@ import { ConfigModule } from '@nestjs/config';
import { SpaceRepositoryModule } from '@app/common/modules/space/space.repository.module'; import { SpaceRepositoryModule } from '@app/common/modules/space/space.repository.module';
import { import {
InviteSpaceRepository, InviteSpaceRepository,
SpaceLinkRepository,
SpaceProductAllocationRepository, SpaceProductAllocationRepository,
SpaceRepository, SpaceRepository,
} from '@app/common/modules/space/repositories'; } from '@app/common/modules/space/repositories';
@ -16,14 +15,12 @@ import { CommunityRepository } from '@app/common/modules/community/repositories'
import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service';
import { ProjectRepository } from '@app/common/modules/project/repositiories'; import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { import {
SpaceLinkService,
SpaceService, SpaceService,
SubspaceDeviceService, SubspaceDeviceService,
SubSpaceService, SubSpaceService,
ValidationService, ValidationService,
} from 'src/space/services'; } from 'src/space/services';
import { TagService as NewTagService } from 'src/tags/services'; import { TagService as NewTagService } from 'src/tags/services';
import { TagService } from 'src/space/services/tag';
import { import {
SpaceModelService, SpaceModelService,
SubSpaceModelService, SubSpaceModelService,
@ -81,16 +78,13 @@ import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/r
SpaceService, SpaceService,
InviteSpaceRepository, InviteSpaceRepository,
// Todo: find out why this is needed // Todo: find out why this is needed
SpaceLinkService,
SubSpaceService, SubSpaceService,
ValidationService, ValidationService,
NewTagService, NewTagService,
SpaceModelService, SpaceModelService,
SpaceProductAllocationService, SpaceProductAllocationService,
SpaceLinkRepository,
SubspaceRepository, SubspaceRepository,
// Todo: find out why this is needed // Todo: find out why this is needed
TagService,
SubspaceDeviceService, SubspaceDeviceService,
SubspaceProductAllocationService, SubspaceProductAllocationService,
SpaceModelRepository, SpaceModelRepository,

View File

@ -323,7 +323,7 @@ export class DeviceService {
async addNewDevice(addDeviceDto: AddDeviceDto, projectUuid: string) { async addNewDevice(addDeviceDto: AddDeviceDto, projectUuid: string) {
try { try {
const device = await this.getDeviceDetailsByDeviceIdTuya( const device = await this.getNewDeviceDetailsFromTuya(
addDeviceDto.deviceTuyaUuid, addDeviceDto.deviceTuyaUuid,
); );
@ -349,6 +349,7 @@ export class DeviceService {
spaceDevice: { uuid: addDeviceDto.spaceUuid }, spaceDevice: { uuid: addDeviceDto.spaceUuid },
tag: { uuid: addDeviceDto.tagUuid }, tag: { uuid: addDeviceDto.tagUuid },
name: addDeviceDto.deviceName, name: addDeviceDto.deviceName,
deviceTuyaConstUuid: device.uuid,
}); });
if (deviceSaved.uuid) { if (deviceSaved.uuid) {
const deviceStatus: BaseResponseDto = const deviceStatus: BaseResponseDto =
@ -752,6 +753,45 @@ export class DeviceService {
); );
} }
} }
async getNewDeviceDetailsFromTuya(
deviceId: string,
): Promise<GetDeviceDetailsInterface> {
console.log('fetching device details from Tuya for deviceId:', deviceId);
try {
const result = await this.tuyaService.getDeviceDetails(deviceId);
if (!result) {
throw new NotFoundException('Device not found');
}
// Convert keys to camel case
const camelCaseResponse = convertKeysToCamelCase(result);
const product = await this.productRepository.findOne({
where: { prodId: camelCaseResponse.productId },
});
if (!product) {
throw new NotFoundException('Product Type is not supported');
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { productId, id, productName, ...rest } = camelCaseResponse;
return {
...rest,
productUuid: product.uuid,
productName: product.name,
} as GetDeviceDetailsInterface;
} catch (error) {
console.log('error', error);
throw new HttpException(
error.message || 'Error fetching device details from Tuya',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async getDeviceInstructionByDeviceId( async getDeviceInstructionByDeviceId(
deviceUuid: string, deviceUuid: string,
projectUuid: string, projectUuid: string,

View File

@ -5,6 +5,7 @@ import {
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
IsUUID,
} from 'class-validator'; } from 'class-validator';
export class AddUserInvitationDto { export class AddUserInvitationDto {
@ -44,6 +45,15 @@ export class AddUserInvitationDto {
@IsOptional() @IsOptional()
public jobTitle?: string; public jobTitle?: string;
@ApiProperty({
description: 'The company name of the user',
example: 'Tech Corp',
required: false,
})
@IsString()
@IsOptional()
public companyName?: string;
@ApiProperty({ @ApiProperty({
description: 'The phone number of the user', description: 'The phone number of the user',
example: '+1234567890', example: '+1234567890',
@ -58,7 +68,7 @@ export class AddUserInvitationDto {
example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', example: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
required: true, required: true,
}) })
@IsString() @IsUUID('4')
@IsNotEmpty() @IsNotEmpty()
public roleUuid: string; public roleUuid: string;
@ApiProperty({ @ApiProperty({
@ -66,15 +76,17 @@ export class AddUserInvitationDto {
example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', example: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
required: true, required: true,
}) })
@IsString() @IsUUID('4')
@IsNotEmpty() @IsNotEmpty()
public projectUuid: string; public projectUuid: string;
@ApiProperty({ @ApiProperty({
description: 'The array of space UUIDs (at least one required)', description: 'The array of space UUIDs (at least one required)',
example: ['b5f3c9d2-58b7-4377-b3f7-60acb711d5d9'], example: ['b5f3c9d2-58b7-4377-b3f7-60acb711d5d9'],
required: true, required: true,
}) })
@IsArray() @IsArray()
@IsUUID('4', { each: true })
@ArrayMinSize(1) @ArrayMinSize(1)
public spaceUuids: string[]; public spaceUuids: string[];
constructor(dto: Partial<AddUserInvitationDto>) { constructor(dto: Partial<AddUserInvitationDto>) {

View File

@ -1,78 +1,10 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, OmitType } from '@nestjs/swagger';
import { import { IsBoolean, IsNotEmpty, IsString } from 'class-validator';
ArrayMinSize, import { AddUserInvitationDto } from './add.invite-user.dto';
IsArray,
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
} from 'class-validator';
export class UpdateUserInvitationDto { export class UpdateUserInvitationDto extends OmitType(AddUserInvitationDto, [
@ApiProperty({ 'email',
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<UpdateUserInvitationDto>) {
Object.assign(this, dto);
}
}
export class DisableUserInvitationDto { export class DisableUserInvitationDto {
@ApiProperty({ @ApiProperty({
description: 'The disable status of the user', description: 'The disable status of the user',

View File

@ -9,6 +9,7 @@ import { OccupancyService } from '@app/common/helper/services/occupancy.service'
import { PowerClampService } from '@app/common/helper/services/power.clamp.service'; import { PowerClampService } from '@app/common/helper/services/power.clamp.service';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service'; import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service';
import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories';
import { AutomationRepository } from '@app/common/modules/automation/repositories'; import { AutomationRepository } from '@app/common/modules/automation/repositories';
import { CommunityRepository } from '@app/common/modules/community/repositories'; import { CommunityRepository } from '@app/common/modules/community/repositories';
import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories'; import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories';
@ -27,6 +28,7 @@ import {
PowerClampHourlyRepository, PowerClampHourlyRepository,
PowerClampMonthlyRepository, PowerClampMonthlyRepository,
} from '@app/common/modules/power-clamp/repositories'; } from '@app/common/modules/power-clamp/repositories';
import { PresenceSensorDailySpaceRepository } from '@app/common/modules/presence-sensor/repositories';
import { ProductRepository } from '@app/common/modules/product/repositories'; import { ProductRepository } from '@app/common/modules/product/repositories';
import { ProjectRepository } from '@app/common/modules/project/repositiories'; import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { RegionRepository } from '@app/common/modules/region/repositories'; import { RegionRepository } from '@app/common/modules/region/repositories';
@ -38,7 +40,6 @@ import {
} from '@app/common/modules/scene/repositories'; } from '@app/common/modules/scene/repositories';
import { import {
InviteSpaceRepository, InviteSpaceRepository,
SpaceLinkRepository,
SpaceProductAllocationRepository, SpaceProductAllocationRepository,
SpaceRepository, SpaceRepository,
} from '@app/common/modules/space'; } from '@app/common/modules/space';
@ -58,7 +59,7 @@ import {
UserRepository, UserRepository,
UserSpaceRepository, UserSpaceRepository,
} from '@app/common/modules/user/repositories'; } from '@app/common/modules/user/repositories';
import { EmailService } from '@app/common/util/email.service'; import { EmailService } from '@app/common/util/email/email.service';
import { CommunityModule } from 'src/community/community.module'; import { CommunityModule } from 'src/community/community.module';
import { CommunityService } from 'src/community/services'; import { CommunityService } from 'src/community/services';
import { DeviceService } from 'src/device/services'; import { DeviceService } from 'src/device/services';
@ -82,8 +83,6 @@ import { SubspaceProductAllocationService } from 'src/space/services/subspace/su
import { TagService as NewTagService } from 'src/tags/services'; import { TagService as NewTagService } from 'src/tags/services';
import { UserDevicePermissionService } from 'src/user-device-permission/services'; import { UserDevicePermissionService } from 'src/user-device-permission/services';
import { UserService, UserSpaceService } from 'src/users/services'; import { UserService, UserSpaceService } from 'src/users/services';
import { PresenceSensorDailySpaceRepository } from '@app/common/modules/presence-sensor/repositories';
import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories';
@Module({ @Module({
imports: [ConfigModule, InviteUserRepositoryModule, CommunityModule], imports: [ConfigModule, InviteUserRepositoryModule, CommunityModule],
@ -121,7 +120,6 @@ import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/r
NewTagService, NewTagService,
SpaceModelService, SpaceModelService,
SpaceProductAllocationService, SpaceProductAllocationService,
SpaceLinkRepository,
SubspaceRepository, SubspaceRepository,
SubspaceDeviceService, SubspaceDeviceService,
SubspaceProductAllocationService, SubspaceProductAllocationService,

View File

@ -8,12 +8,14 @@ import {
InviteUserRepository, InviteUserRepository,
InviteUserSpaceRepository, InviteUserSpaceRepository,
} from '@app/common/modules/Invite-user/repositiories'; } from '@app/common/modules/Invite-user/repositiories';
import { ProjectEntity } from '@app/common/modules/project/entities';
import { RoleTypeEntity } from '@app/common/modules/role-type/entities';
import { RoleTypeRepository } from '@app/common/modules/role-type/repositories'; import { RoleTypeRepository } from '@app/common/modules/role-type/repositories';
import { SpaceRepository } from '@app/common/modules/space'; import { SpaceRepository } from '@app/common/modules/space';
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity'; import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
import { UserEntity } from '@app/common/modules/user/entities'; import { UserEntity } from '@app/common/modules/user/entities';
import { UserRepository } from '@app/common/modules/user/repositories'; import { UserRepository } from '@app/common/modules/user/repositories';
import { EmailService } from '@app/common/util/email.service'; import { EmailService } from '@app/common/util/email/email.service';
import { import {
BadRequestException, BadRequestException,
HttpException, HttpException,
@ -61,6 +63,7 @@ export class InviteUserService {
lastName, lastName,
email, email,
jobTitle, jobTitle,
companyName,
phoneNumber, phoneNumber,
roleUuid, roleUuid,
spaceUuids, spaceUuids,
@ -90,6 +93,8 @@ export class InviteUserService {
); );
} }
await this.checkRole(roleUuid, queryRunner);
await this.checkProject(projectUuid, queryRunner);
// Validate spaces // Validate spaces
const validSpaces = await this.validateSpaces( const validSpaces = await this.validateSpaces(
spaceUuids, spaceUuids,
@ -102,6 +107,7 @@ export class InviteUserService {
lastName, lastName,
email, email,
jobTitle, jobTitle,
companyName,
phoneNumber, phoneNumber,
roleType: { uuid: roleUuid }, roleType: { uuid: roleUuid },
status: UserStatusEnum.INVITED, status: UserStatusEnum.INVITED,
@ -157,185 +163,6 @@ export class InviteUserService {
await queryRunner.release(); await queryRunner.release();
} }
} }
private async validateSpaces(
spaceUuids: string[],
entityManager: EntityManager,
): Promise<SpaceEntity[]> {
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<BaseResponseDto> {
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<BaseResponseDto> {
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<UserEntity> {
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<void> {
await this.userSpaceService.verifyCodeAndAddUserSpace(
{ inviteCode: activationCode },
userUuid,
);
}
private async handleInvitedUser(
user: UserEntity,
invitedUser: InviteUserEntity,
): Promise<void> {
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( async updateUserInvitation(
dto: UpdateUserInvitationDto, dto: UpdateUserInvitationDto,
@ -357,6 +184,9 @@ export class InviteUserService {
throw new HttpException('User not found', HttpStatus.NOT_FOUND); throw new HttpException('User not found', HttpStatus.NOT_FOUND);
} }
await this.checkRole(dto.roleUuid, queryRunner);
await this.checkProject(projectUuid, queryRunner);
// Perform update actions if status is 'INVITED' // Perform update actions if status is 'INVITED'
if (userOldData.status === UserStatusEnum.INVITED) { if (userOldData.status === UserStatusEnum.INVITED) {
await this.updateWhenUserIsInvite(queryRunner, dto, invitedUserUuid); await this.updateWhenUserIsInvite(queryRunner, dto, invitedUserUuid);
@ -439,174 +269,7 @@ export class InviteUserService {
await queryRunner.release(); 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<void> {
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<void> {
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( async disableUserInvitation(
dto: DisableUserInvitationDto, dto: DisableUserInvitationDto,
invitedUserUuid: string, invitedUserUuid: string,
@ -686,74 +349,6 @@ export class InviteUserService {
} }
} }
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( async deleteUserInvitation(
invitedUserUuid: string, invitedUserUuid: string,
): Promise<BaseResponseDto> { ): Promise<BaseResponseDto> {
@ -824,4 +419,486 @@ export class InviteUserService {
await queryRunner.release(); await queryRunner.release();
} }
} }
async activationCode(dto: ActivateCodeDto): Promise<BaseResponseDto> {
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,
);
}
}
async checkEmailAndProject(dto: CheckEmailDto): Promise<BaseResponseDto> {
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 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);
}
}
}
private async validateSpaces(
spaceUuids: string[],
entityManager: EntityManager,
): Promise<SpaceEntity[]> {
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;
}
private async checkRole(
roleUuid: string,
queryRunner: QueryRunner,
): Promise<BaseResponseDto> {
try {
const role = await queryRunner.manager.findOne(RoleTypeEntity, {
where: { uuid: roleUuid },
});
if (!role) {
throw new HttpException('Role not found', HttpStatus.NOT_FOUND);
}
return new SuccessResponseDto({
statusCode: HttpStatus.OK,
success: true,
message: 'Valid role',
});
} catch (error) {
console.error('Error checking role:', error);
throw new HttpException(
error.message || 'An unexpected error occurred while checking the role',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private async checkProject(
projectUuid: string,
queryRunner: QueryRunner,
): Promise<BaseResponseDto> {
try {
const project = await queryRunner.manager.findOne(ProjectEntity, {
where: { uuid: projectUuid },
});
if (!project) {
throw new HttpException('Project not found', HttpStatus.NOT_FOUND);
}
return new SuccessResponseDto({
statusCode: HttpStatus.OK,
success: true,
message: 'Valid project',
});
} catch (error) {
console.error('Error checking project:', error);
throw new HttpException(
error.message ||
'An unexpected error occurred while checking the project',
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,
);
}
}
}
private async getUser(userUuid: string): Promise<UserEntity> {
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<void> {
await this.userSpaceService.verifyCodeAndAddUserSpace(
{ inviteCode: activationCode },
userUuid,
);
}
private async handleInvitedUser(
user: UserEntity,
invitedUser: InviteUserEntity,
): Promise<void> {
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 },
},
);
}
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<void> {
const {
firstName,
lastName,
jobTitle,
companyName,
phoneNumber,
roleUuid,
spaceUuids,
} = dto;
// Update user invitation details
await queryRunner.manager.update(
this.inviteUserRepository.target,
{ uuid: invitedUserUuid },
{
firstName,
lastName,
jobTitle,
companyName,
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<void> {
const {
firstName,
lastName,
jobTitle,
companyName,
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,
companyName,
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);
}
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);
}
} }

View File

@ -22,7 +22,6 @@ import {
} from '@app/common/modules/scene/repositories'; } from '@app/common/modules/scene/repositories';
import { import {
InviteSpaceRepository, InviteSpaceRepository,
SpaceLinkRepository,
SpaceProductAllocationRepository, SpaceProductAllocationRepository,
SpaceRepository, SpaceRepository,
} from '@app/common/modules/space'; } from '@app/common/modules/space';
@ -96,7 +95,6 @@ import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/r
SpaceModelService, SpaceModelService,
SpaceProductAllocationService, SpaceProductAllocationService,
SqlLoaderService, SqlLoaderService,
SpaceLinkRepository,
SubspaceRepository, SubspaceRepository,
SubspaceDeviceService, SubspaceDeviceService,
SubspaceProductAllocationService, SubspaceProductAllocationService,

View File

@ -23,7 +23,6 @@ import {
} from '@app/common/modules/scene/repositories'; } from '@app/common/modules/scene/repositories';
import { import {
InviteSpaceRepository, InviteSpaceRepository,
SpaceLinkRepository,
SpaceProductAllocationRepository, SpaceProductAllocationRepository,
SpaceRepository, SpaceRepository,
} from '@app/common/modules/space'; } from '@app/common/modules/space';
@ -94,7 +93,6 @@ const CommandHandlers = [CreateOrphanSpaceHandler];
SpaceModelService, SpaceModelService,
DeviceService, DeviceService,
SpaceProductAllocationService, SpaceProductAllocationService,
SpaceLinkRepository,
SubspaceRepository, SubspaceRepository,
SubspaceDeviceService, SubspaceDeviceService,
SubspaceProductAllocationService, SubspaceProductAllocationService,

View File

@ -33,6 +33,7 @@ export class ProjectUserService {
'status', 'status',
'phoneNumber', 'phoneNumber',
'jobTitle', 'jobTitle',
'companyName',
'invitedBy', 'invitedBy',
'isEnabled', 'isEnabled',
], ],
@ -91,6 +92,7 @@ export class ProjectUserService {
'status', 'status',
'phoneNumber', 'phoneNumber',
'jobTitle', 'jobTitle',
'companyName',
'invitedBy', 'invitedBy',
'isEnabled', 'isEnabled',
], ],

View File

@ -22,7 +22,6 @@ import {
} from '@app/common/modules/scene/repositories'; } from '@app/common/modules/scene/repositories';
import { import {
InviteSpaceRepository, InviteSpaceRepository,
SpaceLinkRepository,
SpaceProductAllocationRepository, SpaceProductAllocationRepository,
SpaceRepository, SpaceRepository,
} from '@app/common/modules/space'; } from '@app/common/modules/space';
@ -93,7 +92,6 @@ const CommandHandlers = [
DeviceRepository, DeviceRepository,
TuyaService, TuyaService,
CommunityRepository, CommunityRepository,
SpaceLinkRepository,
InviteSpaceRepository, InviteSpaceRepository,
NewTagService, NewTagService,
DeviceService, DeviceService,

View File

@ -1,23 +1,26 @@
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { SpaceService } from '../services';
import { ControllerRoute } from '@app/common/constants/controller-route'; import { ControllerRoute } from '@app/common/constants/controller-route';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Param, Param,
ParseUUIDPipe,
Post, Post,
Put, Put,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { AddSpaceDto, CommunitySpaceParam, UpdateSpaceDto } from '../dtos'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
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 { Permissions } from 'src/decorators/permissions.decorator';
import { PermissionsGuard } from 'src/guards/permissions.guard';
import { AddSpaceDto, CommunitySpaceParam, UpdateSpaceDto } from '../dtos';
import { GetSpaceDto } from '../dtos/get.space.dto'; import { GetSpaceDto } from '../dtos/get.space.dto';
import { GetSpaceParam } from '../dtos/get.space.param';
import { OrderSpacesDto } from '../dtos/order.spaces.dto';
import { SpaceService } from '../services';
@ApiTags('Space Module') @ApiTags('Space Module')
@Controller({ @Controller({
@ -65,6 +68,30 @@ export class SpaceController {
); );
} }
@ApiBearerAuth()
@UseGuards(PermissionsGuard)
@Permissions('SPACE_UPDATE')
@ApiOperation({
summary:
ControllerRoute.SPACE.ACTIONS
.UPDATE_CHILDREN_SPACES_ORDER_OF_A_SPACE_SUMMARY,
description:
ControllerRoute.SPACE.ACTIONS
.UPDATE_CHILDREN_SPACES_ORDER_OF_A_SPACE_DESCRIPTION,
})
@Post(':parentSpaceUuid/spaces/order')
async updateSpacesOrder(
@Body() orderSpacesDto: OrderSpacesDto,
@Param() communitySpaceParam: CommunitySpaceParam,
@Param('parentSpaceUuid', ParseUUIDPipe) parentSpaceUuid: string,
) {
await this.spaceService.updateSpacesOrder(parentSpaceUuid, orderSpacesDto);
return new SuccessResponseDto({
statusCode: 200,
message: 'Spaces order updated successfully',
});
}
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(PermissionsGuard) @UseGuards(PermissionsGuard)
@Permissions('SPACE_DELETE') @Permissions('SPACE_DELETE')

View File

@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayUnique, IsNotEmpty, IsUUID } from 'class-validator';
export class OrderSpacesDto {
@ApiProperty({
description: 'List of children spaces associated with the space',
type: [String],
})
@IsNotEmpty()
@ArrayUnique()
@IsUUID('4', { each: true, message: 'Invalid space UUID provided' })
spacesUuids: string[];
}

View File

@ -2,6 +2,7 @@ import { ORPHAN_SPACE_NAME } from '@app/common/constants/orphan-constant';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { import {
ArrayUnique,
IsArray, IsArray,
IsNumber, IsNumber,
IsOptional, IsOptional,
@ -49,6 +50,21 @@ export class UpdateSpaceDto {
description: 'List of subspace modifications', description: 'List of subspace modifications',
type: [UpdateSubspaceDto], type: [UpdateSubspaceDto],
}) })
@ArrayUnique((subspace) => subspace.subspaceName ?? subspace.uuid, {
message(validationArguments) {
const subspaces = validationArguments.value;
const nameCounts = subspaces.reduce((acc, curr) => {
acc[curr.subspaceName ?? curr.uuid] =
(acc[curr.subspaceName ?? curr.uuid] || 0) + 1;
return acc;
}, {});
// Find duplicates
const duplicates = Object.keys(nameCounts).filter(
(name) => nameCounts[name] > 1,
);
return `Duplicate subspace names found: ${duplicates.join(', ')}`;
},
})
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })

View File

@ -2,6 +2,5 @@ export * from './space.service';
export * from './space-user.service'; export * from './space-user.service';
export * from './space-device.service'; export * from './space-device.service';
export * from './subspace'; export * from './subspace';
export * from './space-link';
export * from './space-scene.service'; export * from './space-scene.service';
export * from './space-validation.service'; export * from './space-validation.service';

View File

@ -1 +0,0 @@
export * from './space-link.service';

View File

@ -1,6 +0,0 @@
import { Injectable } from '@nestjs/common';
// todo: find out why we need to import this
// in community module in order for the whole system to work
@Injectable()
export class SpaceLinkService {}

View File

@ -33,6 +33,7 @@ import {
} from '../dtos'; } from '../dtos';
import { CreateProductAllocationDto } from '../dtos/create-product-allocation.dto'; import { CreateProductAllocationDto } from '../dtos/create-product-allocation.dto';
import { GetSpaceDto } from '../dtos/get.space.dto'; import { GetSpaceDto } from '../dtos/get.space.dto';
import { OrderSpacesDto } from '../dtos/order.spaces.dto';
import { SpaceWithParentsDto } from '../dtos/space.parents.dto'; import { SpaceWithParentsDto } from '../dtos/space.parents.dto';
import { SpaceProductAllocationService } from './space-product-allocation.service'; import { SpaceProductAllocationService } from './space-product-allocation.service';
import { ValidationService } from './space-validation.service'; import { ValidationService } from './space-validation.service';
@ -355,6 +356,54 @@ export class SpaceService {
} }
} }
async updateSpacesOrder(
parentSpaceUuid: string,
{ spacesUuids }: OrderSpacesDto,
) {
const parentSpace = await this.spaceRepository.findOne({
where: { uuid: parentSpaceUuid, disabled: false },
relations: ['children'],
});
if (!parentSpace) {
throw new HttpException(
`Parent space with ID ${parentSpaceUuid} not found`,
HttpStatus.NOT_FOUND,
);
}
// ensure that all sent spaces belong to the parent space
const missingSpaces = spacesUuids.filter(
(uuid) => !parentSpace.children.some((child) => child.uuid === uuid),
);
if (missingSpaces.length > 0) {
throw new HttpException(
`Some spaces with IDs ${missingSpaces.join(
', ',
)} do not belong to the parent space with ID ${parentSpaceUuid}`,
HttpStatus.BAD_REQUEST,
);
}
try {
await this.spaceRepository.update(
{ uuid: In(spacesUuids), parent: { uuid: parentSpaceUuid } },
{
order: () =>
'CASE ' +
spacesUuids
.map((s, index) => `WHEN uuid = '${s}' THEN ${index + 1}`)
.join(' ') +
' END',
},
);
return;
} catch (error) {
console.error('Error updating spaces order:', error);
throw new HttpException(
'An error occurred while updating spaces order',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async delete(params: GetSpaceParam): Promise<BaseResponseDto> { async delete(params: GetSpaceParam): Promise<BaseResponseDto> {
const queryRunner = this.dataSource.createQueryRunner(); const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect(); await queryRunner.connect();
@ -426,7 +475,7 @@ export class SpaceService {
} }
} }
async disableSpace(space: SpaceEntity, orphanSpace: SpaceEntity) { private async disableSpace(space: SpaceEntity, orphanSpace: SpaceEntity) {
await this.commandBus.execute( await this.commandBus.execute(
new DisableSpaceCommand({ spaceUuid: space.uuid, orphanSpace }), new DisableSpaceCommand({ spaceUuid: space.uuid, orphanSpace }),
); );
@ -709,10 +758,21 @@ export class SpaceService {
rootSpaces.push(map.get(space.uuid)!); // Push only root spaces rootSpaces.push(map.get(space.uuid)!); // Push only root spaces
} }
}); });
rootSpaces.forEach(this.sortSpaceChildren.bind(this));
return rootSpaces; return rootSpaces;
} }
private sortSpaceChildren(space: SpaceEntity) {
if (space.children && space.children.length > 0) {
space.children.sort((a, b) => {
const aOrder = a.order ?? Infinity;
const bOrder = b.order ?? Infinity;
return aOrder - bOrder;
});
space.children.forEach(this.sortSpaceChildren.bind(this)); // Recursively sort children of children
}
}
private validateSpaceCreationCriteria({ private validateSpaceCreationCriteria({
spaceModelUuid, spaceModelUuid,
productAllocations, productAllocations,

View File

@ -23,7 +23,7 @@ export class SubspaceProductAllocationService {
// spaceAllocationsToExclude?: SpaceProductAllocationEntity[], // spaceAllocationsToExclude?: SpaceProductAllocationEntity[],
): Promise<void> { ): Promise<void> {
try { try {
if (!allocationsData.length) return; if (!allocationsData?.length) return;
const allocations: SubspaceProductAllocationEntity[] = []; const allocations: SubspaceProductAllocationEntity[] = [];
@ -112,7 +112,7 @@ export class SubspaceProductAllocationService {
); );
// Create the product-tag mapping based on the processed tags // Create the product-tag mapping based on the processed tags
const productTagMapping = subspace.productAllocations.map( const productTagMapping = subspace.productAllocations?.map(
({ tagUuid, tagName, productUuid }) => { ({ tagUuid, tagName, productUuid }) => {
const inputTag = tagUuid const inputTag = tagUuid
? createdTagsByUUID.get(tagUuid) ? createdTagsByUUID.get(tagUuid)

View File

@ -39,7 +39,7 @@ export class SubSpaceService {
private readonly subspaceProductAllocationService: SubspaceProductAllocationService, private readonly subspaceProductAllocationService: SubspaceProductAllocationService,
) {} ) {}
async createSubspaces( private async createSubspaces(
subspaceData: Array<{ subspaceData: Array<{
subspaceName: string; subspaceName: string;
space: SpaceEntity; space: SpaceEntity;
@ -342,26 +342,37 @@ export class SubSpaceService {
})), })),
); );
const existingSubspaces = await this.subspaceRepository.find({
where: {
uuid: In(
subspaceDtos.filter((dto) => dto.uuid).map((dto) => dto.uuid),
),
},
});
if (
existingSubspaces.length !==
subspaceDtos.filter((dto) => dto.uuid).length
) {
throw new HttpException(
`Some subspaces with provided UUIDs do not exist in the space.`,
HttpStatus.NOT_FOUND,
);
}
const updatedSubspaces: SubspaceEntity[] = await queryRunner.manager.save( const updatedSubspaces: SubspaceEntity[] = await queryRunner.manager.save(
SubspaceEntity, SubspaceEntity,
[ newSubspaces,
...newSubspaces,
...subspaceDtos
.filter((dto) => dto.uuid)
.map((dto) => ({
subspaceName: dto.subspaceName,
space,
})),
],
); );
const allSubspaces = [...updatedSubspaces, ...existingSubspaces];
// create or update allocations for the subspaces // create or update allocations for the subspaces
if (updatedSubspaces.length > 0) { if (allSubspaces.length > 0) {
await this.subspaceProductAllocationService.updateSubspaceProductAllocationsV2( await this.subspaceProductAllocationService.updateSubspaceProductAllocationsV2(
subspaceDtos.map((dto) => ({ subspaceDtos.map((dto) => ({
...dto, ...dto,
uuid: uuid:
dto.uuid || dto.uuid ||
updatedSubspaces.find((s) => s.subspaceName === dto.subspaceName) allSubspaces.find((s) => s.subspaceName === dto.subspaceName)
?.uuid, ?.uuid,
})), })),
projectUuid, projectUuid,

View File

@ -1 +0,0 @@
export * from './tag.service';

View File

@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
// todo: find out why we need to import this
// in community module in order for the whole system to work
@Injectable()
export class TagService {
constructor() {}
}

View File

@ -37,7 +37,6 @@ import {
} from '@app/common/modules/space-model'; } from '@app/common/modules/space-model';
import { import {
InviteSpaceRepository, InviteSpaceRepository,
SpaceLinkRepository,
SpaceProductAllocationRepository, SpaceProductAllocationRepository,
SpaceRepository, SpaceRepository,
} from '@app/common/modules/space/repositories'; } from '@app/common/modules/space/repositories';
@ -116,7 +115,6 @@ export const CommandHandlers = [DisableSpaceHandler];
SubspaceRepository, SubspaceRepository,
DeviceRepository, DeviceRepository,
CommunityRepository, CommunityRepository,
SpaceLinkRepository,
UserSpaceRepository, UserSpaceRepository,
UserRepository, UserRepository,
SpaceUserService, SpaceUserService,

View File

@ -1,28 +1,28 @@
import { CommonErrorCodes } from '@app/common/constants/error-codes.enum';
import { PermissionType } from '@app/common/constants/permission-type.enum';
import { RoleType } from '@app/common/constants/role.type.enum';
import { UserStatusEnum } from '@app/common/constants/user-status.enum';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import {
InviteUserRepository,
InviteUserSpaceRepository,
} from '@app/common/modules/Invite-user/repositiories';
import { InviteSpaceEntity } from '@app/common/modules/space/entities/invite-space.entity';
import {
InviteSpaceRepository,
SpaceRepository,
} from '@app/common/modules/space/repositories';
import { UserSpaceRepository } from '@app/common/modules/user/repositories';
import { import {
BadRequestException, BadRequestException,
HttpException, HttpException,
HttpStatus, HttpStatus,
Injectable, Injectable,
} from '@nestjs/common'; } 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 {
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 { UserDevicePermissionService } from 'src/user-device-permission/services';
import { PermissionType } from '@app/common/constants/permission-type.enum'; import { AddUserSpaceDto, AddUserSpaceUsingCodeDto } from '../dtos';
import { InviteSpaceEntity } from '@app/common/modules/space/entities/invite-space.entity';
import { UserService } from './user.service'; 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() @Injectable()
export class UserSpaceService { export class UserSpaceService {
@ -154,6 +154,7 @@ export class UserSpaceService {
lastName: user.lastName, lastName: user.lastName,
email: user.email, email: user.email,
jobTitle: null, jobTitle: null,
companyName: null,
phoneNumber: null, phoneNumber: null,
roleType: { uuid: user.role.uuid }, roleType: { uuid: user.role.uuid },
status: UserStatusEnum.ACTIVE, status: UserStatusEnum.ACTIVE,

View File

@ -28,7 +28,7 @@ import { PasswordType } from '@app/common/constants/password-type.enum';
import { VisitorPasswordEnum } from '@app/common/constants/visitor-password.enum'; import { VisitorPasswordEnum } from '@app/common/constants/visitor-password.enum';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { ProjectRepository } from '@app/common/modules/project/repositiories'; import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { EmailService } from '@app/common/util/email.service'; import { EmailService } from '@app/common/util/email/email.service';
import { DeviceService } from 'src/device/services'; import { DeviceService } from 'src/device/services';
import { DoorLockService } from 'src/door-lock/services'; import { DoorLockService } from 'src/door-lock/services';
import { PasswordEncryptionService } from 'src/door-lock/services/encryption.services'; import { PasswordEncryptionService } from 'src/door-lock/services/encryption.services';

View File

@ -1,39 +1,39 @@
import { Module } from '@nestjs/common';
import { VisitorPasswordService } from './services/visitor-password.service';
import { VisitorPasswordController } from './controllers/visitor-password.controller';
import { ConfigModule } from '@nestjs/config';
import { DeviceRepositoryModule } from '@app/common/modules/device';
import { DeviceRepository } from '@app/common/modules/device/repositories';
import { EmailService } from '@app/common/util/email.service';
import { PasswordEncryptionService } from 'src/door-lock/services/encryption.services';
import { DoorLockModule } from 'src/door-lock/door.lock.module';
import { DeviceService } from 'src/device/services';
import { ProductRepository } from '@app/common/modules/product/repositories';
import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service'; import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service';
import { SpaceRepository } from '@app/common/modules/space/repositories'; import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
import { VisitorPasswordRepository } from '@app/common/modules/visitor-password/repositories'; import { OccupancyService } from '@app/common/helper/services/occupancy.service';
import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories'; import { PowerClampService } from '@app/common/helper/services/power.clamp.service';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service'; import { TuyaService } from '@app/common/integrations/tuya/services/tuya.service';
import { SceneService } from 'src/scene/services'; import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories';
import { AutomationRepository } from '@app/common/modules/automation/repositories';
import { CommunityRepository } from '@app/common/modules/community/repositories';
import { DeviceRepositoryModule } from '@app/common/modules/device';
import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories';
import { DeviceRepository } from '@app/common/modules/device/repositories';
import {
PowerClampDailyRepository,
PowerClampHourlyRepository,
PowerClampMonthlyRepository,
} from '@app/common/modules/power-clamp/repositories';
import { PresenceSensorDailySpaceRepository } from '@app/common/modules/presence-sensor/repositories';
import { ProductRepository } from '@app/common/modules/product/repositories';
import { ProjectRepository } from '@app/common/modules/project/repositiories';
import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories';
import { import {
SceneIconRepository, SceneIconRepository,
SceneRepository, SceneRepository,
} from '@app/common/modules/scene/repositories'; } from '@app/common/modules/scene/repositories';
import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; import { SpaceRepository } from '@app/common/modules/space/repositories';
import { AutomationRepository } from '@app/common/modules/automation/repositories'; import { VisitorPasswordRepository } from '@app/common/modules/visitor-password/repositories';
import { ProjectRepository } from '@app/common/modules/project/repositiories'; import { EmailService } from '@app/common/util/email/email.service';
import { PowerClampService } from '@app/common/helper/services/power.clamp.service'; import { Module } from '@nestjs/common';
import { import { ConfigModule } from '@nestjs/config';
PowerClampHourlyRepository, import { DeviceService } from 'src/device/services';
PowerClampDailyRepository, import { DoorLockModule } from 'src/door-lock/door.lock.module';
PowerClampMonthlyRepository, import { PasswordEncryptionService } from 'src/door-lock/services/encryption.services';
} from '@app/common/modules/power-clamp/repositories'; import { SceneService } from 'src/scene/services';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service'; import { VisitorPasswordController } from './controllers/visitor-password.controller';
import { OccupancyService } from '@app/common/helper/services/occupancy.service'; import { VisitorPasswordService } from './services/visitor-password.service';
import { CommunityRepository } from '@app/common/modules/community/repositories';
import { AqiDataService } from '@app/common/helper/services/aqi.data.service';
import { PresenceSensorDailySpaceRepository } from '@app/common/modules/presence-sensor/repositories';
import { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories';
@Module({ @Module({
imports: [ConfigModule, DeviceRepositoryModule, DoorLockModule], imports: [ConfigModule, DeviceRepositoryModule, DoorLockModule],
controllers: [VisitorPasswordController], controllers: [VisitorPasswordController],