From e5970c02c1e455d1d3d3ec7582fd7c6a7f04f56c Mon Sep 17 00:00:00 2001 From: ZaydSkaff Date: Tue, 15 Jul 2025 10:11:36 +0300 Subject: [PATCH] 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 --- libs/common/src/common.module.ts | 10 +- libs/common/src/config/email.config.ts | 6 + libs/common/src/constants/controller-route.ts | 19 ++ libs/common/src/constants/mail-trap.ts | 3 - libs/common/src/database/database.module.ts | 4 +- .../booking/booking.repository.module.ts | 5 +- .../booking/entities/booking.entity.ts | 44 ++++ .../src/modules/booking/entities/index.ts | 1 - .../repositories/bookable-space.repository.ts | 10 + .../repositories/booking.repository.ts | 8 +- .../src/modules/booking/repositories/index.ts | 1 - .../modules/space/entities/space.entity.ts | 6 +- .../src/modules/user/entities/user.entity.ts | 4 + .../src/util/email/batch-email.interface.ts | 8 + .../src/util/{ => email}/email.service.ts | 224 +++++++++++------- .../src/util/email/single-email.interface.ts | 7 + .../src/util/time-to-12-hours-convetion.ts | 7 + package-lock.json | 52 ++-- package.json | 4 +- src/auth/auth.module.ts | 16 +- src/auth/services/user-auth.service.ts | 2 +- src/booking/booking.module.ts | 24 +- .../controllers/bookable-space.controller.ts | 4 +- src/booking/controllers/booking.controller.ts | 107 +++++++++ src/booking/controllers/index.ts | 1 - src/booking/dtos/booking-request.dto.ts | 14 ++ src/booking/dtos/booking-response.dto.ts | 88 +++++++ src/booking/dtos/create-booking.dto.ts | 35 +++ src/booking/dtos/index.ts | 1 - src/booking/dtos/my-booking-request.dto.ts | 14 ++ .../services/bookable-space.service.ts | 183 +++++++++++++- src/booking/services/booking.service.ts | 215 +++++++++++++++++ src/booking/services/index.ts | 1 - src/invite-user/invite-user.module.ts | 6 +- .../services/invite-user.service.ts | 2 +- .../services/visitor-password.service.ts | 2 +- .../visitor-password.module.ts | 60 ++--- 37 files changed, 1014 insertions(+), 184 deletions(-) delete mode 100644 libs/common/src/constants/mail-trap.ts create mode 100644 libs/common/src/modules/booking/entities/booking.entity.ts delete mode 100644 libs/common/src/modules/booking/entities/index.ts create mode 100644 libs/common/src/modules/booking/repositories/bookable-space.repository.ts delete mode 100644 libs/common/src/modules/booking/repositories/index.ts create mode 100644 libs/common/src/util/email/batch-email.interface.ts rename libs/common/src/util/{ => email}/email.service.ts (60%) create mode 100644 libs/common/src/util/email/single-email.interface.ts create mode 100644 libs/common/src/util/time-to-12-hours-convetion.ts create mode 100644 src/booking/controllers/booking.controller.ts delete mode 100644 src/booking/controllers/index.ts create mode 100644 src/booking/dtos/booking-request.dto.ts create mode 100644 src/booking/dtos/booking-response.dto.ts create mode 100644 src/booking/dtos/create-booking.dto.ts delete mode 100644 src/booking/dtos/index.ts create mode 100644 src/booking/dtos/my-booking-request.dto.ts create mode 100644 src/booking/services/booking.service.ts delete mode 100644 src/booking/services/index.ts diff --git a/libs/common/src/common.module.ts b/libs/common/src/common.module.ts index 91e5507..325df3d 100644 --- a/libs/common/src/common.module.ts +++ b/libs/common/src/common.module.ts @@ -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, diff --git a/libs/common/src/config/email.config.ts b/libs/common/src/config/email.config.ts index 57ae480..e83d044 100644 --- a/libs/common/src/config/email.config.ts +++ b/libs/common/src/config/email.config.ts @@ -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, }), ); diff --git a/libs/common/src/constants/controller-route.ts b/libs/common/src/constants/controller-route.ts index af7bda4..a9d4607 100644 --- a/libs/common/src/constants/controller-route.ts +++ b/libs/common/src/constants/controller-route.ts @@ -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 { diff --git a/libs/common/src/constants/mail-trap.ts b/libs/common/src/constants/mail-trap.ts deleted file mode 100644 index 6642244..0000000 --- a/libs/common/src/constants/mail-trap.ts +++ /dev/null @@ -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'; diff --git a/libs/common/src/database/database.module.ts b/libs/common/src/database/database.module.ts index 3038c35..6aab302 100644 --- a/libs/common/src/database/database.module.ts +++ b/libs/common/src/database/database.module.ts @@ -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'))), diff --git a/libs/common/src/modules/booking/booking.repository.module.ts b/libs/common/src/modules/booking/booking.repository.module.ts index fb5601b..7cf3ed6 100644 --- a/libs/common/src/modules/booking/booking.repository.module.ts +++ b/libs/common/src/modules/booking/booking.repository.module.ts @@ -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 {} diff --git a/libs/common/src/modules/booking/entities/booking.entity.ts b/libs/common/src/modules/booking/entities/booking.entity.ts new file mode 100644 index 0000000..2c0e88d --- /dev/null +++ b/libs/common/src/modules/booking/entities/booking.entity.ts @@ -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; +} diff --git a/libs/common/src/modules/booking/entities/index.ts b/libs/common/src/modules/booking/entities/index.ts deleted file mode 100644 index 7a921ce..0000000 --- a/libs/common/src/modules/booking/entities/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './bookable-space.entity'; diff --git a/libs/common/src/modules/booking/repositories/bookable-space.repository.ts b/libs/common/src/modules/booking/repositories/bookable-space.repository.ts new file mode 100644 index 0000000..87a1c49 --- /dev/null +++ b/libs/common/src/modules/booking/repositories/bookable-space.repository.ts @@ -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 { + constructor(private dataSource: DataSource) { + super(BookableSpaceEntity, dataSource.createEntityManager()); + } +} diff --git a/libs/common/src/modules/booking/repositories/booking.repository.ts b/libs/common/src/modules/booking/repositories/booking.repository.ts index 87a1c49..9b6af87 100644 --- a/libs/common/src/modules/booking/repositories/booking.repository.ts +++ b/libs/common/src/modules/booking/repositories/booking.repository.ts @@ -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 { +export class BookingEntityRepository extends Repository { constructor(private dataSource: DataSource) { - super(BookableSpaceEntity, dataSource.createEntityManager()); + super(BookingEntity, dataSource.createEntityManager()); } } diff --git a/libs/common/src/modules/booking/repositories/index.ts b/libs/common/src/modules/booking/repositories/index.ts deleted file mode 100644 index ab4b40c..0000000 --- a/libs/common/src/modules/booking/repositories/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './booking.repository'; diff --git a/libs/common/src/modules/space/entities/space.entity.ts b/libs/common/src/modules/space/entities/space.entity.ts index 6a04865..f895de9 100644 --- a/libs/common/src/modules/space/entities/space.entity.ts +++ b/libs/common/src/modules/space/entities/space.entity.ts @@ -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 { @@ -132,6 +133,9 @@ export class SpaceEntity extends AbstractEntity { @OneToOne(() => BookableSpaceEntity, (bookable) => bookable.space) bookableConfig: BookableSpaceEntity; + @OneToMany(() => BookingEntity, (booking) => booking.space) + bookings: BookingEntity[]; + constructor(partial: Partial) { super(); Object.assign(this, partial); diff --git a/libs/common/src/modules/user/entities/user.entity.ts b/libs/common/src/modules/user/entities/user.entity.ts index 87b5e16..fe6aa27 100644 --- a/libs/common/src/modules/user/entities/user.entity.ts +++ b/libs/common/src/modules/user/entities/user.entity.ts @@ -29,6 +29,7 @@ import { UserOtpDto, UserSpaceDto, } from '../dtos'; +import { BookingEntity } from '../../booking/entities/booking.entity'; @Entity({ name: 'user' }) export class UserEntity extends AbstractEntity { @@ -121,6 +122,9 @@ export class UserEntity extends AbstractEntity { ) deviceUserNotification: DeviceNotificationEntity[]; + @OneToMany(() => BookingEntity, (booking) => booking.user) + bookings: BookingEntity[]; + @ManyToOne(() => RegionEntity, (region) => region.users, { nullable: true }) region: RegionEntity; @ManyToOne(() => TimeZoneEntity, (timezone) => timezone.users, { diff --git a/libs/common/src/util/email/batch-email.interface.ts b/libs/common/src/util/email/batch-email.interface.ts new file mode 100644 index 0000000..834acb0 --- /dev/null +++ b/libs/common/src/util/email/batch-email.interface.ts @@ -0,0 +1,8 @@ +export interface BatchEmailData { + base: { from: { email: string }; template_uuid: string }; + requests: Array<{ + to: { email: string }[]; + template_variables: Record; + }>; + isBatch: true; +} diff --git a/libs/common/src/util/email.service.ts b/libs/common/src/util/email/email.service.ts similarity index 60% rename from libs/common/src/util/email.service.ts rename to libs/common/src/util/email/email.service.ts index 0a9b1f2..e72af59 100644 --- a/libs/common/src/util/email.service.ts +++ b/libs/common/src/util/email/email.service.ts @@ -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('email-config.SMTP_PASSWORD'), }, }; + this.API_TOKEN = this.configService.get( + 'email-config.MAILTRAP_API_TOKEN', + ); + this.SEND_EMAIL_API_URL = this.configService.get( + 'email-config.SEND_EMAIL_API_URL', + ); + this.BATCH_EMAIL_API_URL = this.configService.get( + 'email-config.BATCH_EMAIL_API_URL', + ); } async sendEmail( @@ -31,7 +42,7 @@ export class EmailService { ): Promise { 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 { - const isProduction = process.env.NODE_ENV === 'production'; - const API_TOKEN = this.configService.get( - 'email-config.MAILTRAP_API_TOKEN', - ); - const API_URL = isProduction - ? SEND_EMAIL_API_URL_PROD - : SEND_EMAIL_API_URL_DEV; const TEMPLATE_UUID = this.configService.get( 'email-config.MAILTRAP_INVITATION_TEMPLATE_UUID', ); @@ -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 { - const isProduction = process.env.NODE_ENV === 'production'; - const API_TOKEN = this.configService.get( - 'email-config.MAILTRAP_API_TOKEN', - ); - const API_URL = isProduction - ? SEND_EMAIL_API_URL_PROD - : SEND_EMAIL_API_URL_DEV; - // Determine the template UUID based on the arguments const templateUuid = isDelete ? this.configService.get( @@ -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 { - const isProduction = process.env.NODE_ENV === 'production'; - const API_TOKEN = this.configService.get( - 'email-config.MAILTRAP_API_TOKEN', - ); - const API_URL = isProduction - ? SEND_EMAIL_API_URL_PROD - : SEND_EMAIL_API_URL_DEV; const TEMPLATE_UUID = this.configService.get( 'email-config.MAILTRAP_EDIT_USER_TEMPLATE_UUID', ); @@ -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 { - const isProduction = process.env.NODE_ENV === 'production'; - const API_TOKEN = this.configService.get( - 'email-config.MAILTRAP_API_TOKEN', - ); - const API_URL = isProduction - ? SEND_EMAIL_API_URL_PROD - : SEND_EMAIL_API_URL_DEV; const TEMPLATE_UUID = this.configService.get( 'email-config.MAILTRAP_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 { + const TEMPLATE_UUID = this.configService.get( + '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 { + const TEMPLATE_UUID = this.configService.get( + '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 { + 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, + ); + } + } } diff --git a/libs/common/src/util/email/single-email.interface.ts b/libs/common/src/util/email/single-email.interface.ts new file mode 100644 index 0000000..a7a14e2 --- /dev/null +++ b/libs/common/src/util/email/single-email.interface.ts @@ -0,0 +1,7 @@ +export interface SingleEmailData { + from: { email: string }; + to: { email: string }[]; + template_uuid: string; + template_variables?: Record; + isBatch: false; +} diff --git a/libs/common/src/util/time-to-12-hours-convetion.ts b/libs/common/src/util/time-to-12-hours-convetion.ts new file mode 100644 index 0000000..0f6ab5f --- /dev/null +++ b/libs/common/src/util/time-to-12-hours-convetion.ts @@ -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'); +} diff --git a/package-lock.json b/package-lock.json index 04c97a0..39a752b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "class-validator": "^0.14.1", "crypto-js": "^4.2.0", "csv-parser": "^3.2.0", + "date-fns": "^4.1.0", "express-rate-limit": "^7.1.5", "firebase": "^10.12.5", "google-auth-library": "^9.14.1", @@ -40,7 +41,7 @@ "morgan": "^1.10.0", "nest-winston": "^1.10.2", "node-cache": "^5.1.2", - "nodemailer": "^6.9.10", + "nodemailer": "^7.0.5", "onesignal-node": "^3.4.0", "passport-jwt": "^4.0.1", "pg": "^8.11.3", @@ -60,6 +61,7 @@ "@types/jest": "^29.5.2", "@types/multer": "^1.4.12", "@types/node": "^20.3.1", + "@types/nodemailer": "^6.4.17", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", @@ -3366,6 +3368,15 @@ "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": { "version": "6.14.0", "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" } }, + "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": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -5360,19 +5387,12 @@ } }, "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" - }, + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, "node_modules/dayjs": { @@ -9784,9 +9804,9 @@ "dev": true }, "node_modules/nodemailer": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", - "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz", + "integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==", "engines": { "node": ">=6.0.0" } diff --git a/package.json b/package.json index b496f90..f0bc8cc 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "class-validator": "^0.14.1", "crypto-js": "^4.2.0", "csv-parser": "^3.2.0", + "date-fns": "^4.1.0", "express-rate-limit": "^7.1.5", "firebase": "^10.12.5", "google-auth-library": "^9.14.1", @@ -52,7 +53,7 @@ "morgan": "^1.10.0", "nest-winston": "^1.10.2", "node-cache": "^5.1.2", - "nodemailer": "^6.9.10", + "nodemailer": "^7.0.5", "onesignal-node": "^3.4.0", "passport-jwt": "^4.0.1", "pg": "^8.11.3", @@ -72,6 +73,7 @@ "@types/jest": "^29.5.2", "@types/multer": "^1.4.12", "@types/node": "^20.3.1", + "@types/nodemailer": "^6.4.17", "@types/supertest": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index df23572..3fae71d 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -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 { ConfigModule } from '@nestjs/config'; -import { UserRepository } from '@app/common/modules/user/repositories'; -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 { JwtService } from '@nestjs/jwt'; import { RoleService } from 'src/role/services'; import { UserAuthController } from './controllers'; 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({ imports: [ConfigModule], diff --git a/src/auth/services/user-auth.service.ts b/src/auth/services/user-auth.service.ts index 8f6f56c..abf8942 100644 --- a/src/auth/services/user-auth.service.ts +++ b/src/auth/services/user-auth.service.ts @@ -16,7 +16,7 @@ import { UserSessionRepository } from '../../../libs/common/src/modules/session/ 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.service'; +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'; diff --git a/src/booking/booking.module.ts b/src/booking/booking.module.ts index fc9b2db..490f96b 100644 --- a/src/booking/booking.module.ts +++ b/src/booking/booking.module.ts @@ -1,17 +1,29 @@ -import { Global, Module } from '@nestjs/common'; -import { BookableSpaceController } from './controllers'; -import { BookableSpaceService } from './services'; -import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories'; +import { BookingRepositoryModule } from '@app/common/modules/booking/booking.repository.module'; +import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories/bookable-space.repository'; +import { BookingEntityRepository } from '@app/common/modules/booking/repositories/booking.repository'; 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() @Module({ - controllers: [BookableSpaceController], + imports: [BookingRepositoryModule], + controllers: [BookableSpaceController, BookingController], providers: [ BookableSpaceService, + BookingService, + EmailService, BookableSpaceEntityRepository, + BookingEntityRepository, + SpaceRepository, + UserRepository, ], - exports: [BookableSpaceService], + exports: [BookableSpaceService, BookingService], }) export class BookingModule {} diff --git a/src/booking/controllers/bookable-space.controller.ts b/src/booking/controllers/bookable-space.controller.ts index 876976b..99452f6 100644 --- a/src/booking/controllers/bookable-space.controller.ts +++ b/src/booking/controllers/bookable-space.controller.ts @@ -19,11 +19,11 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { PageResponse } from '@app/common/dto/pagination.response.dto'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; import { plainToInstance } from 'class-transformer'; -import { CreateBookableSpaceDto } from '../dtos'; import { BookableSpaceRequestDto } from '../dtos/bookable-space-request.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 { BookableSpaceService } from '../services'; +import { BookableSpaceService } from '../services/bookable-space.service'; @ApiTags('Booking Module') @Controller({ diff --git a/src/booking/controllers/booking.controller.ts b/src/booking/controllers/booking.controller.ts new file mode 100644 index 0000000..bdbe516 --- /dev/null +++ b/src/booking/controllers/booking.controller.ts @@ -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 { + 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 { + 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 { + 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', + }); + } +} diff --git a/src/booking/controllers/index.ts b/src/booking/controllers/index.ts deleted file mode 100644 index d239862..0000000 --- a/src/booking/controllers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './bookable-space.controller'; diff --git a/src/booking/dtos/booking-request.dto.ts b/src/booking/dtos/booking-request.dto.ts new file mode 100644 index 0000000..dd61d14 --- /dev/null +++ b/src/booking/dtos/booking-request.dto.ts @@ -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; +} diff --git a/src/booking/dtos/booking-response.dto.ts b/src/booking/dtos/booking-response.dto.ts new file mode 100644 index 0000000..a8a0bc7 --- /dev/null +++ b/src/booking/dtos/booking-response.dto.ts @@ -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; +} diff --git a/src/booking/dtos/create-booking.dto.ts b/src/booking/dtos/create-booking.dto.ts new file mode 100644 index 0000000..4f453b2 --- /dev/null +++ b/src/booking/dtos/create-booking.dto.ts @@ -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; +} diff --git a/src/booking/dtos/index.ts b/src/booking/dtos/index.ts deleted file mode 100644 index c56d66c..0000000 --- a/src/booking/dtos/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './create-bookable-space.dto'; diff --git a/src/booking/dtos/my-booking-request.dto.ts b/src/booking/dtos/my-booking-request.dto.ts new file mode 100644 index 0000000..402aa36 --- /dev/null +++ b/src/booking/dtos/my-booking-request.dto.ts @@ -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'; +} diff --git a/src/booking/services/bookable-space.service.ts b/src/booking/services/bookable-space.service.ts index 0379214..c497f83 100644 --- a/src/booking/services/bookable-space.service.ts +++ b/src/booking/services/bookable-space.service.ts @@ -2,24 +2,30 @@ import { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { PageResponseDto } from '@app/common/dto/pagination.response.dto'; import { timeToMinutes } from '@app/common/helper/timeToMinutes'; 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 { 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 { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; -import { In } from 'typeorm'; -import { CreateBookableSpaceDto } from '../dtos'; +import { format } from 'date-fns'; +import { Brackets, In } from 'typeorm'; import { BookableSpaceRequestDto } from '../dtos/bookable-space-request.dto'; +import { CreateBookableSpaceDto } from '../dtos/create-bookable-space.dto'; import { UpdateBookableSpaceDto } from '../dtos/update-bookable-space.dto'; @Injectable() export class BookableSpaceService { constructor( + private readonly emailService: EmailService, private readonly bookableSpaceEntityRepository: BookableSpaceEntityRepository, + private readonly bookingEntityRepository: BookingEntityRepository, private readonly spaceRepository: SpaceRepository, ) {} @@ -84,8 +90,7 @@ export class BookableSpaceService { } /** - * todo: if updating availability, send to the ones who have access to this space - * todo: if updating other fields, just send emails to all users who's bookings might be affected + * Update bookable space configuration */ async update(spaceUuid: string, dto: UpdateBookableSpaceDto) { // fetch spaces exist @@ -102,11 +107,179 @@ export class BookableSpaceService { dto.startTime || space.bookableConfig.startTime, 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); return this.bookableSpaceEntityRepository.save(space.bookableConfig); } + private async handleTimingUpdate( + dto: UpdateBookableSpaceDto, + space: SpaceEntity, + ): Promise { + 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 { + 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 */ diff --git a/src/booking/services/booking.service.ts b/src/booking/services/booking.service.ts new file mode 100644 index 0000000..d11b25a --- /dev/null +++ b/src/booking/services/booking.service.ts @@ -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 { + 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); + } +} diff --git a/src/booking/services/index.ts b/src/booking/services/index.ts deleted file mode 100644 index f8a6199..0000000 --- a/src/booking/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './bookable-space.service'; diff --git a/src/invite-user/invite-user.module.ts b/src/invite-user/invite-user.module.ts index 36dbbb5..2b528f6 100644 --- a/src/invite-user/invite-user.module.ts +++ b/src/invite-user/invite-user.module.ts @@ -9,6 +9,7 @@ import { OccupancyService } from '@app/common/helper/services/occupancy.service' 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 { AqiSpaceDailyPollutantStatsRepository } from '@app/common/modules/aqi/repositories'; import { AutomationRepository } from '@app/common/modules/automation/repositories'; import { CommunityRepository } from '@app/common/modules/community/repositories'; import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories'; @@ -27,6 +28,7 @@ import { 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 { RegionRepository } from '@app/common/modules/region/repositories'; @@ -57,7 +59,7 @@ import { UserRepository, UserSpaceRepository, } 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 { CommunityService } from 'src/community/services'; import { DeviceService } from 'src/device/services'; @@ -81,8 +83,6 @@ import { SubspaceProductAllocationService } from 'src/space/services/subspace/su import { TagService as NewTagService } from 'src/tags/services'; import { UserDevicePermissionService } from 'src/user-device-permission/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({ imports: [ConfigModule, InviteUserRepositoryModule, CommunityModule], diff --git a/src/invite-user/services/invite-user.service.ts b/src/invite-user/services/invite-user.service.ts index 10f1e5e..d9b4b66 100644 --- a/src/invite-user/services/invite-user.service.ts +++ b/src/invite-user/services/invite-user.service.ts @@ -15,7 +15,7 @@ import { SpaceRepository } from '@app/common/modules/space'; import { SpaceEntity } from '@app/common/modules/space/entities/space.entity'; import { UserEntity } from '@app/common/modules/user/entities'; 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 { BadRequestException, HttpException, diff --git a/src/vistor-password/services/visitor-password.service.ts b/src/vistor-password/services/visitor-password.service.ts index d7ce937..267c65f 100644 --- a/src/vistor-password/services/visitor-password.service.ts +++ b/src/vistor-password/services/visitor-password.service.ts @@ -28,7 +28,7 @@ import { PasswordType } from '@app/common/constants/password-type.enum'; import { VisitorPasswordEnum } from '@app/common/constants/visitor-password.enum'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; 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 { DoorLockService } from 'src/door-lock/services'; import { PasswordEncryptionService } from 'src/door-lock/services/encryption.services'; diff --git a/src/vistor-password/visitor-password.module.ts b/src/vistor-password/visitor-password.module.ts index b1d927c..6599d45 100644 --- a/src/vistor-password/visitor-password.module.ts +++ b/src/vistor-password/visitor-password.module.ts @@ -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 { SpaceRepository } from '@app/common/modules/space/repositories'; -import { VisitorPasswordRepository } from '@app/common/modules/visitor-password/repositories'; -import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories'; +import { AqiDataService } from '@app/common/helper/services/aqi.data.service'; +import { OccupancyService } from '@app/common/helper/services/occupancy.service'; +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 { 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 { SceneIconRepository, SceneRepository, } from '@app/common/modules/scene/repositories'; -import { SceneDeviceRepository } from '@app/common/modules/scene-device/repositories'; -import { AutomationRepository } from '@app/common/modules/automation/repositories'; -import { ProjectRepository } from '@app/common/modules/project/repositiories'; -import { PowerClampService } from '@app/common/helper/services/power.clamp.service'; -import { - PowerClampHourlyRepository, - PowerClampDailyRepository, - PowerClampMonthlyRepository, -} from '@app/common/modules/power-clamp/repositories'; -import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service'; -import { OccupancyService } from '@app/common/helper/services/occupancy.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'; +import { SpaceRepository } from '@app/common/modules/space/repositories'; +import { VisitorPasswordRepository } from '@app/common/modules/visitor-password/repositories'; +import { EmailService } from '@app/common/util/email/email.service'; +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { DeviceService } from 'src/device/services'; +import { DoorLockModule } from 'src/door-lock/door.lock.module'; +import { PasswordEncryptionService } from 'src/door-lock/services/encryption.services'; +import { SceneService } from 'src/scene/services'; +import { VisitorPasswordController } from './controllers/visitor-password.controller'; +import { VisitorPasswordService } from './services/visitor-password.service'; @Module({ imports: [ConfigModule, DeviceRepositoryModule, DoorLockModule], controllers: [VisitorPasswordController],