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
This commit is contained in:
ZaydSkaff
2025-07-15 10:11:36 +03:00
committed by GitHub
parent a9cb1b6704
commit e5970c02c1
37 changed files with 1014 additions and 184 deletions

View File

@ -1,12 +1,11 @@
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 config from './config';
import { DatabaseModule } from './database/database.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 { SceneDeviceRepository } from './modules/scene-device/repositories';
import { SpaceRepository } from './modules/space';
@ -15,6 +14,7 @@ import {
SubspaceModelRepository,
} from './modules/space-model';
import { SubspaceRepository } from './modules/space/repositories/subspace.repository';
import { EmailService } from './util/email/email.service';
@Module({
providers: [
CommonService,

View File

@ -10,6 +10,8 @@ export default registerAs(
SMTP_USER: process.env.SMTP_USER,
SMTP_SENDER: process.env.SMTP_SENDER,
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_INVITATION_TEMPLATE_UUID:
process.env.MAILTRAP_INVITATION_TEMPLATE_UUID,
@ -21,5 +23,9 @@ export default registerAs(
process.env.MAILTRAP_EDIT_USER_TEMPLATE_UUID,
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.';
};
};
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 {
public static readonly ROUTE = '/projects/:projectUuid/communities';
static ACTIONS = class {

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 { AqiSpaceDailyPollutantStatsEntity } from '../modules/aqi/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 { CommunityEntity } from '../modules/community/entities';
import { DeviceStatusLogEntity } from '../modules/device-status-log/entities';
@ -58,7 +60,6 @@ import {
UserSpaceEntity,
} from '../modules/user/entities';
import { VisitorPasswordEntity } from '../modules/visitor-password/entities';
import { BookableSpaceEntity } from '../modules/booking/entities';
@Module({
imports: [
TypeOrmModule.forRootAsync({
@ -119,6 +120,7 @@ import { BookableSpaceEntity } from '../modules/booking/entities';
AqiSpaceDailyPollutantStatsEntity,
SpaceDailyOccupancyDurationEntity,
BookableSpaceEntity,
BookingEntity,
],
namingStrategy: new SnakeNamingStrategy(),
synchronize: Boolean(JSON.parse(configService.get('DB_SYNC'))),

View File

@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookableSpaceEntity } from './entities/bookable-space.entity';
import { BookingEntity } from './entities/booking.entity';
@Module({
providers: [],
exports: [],
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 { BookableSpaceEntity } from '../entities/bookable-space.entity';
import { DataSource, Repository } from 'typeorm';
import { BookingEntity } from '../entities/booking.entity';
@Injectable()
export class BookableSpaceEntityRepository extends Repository<BookableSpaceEntity> {
export class BookingEntityRepository extends Repository<BookingEntity> {
constructor(private dataSource: DataSource) {
super(BookableSpaceEntity, dataSource.createEntityManager());
super(BookingEntity, dataSource.createEntityManager());
}
}

View File

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

View File

@ -8,7 +8,7 @@ import {
} from 'typeorm';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { AqiSpaceDailyPollutantStatsEntity } from '../../aqi/entities';
import { BookableSpaceEntity } from '../../booking/entities';
import { BookableSpaceEntity } from '../../booking/entities/bookable-space.entity';
import { CommunityEntity } from '../../community/entities';
import { DeviceEntity } from '../../device/entities';
import { InviteUserSpaceEntity } from '../../Invite-user/entities';
@ -20,6 +20,7 @@ import { UserSpaceEntity } from '../../user/entities';
import { SpaceDto } from '../dtos';
import { SpaceProductAllocationEntity } from './space-product-allocation.entity';
import { SubspaceEntity } from './subspace/subspace.entity';
import { BookingEntity } from '../../booking/entities/booking.entity';
@Entity({ name: 'space' })
export class SpaceEntity extends AbstractEntity<SpaceDto> {
@ -132,6 +133,9 @@ export class SpaceEntity extends AbstractEntity<SpaceDto> {
@OneToOne(() => BookableSpaceEntity, (bookable) => bookable.space)
bookableConfig: BookableSpaceEntity;
@OneToMany(() => BookingEntity, (booking) => booking.space)
bookings: BookingEntity[];
constructor(partial: Partial<SpaceEntity>) {
super();
Object.assign(this, partial);

View File

@ -29,6 +29,7 @@ import {
UserOtpDto,
UserSpaceDto,
} from '../dtos';
import { BookingEntity } from '../../booking/entities/booking.entity';
@Entity({ name: 'user' })
export class UserEntity extends AbstractEntity<UserDto> {
@ -121,6 +122,9 @@ export class UserEntity extends AbstractEntity<UserDto> {
)
deviceUserNotification: DeviceNotificationEntity[];
@OneToMany(() => BookingEntity, (booking) => booking.user)
bookings: BookingEntity[];
@ManyToOne(() => RegionEntity, (region) => region.users, { nullable: true })
region: RegionEntity;
@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 { ConfigService } from '@nestjs/config';
import axios from 'axios';
import * as nodemailer from 'nodemailer';
import {
SEND_EMAIL_API_URL_DEV,
SEND_EMAIL_API_URL_PROD,
} from '../constants/mail-trap';
import nodemailer from 'nodemailer';
import Mail from 'nodemailer/lib/mailer';
import { BatchEmailData } from './batch-email.interface';
import { SingleEmailData } from './single-email.interface';
@Injectable()
export class EmailService {
private smtpConfig: any;
private API_TOKEN: string;
private SEND_EMAIL_API_URL: string;
private BATCH_EMAIL_API_URL: string;
constructor(private readonly configService: ConfigService) {
this.smtpConfig = {
@ -22,6 +24,15 @@ export class EmailService {
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(
@ -31,7 +42,7 @@ export class EmailService {
): Promise<void> {
const transporter = nodemailer.createTransport(this.smtpConfig);
const mailOptions = {
const mailOptions: Mail.Options = {
from: this.smtpConfig.sender,
to: email,
subject,
@ -44,13 +55,6 @@ export class EmailService {
email: string,
emailInvitationData: any,
): 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>(
'email-config.MAILTRAP_INVITATION_TEMPLATE_UUID',
);
@ -68,21 +72,12 @@ export class EmailService {
template_variables: emailInvitationData,
};
try {
await axios.post(API_URL, emailData, {
headers: {
Authorization: `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
},
});
} catch (error) {
throw new HttpException(
error.response?.data?.message ||
'Error sending email using Mailtrap template',
error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
return this.sendEmailWithTemplateV2({
...emailData,
isBatch: false,
});
}
async sendEmailWithTemplate({
email,
name,
@ -94,14 +89,6 @@ export class EmailService {
isEnable: boolean;
isDelete: boolean;
}): 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
const templateUuid = isDelete
? this.configService.get<string>(
@ -128,32 +115,16 @@ export class EmailService {
},
};
try {
await axios.post(API_URL, emailData, {
headers: {
Authorization: `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
},
});
} catch (error) {
throw new HttpException(
error.response?.data?.message ||
'Error sending email using Mailtrap template',
error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
return this.sendEmailWithTemplateV2({
...emailData,
isBatch: false,
});
}
async sendEditUserEmailWithTemplate(
email: string,
emailEditData: any,
): 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>(
'email-config.MAILTRAP_EDIT_USER_TEMPLATE_UUID',
);
@ -171,32 +142,15 @@ export class EmailService {
template_variables: emailEditData,
};
try {
await axios.post(API_URL, emailData, {
headers: {
Authorization: `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
},
});
} catch (error) {
throw new HttpException(
error.response?.data?.message ||
'Error sending email using Mailtrap template',
error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
return this.sendEmailWithTemplateV2({
...emailData,
isBatch: false,
});
}
async sendOtpEmailWithTemplate(
email: string,
emailEditData: any,
): 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>(
'email-config.MAILTRAP_SEND_OTP_TEMPLATE_UUID',
);
@ -214,20 +168,84 @@ export class EmailService {
template_variables: emailEditData,
};
try {
await axios.post(API_URL, emailData, {
headers: {
Authorization: `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json',
return this.sendEmailWithTemplateV2({
...emailData,
isBatch: false,
});
}
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,
},
});
} catch (error) {
throw new HttpException(
error.response?.data?.message ||
'Error sending email using Mailtrap template',
error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
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(
addedSpaceNames: string[],
@ -264,4 +282,30 @@ export class EmailService {
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');
}