mirror of
https://github.com/SyncrowIOT/backend.git
synced 2025-07-15 02:15:21 +00:00
Compare commits
6 Commits
fix/remove
...
feat/imple
Author | SHA1 | Date | |
---|---|---|---|
990ef11c8b | |||
2efdbb6de8 | |||
a585eea329 | |||
b026fa593b | |||
48a6d15c96 | |||
a8d33bbc52 |
@ -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,
|
||||
}),
|
||||
);
|
||||
|
@ -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 {
|
||||
|
@ -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';
|
@ -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'))),
|
||||
|
@ -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 {}
|
||||
|
44
libs/common/src/modules/booking/entities/booking.entity.ts
Normal file
44
libs/common/src/modules/booking/entities/booking.entity.ts
Normal 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;
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './bookable-space.entity';
|
@ -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());
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
export * from './booking.repository';
|
@ -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> {
|
||||
@ -75,6 +76,13 @@ export class SpaceEntity extends AbstractEntity<SpaceDto> {
|
||||
})
|
||||
subspaces?: SubspaceEntity[];
|
||||
|
||||
// Position columns
|
||||
@Column({ type: 'float', nullable: false, default: 0 })
|
||||
public x: number; // X coordinate for position
|
||||
|
||||
@Column({ type: 'float', nullable: false, default: 0 })
|
||||
public y: number; // Y coordinate for position
|
||||
|
||||
@OneToMany(
|
||||
() => DeviceEntity,
|
||||
(devicesSpaceEntity) => devicesSpaceEntity.spaceDevice,
|
||||
@ -125,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);
|
||||
|
@ -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, {
|
||||
|
@ -1,15 +1,15 @@
|
||||
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';
|
||||
|
||||
@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 +22,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 +40,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 +53,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 +70,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 +87,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 +113,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 +140,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 +166,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 +280,47 @@ 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface BatchEmailData {
|
||||
base: { from: { email: string }; template_uuid: string };
|
||||
requests: Array<{
|
||||
to: { email: string }[];
|
||||
template_variables: Record<string, any>;
|
||||
}>;
|
||||
isBatch: true;
|
||||
}
|
||||
|
||||
interface SingleEmailData {
|
||||
from: { email: string };
|
||||
to: { email: string }[];
|
||||
template_uuid: string;
|
||||
template_variables?: Record<string, any>;
|
||||
isBatch: false;
|
||||
}
|
||||
|
7
libs/common/src/util/time-to-12-hours-convetion.ts
Normal file
7
libs/common/src/util/time-to-12-hours-convetion.ts
Normal 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
52
package-lock.json
generated
@ -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"
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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.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 {}
|
||||
|
@ -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({
|
||||
|
107
src/booking/controllers/booking.controller.ts
Normal file
107
src/booking/controllers/booking.controller.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './bookable-space.controller';
|
14
src/booking/dtos/booking-request.dto.ts
Normal file
14
src/booking/dtos/booking-request.dto.ts
Normal 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;
|
||||
}
|
88
src/booking/dtos/booking-response.dto.ts
Normal file
88
src/booking/dtos/booking-response.dto.ts
Normal 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;
|
||||
}
|
35
src/booking/dtos/create-booking.dto.ts
Normal file
35
src/booking/dtos/create-booking.dto.ts
Normal 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;
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './create-bookable-space.dto';
|
14
src/booking/dtos/my-booking-request.dto.ts
Normal file
14
src/booking/dtos/my-booking-request.dto.ts
Normal 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';
|
||||
}
|
@ -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.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<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
|
||||
*/
|
||||
|
215
src/booking/services/booking.service.ts
Normal file
215
src/booking/services/booking.service.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './bookable-space.service';
|
@ -6,6 +6,7 @@ import {
|
||||
IsArray,
|
||||
IsMongoId,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
@ -47,6 +48,14 @@ export class AddSpaceDto {
|
||||
@IsOptional()
|
||||
public icon?: string;
|
||||
|
||||
@ApiProperty({ description: 'X position on canvas', example: 120 })
|
||||
@IsNumber()
|
||||
x: number;
|
||||
|
||||
@ApiProperty({ description: 'Y position on canvas', example: 200 })
|
||||
@IsNumber()
|
||||
y: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'UUID of the Space Model',
|
||||
example: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
|
||||
|
@ -4,6 +4,7 @@ import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
NotEquals,
|
||||
@ -35,6 +36,16 @@ export class UpdateSpaceDto {
|
||||
@IsOptional()
|
||||
public icon?: string;
|
||||
|
||||
@ApiProperty({ description: 'X position on canvas', example: 120 })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
x?: number;
|
||||
|
||||
@ApiProperty({ description: 'Y position on canvas', example: 200 })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
y?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'List of subspace modifications',
|
||||
type: [UpdateSubspaceDto],
|
||||
|
@ -93,9 +93,6 @@ export class SpaceService {
|
||||
parentUuid && !isRecursiveCall
|
||||
? await this.validationService.validateSpace(parentUuid)
|
||||
: null;
|
||||
if (parent) {
|
||||
await this.validateNamingConflict(addSpaceDto.spaceName, parent);
|
||||
}
|
||||
|
||||
const spaceModel = spaceModelUuid
|
||||
? await this.validationService.validateSpaceModel(spaceModelUuid)
|
||||
@ -105,6 +102,8 @@ export class SpaceService {
|
||||
// todo: find a better way to handle this instead of naming every key
|
||||
spaceName: addSpaceDto.spaceName,
|
||||
icon: addSpaceDto.icon,
|
||||
x: addSpaceDto.x,
|
||||
y: addSpaceDto.y,
|
||||
spaceModel,
|
||||
parent: isRecursiveCall
|
||||
? recursiveCallParentEntity
|
||||
@ -506,8 +505,6 @@ export class SpaceService {
|
||||
spaceUuid,
|
||||
);
|
||||
|
||||
await this.validateNamingConflict(updateSpaceDto.spaceName, space, true);
|
||||
|
||||
if (space.spaceModel && !updateSpaceDto.spaceModelUuid) {
|
||||
await queryRunner.manager.update(SpaceEntity, space.uuid, {
|
||||
spaceModel: null,
|
||||
@ -658,11 +655,13 @@ export class SpaceService {
|
||||
updateSpaceDto: UpdateSpaceDto,
|
||||
queryRunner: QueryRunner,
|
||||
): Promise<void> {
|
||||
const { spaceName, icon } = updateSpaceDto;
|
||||
const { spaceName, x, y, icon } = updateSpaceDto;
|
||||
|
||||
const updateFields: Partial<SpaceEntity> = {};
|
||||
|
||||
if (spaceName) updateFields.spaceName = spaceName;
|
||||
if (x !== undefined) updateFields.x = x;
|
||||
if (y !== undefined) updateFields.y = y;
|
||||
if (icon) updateFields.icon = icon;
|
||||
|
||||
if (Object.keys(updateFields).length > 0) {
|
||||
@ -829,34 +828,4 @@ export class SpaceService {
|
||||
queryRunner,
|
||||
);
|
||||
}
|
||||
|
||||
async validateNamingConflict(
|
||||
newSpaceName: string,
|
||||
parent: SpaceEntity,
|
||||
isUpdate: boolean = false,
|
||||
): Promise<void> {
|
||||
if (!isUpdate && parent.spaceName === newSpaceName) {
|
||||
throw new HttpException(
|
||||
`Space can't be created with the same name as its parent space`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
if (parent.children?.some((child) => child.spaceName === newSpaceName)) {
|
||||
throw new HttpException(
|
||||
`Space name cannot be the same as one of its siblings/children`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
if (isUpdate) {
|
||||
const sibling = await this.spaceRepository.exists({
|
||||
where: { spaceName: newSpaceName, parent: { uuid: parent.uuid } },
|
||||
});
|
||||
if (sibling) {
|
||||
throw new HttpException(
|
||||
`Space name cannot be the same as one of its siblings/children`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user