Compare commits

..

8 Commits

Author SHA1 Message Date
434d9dd421 refactor: remove booking module and related files 2025-07-15 23:19:50 -06:00
e849c0e6d8 refactor: remove AQI module and related files 2025-07-15 19:00:53 -06:00
e63c0434ab refactor: remove occupancy module and related files 2025-07-15 18:55:43 -06:00
ee489633ab Test file to trigger AI PR description 2025-07-15 18:53:03 -06:00
fffa27b6ee Add AI PR Description Action 2025-07-15 18:48:43 -06:00
12579fcd6e fix nodemailer import (#473) 2025-07-15 16:46:50 +03:00
036c8bea17 task: add space filter to bookings (#471) 2025-07-15 13:31:56 +03:00
e5970c02c1 SP-1757, SP-1758, SP-1809, SP-1810: Feat/implement booking (#469)
* fix: commission device API

* task: add create booking API

* add get All api for dashboard & mobile

* add Find APIs for bookings

* implement sending email updates on update bookable space

* move email interfaces to separate files
2025-07-15 10:11:36 +03:00
55 changed files with 428 additions and 1173 deletions

54
.github/workflows/pr-description.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: 🤖 AI PR Description Generator (with Template)
on:
pull_request:
types: [opened, edited]
jobs:
generate-description:
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v4
- name: Install GitHub CLI
uses: cli/cli-action@v2
- name: Fetch PR Commits
id: fetch_commits
run: |
COMMITS=$(gh pr view ${{ github.event.pull_request.number }} --json commits --jq '.commits[].message' | sed 's/^/- /')
echo "commits<<EOF" >> $GITHUB_ENV
echo "$COMMITS" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Generate PR Description with OpenAI
id: generate_description
run: |
RESPONSE=$(curl -s https://api.openai.com/v1/chat/completions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o",
"messages": [{
"role": "user",
"content": "Given the following commit messages:\n\n'"${commits}"'\n\nFill the following pull request template. Only fill the \"## Description\" section:\n\n<!--\n Thanks for contributing!\n\n Provide a description of your changes below and a general summary in the title.\n-->\n\n## Jira Ticket\n\n[SP-0000](https://syncrow.atlassian.net/browse/SP-0000)\n\n## Description\n\n<!--- Describe your changes in detail -->\n\n## How to Test\n\n<!--- Describe the created APIs / Logic -->"
}]
}')
DESCRIPTION=$(echo "$RESPONSE" | jq -r '.choices[0].message.content')
echo "description<<EOF" >> $GITHUB_ENV
echo "$DESCRIPTION" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
commits: ${{ env.commits }}
- name: Update PR Body with AI Description
run: |
gh pr edit ${{ github.event.pull_request.number }} --body "${{ env.description }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

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

View File

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

View File

@ -91,6 +91,25 @@ export class ControllerRoute {
'This endpoint allows you to update existing bookable spaces by providing the required details.';
};
};
static BOOKING = class {
public static readonly ROUTE = 'bookings';
static ACTIONS = class {
public static readonly ADD_BOOKING_SUMMARY = 'Add new booking';
public static readonly ADD_BOOKING_DESCRIPTION =
'This endpoint allows you to add new booking by providing the required details.';
public static readonly GET_ALL_BOOKINGS_SUMMARY = 'Get all bookings';
public static readonly GET_ALL_BOOKINGS_DESCRIPTION =
'This endpoint retrieves all bookings.';
public static readonly GET_MY_BOOKINGS_SUMMARY = 'Get my bookings';
public static readonly GET_MY_BOOKINGS_DESCRIPTION =
'This endpoint retrieves all bookings for the authenticated user.';
};
};
static COMMUNITY = class {
public static readonly ROUTE = '/projects/:projectUuid/communities';
static ACTIONS = class {

View File

@ -1,3 +0,0 @@
export const SEND_EMAIL_API_URL_PROD = 'https://send.api.mailtrap.io/api/send/';
export const SEND_EMAIL_API_URL_DEV =
'https://sandbox.api.mailtrap.io/api/send/2634012';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ import {
} from 'typeorm';
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
import { AqiSpaceDailyPollutantStatsEntity } from '../../aqi/entities';
import { BookableSpaceEntity } from '../../booking/entities';
import { BookableSpaceEntity } from '../../booking/entities/bookable-space.entity';
import { CommunityEntity } from '../../community/entities';
import { DeviceEntity } from '../../device/entities';
import { InviteUserSpaceEntity } from '../../Invite-user/entities';
@ -20,6 +20,7 @@ import { UserSpaceEntity } from '../../user/entities';
import { SpaceDto } from '../dtos';
import { SpaceProductAllocationEntity } from './space-product-allocation.entity';
import { SubspaceEntity } from './subspace/subspace.entity';
import { BookingEntity } from '../../booking/entities/booking.entity';
@Entity({ name: 'space' })
export class SpaceEntity extends AbstractEntity<SpaceDto> {
@ -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);

View File

@ -29,6 +29,7 @@ import {
UserOtpDto,
UserSpaceDto,
} from '../dtos';
import { BookingEntity } from '../../booking/entities/booking.entity';
@Entity({ name: 'user' })
export class UserEntity extends AbstractEntity<UserDto> {
@ -121,6 +122,9 @@ export class UserEntity extends AbstractEntity<UserDto> {
)
deviceUserNotification: DeviceNotificationEntity[];
@OneToMany(() => BookingEntity, (booking) => booking.user)
bookings: BookingEntity[];
@ManyToOne(() => RegionEntity, (region) => region.users, { nullable: true })
region: RegionEntity;
@ManyToOne(() => TimeZoneEntity, (timezone) => timezone.users, {

View File

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

View File

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

View File

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

View File

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

52
package-lock.json generated
View File

@ -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"
}

View File

@ -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",

View File

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { AqiService } from './services';
import { AqiController } from './controllers';
@Module({
imports: [ConfigModule],
controllers: [AqiController],
providers: [AqiService, SqlLoaderService],
})
export class AqiModule {}

View File

@ -1,64 +0,0 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiParam,
} from '@nestjs/swagger';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { ControllerRoute } from '@app/common/constants/controller-route';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { AqiService } from '../services/aqi.service';
import {
GetAqiDailyBySpaceDto,
GetAqiPollutantBySpaceDto,
} from '../dto/get-aqi.dto';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SpaceParamsDto } from '../dto/aqi-params.dto';
@ApiTags('AQI Module')
@Controller({
version: EnableDisableStatusEnum.ENABLED,
path: ControllerRoute.AQI.ROUTE,
})
export class AqiController {
constructor(private readonly aqiService: AqiService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('range/space/:spaceUuid')
@ApiOperation({
summary: ControllerRoute.AQI.ACTIONS.GET_AQI_RANGE_DATA_SUMMARY,
description: ControllerRoute.AQI.ACTIONS.GET_AQI_RANGE_DATA_DESCRIPTION,
})
@ApiParam({
name: 'spaceUuid',
description: 'UUID of the Space',
required: true,
})
async getAQIRangeDataBySpace(
@Param() params: SpaceParamsDto,
@Query() query: GetAqiDailyBySpaceDto,
): Promise<BaseResponseDto> {
return await this.aqiService.getAQIRangeDataBySpace(params, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('distribution/space/:spaceUuid')
@ApiOperation({
summary: ControllerRoute.AQI.ACTIONS.GET_AQI_DISTRIBUTION_DATA_SUMMARY,
description:
ControllerRoute.AQI.ACTIONS.GET_AQI_DISTRIBUTION_DATA_DESCRIPTION,
})
@ApiParam({
name: 'spaceUuid',
description: 'UUID of the Space',
required: true,
})
async getAQIDistributionDataBySpace(
@Param() params: SpaceParamsDto,
@Query() query: GetAqiPollutantBySpaceDto,
): Promise<BaseResponseDto> {
return await this.aqiService.getAQIDistributionDataBySpace(params, query);
}
}

View File

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

View File

@ -1,7 +0,0 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
export class SpaceParamsDto {
@IsUUID('4', { message: 'Invalid UUID format' })
@IsNotEmpty()
spaceUuid: string;
}

View File

@ -1,37 +0,0 @@
import { PollutantType } from '@app/common/constants/pollutants.enum';
import { ApiProperty } from '@nestjs/swagger';
import { Matches, IsNotEmpty, IsString } from 'class-validator';
export class GetAqiDailyBySpaceDto {
@ApiProperty({
description: 'Month and year in format YYYY-MM',
example: '2025-03',
required: true,
})
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/, {
message: 'monthDate must be in YYYY-MM format',
})
@IsNotEmpty()
monthDate: string;
}
export class GetAqiPollutantBySpaceDto {
@ApiProperty({
description: 'Pollutant Type',
enum: PollutantType,
example: PollutantType.AQI,
required: true,
})
@IsString()
@IsNotEmpty()
public pollutantType: string;
@ApiProperty({
description: 'Month and year in format YYYY-MM',
example: '2025-03',
required: true,
})
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/, {
message: 'monthDate must be in YYYY-MM format',
})
@IsNotEmpty()
monthDate: string;
}

View File

@ -1,138 +0,0 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import {
GetAqiDailyBySpaceDto,
GetAqiPollutantBySpaceDto,
} from '../dto/get-aqi.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { SpaceParamsDto } from '../dto/aqi-params.dto';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { DataSource } from 'typeorm';
import { SQL_PROCEDURES_PATH } from '@app/common/constants/sql-query-path';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { convertKeysToCamelCase } from '@app/common/helper/camelCaseConverter';
import { PollutantType } from '@app/common/constants/pollutants.enum';
@Injectable()
export class AqiService {
constructor(
private readonly sqlLoader: SqlLoaderService,
private readonly dataSource: DataSource,
) {}
async getAQIDistributionDataBySpace(
params: SpaceParamsDto,
query: GetAqiPollutantBySpaceDto,
): Promise<BaseResponseDto> {
const { monthDate, pollutantType } = query;
const { spaceUuid } = params;
try {
const data = await this.executeProcedure(
'fact_daily_space_aqi',
'proceduce_select_daily_space_aqi',
[spaceUuid, monthDate],
);
const categories = [
'good',
'moderate',
'unhealthy_sensitive',
'unhealthy',
'very_unhealthy',
'hazardous',
];
const transformedData = data.map((item) => {
const date = new Date(item.event_date).toLocaleDateString('en-CA'); // YYYY-MM-DD
const categoryData = categories.map((category) => {
const key = `${category}_${pollutantType.toLowerCase()}_percentage`;
return {
type: category,
percentage: item[key] ?? 0,
};
});
return { date, data: categoryData };
});
const response = this.buildResponse(
`AQI distribution data fetched successfully for ${spaceUuid} space and pollutant ${pollutantType}`,
transformedData,
);
return response;
} catch (error) {
console.error('Failed to fetch AQI distribution data', {
error,
spaceUuid,
});
throw new HttpException(
error.response?.message || 'Failed to fetch AQI distribution data',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async getAQIRangeDataBySpace(
params: SpaceParamsDto,
query: GetAqiDailyBySpaceDto,
): Promise<BaseResponseDto> {
const { monthDate } = query;
const { spaceUuid } = params;
try {
const data = await this.executeProcedure(
'fact_daily_space_aqi',
'proceduce_select_daily_space_aqi',
[spaceUuid, monthDate],
);
// Define pollutants dynamically
const pollutants = Object.values(PollutantType);
const transformedData = data.map((item) => {
const date = new Date(item.event_date).toLocaleDateString('en-CA'); // YYYY-MM-DD
const dailyData = pollutants.map((type) => ({
type,
min: item[`daily_min_${type}`],
max: item[`daily_max_${type}`],
average: item[`daily_avg_${type}`],
}));
return { date, data: dailyData };
});
const response = this.buildResponse(
`AQI data fetched successfully for ${spaceUuid} space`,
transformedData,
);
return convertKeysToCamelCase(response);
} catch (error) {
console.error('Failed to fetch AQI data', {
error,
spaceUuid,
});
throw new HttpException(
error.response?.message || 'Failed to fetch AQI data',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private buildResponse(message: string, data: any[]) {
return new SuccessResponseDto({
message,
data,
statusCode: HttpStatus.OK,
});
}
private async executeProcedure(
procedureFolderName: string,
procedureFileName: string,
params: (string | number | null)[],
): Promise<any[]> {
const query = this.loadQuery(procedureFolderName, procedureFileName);
return await this.dataSource.query(query, params);
}
private loadQuery(folderName: string, fileName: string): string {
return this.sqlLoader.loadQuery(folderName, fileName, SQL_PROCEDURES_PATH);
}
}

View File

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

View File

@ -1,15 +1,17 @@
import { AuthService } from '@app/common/auth/services/auth.service';
import { RoleTypeRepository } from '@app/common/modules/role-type/repositories';
import { UserSessionRepository } from '@app/common/modules/session/repositories/session.repository';
import {
UserOtpRepository,
UserRepository,
} from '@app/common/modules/user/repositories';
import { EmailService } from '@app/common/util/email/email.service';
import { Module } from '@nestjs/common';
import { 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],

View File

@ -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';

View File

@ -1,17 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { BookableSpaceController } from './controllers';
import { BookableSpaceService } from './services';
import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories';
import { SpaceRepository } from '@app/common/modules/space';
@Global()
@Module({
controllers: [BookableSpaceController],
providers: [
BookableSpaceService,
BookableSpaceEntityRepository,
SpaceRepository,
],
exports: [BookableSpaceService],
})
export class BookingModule {}

View File

@ -1,107 +0,0 @@
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,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
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 { UpdateBookableSpaceDto } from '../dtos/update-bookable-space.dto';
import { BookableSpaceService } from '../services';
@ApiTags('Booking Module')
@Controller({
version: EnableDisableStatusEnum.ENABLED,
path: ControllerRoute.BOOKABLE_SPACES.ROUTE,
})
export class BookableSpaceController {
constructor(private readonly bookableSpaceService: BookableSpaceService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post()
@ApiOperation({
summary:
ControllerRoute.BOOKABLE_SPACES.ACTIONS.ADD_BOOKABLE_SPACES_SUMMARY,
description:
ControllerRoute.BOOKABLE_SPACES.ACTIONS.ADD_BOOKABLE_SPACES_DESCRIPTION,
})
async create(@Body() dto: CreateBookableSpaceDto): Promise<BaseResponseDto> {
const result = await this.bookableSpaceService.create(dto);
return new SuccessResponseDto({
data: result,
message: 'Successfully created bookable spaces',
});
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get()
@ApiOperation({
summary:
ControllerRoute.BOOKABLE_SPACES.ACTIONS.GET_ALL_BOOKABLE_SPACES_SUMMARY,
description:
ControllerRoute.BOOKABLE_SPACES.ACTIONS
.GET_ALL_BOOKABLE_SPACES_DESCRIPTION,
})
async findAll(
@Query() query: BookableSpaceRequestDto,
@Req() req: Request,
): Promise<PageResponse<BookableSpaceResponseDto>> {
const project = req['user']?.project?.uuid;
if (!project) {
throw new Error('Project UUID is required in the request');
}
const { data, pagination } = await this.bookableSpaceService.findAll(
query,
project,
);
return new PageResponse<BookableSpaceResponseDto>(
{
data: data.map((space) =>
plainToInstance(BookableSpaceResponseDto, space, {
excludeExtraneousValues: true,
}),
),
message: 'Successfully fetched all bookable spaces',
},
pagination,
);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Put(':spaceUuid')
@ApiOperation({
summary:
ControllerRoute.BOOKABLE_SPACES.ACTIONS.UPDATE_BOOKABLE_SPACES_SUMMARY,
description:
ControllerRoute.BOOKABLE_SPACES.ACTIONS
.UPDATE_BOOKABLE_SPACES_DESCRIPTION,
})
async update(
@Param('spaceUuid', ParseUUIDPipe) spaceUuid: string,
@Body() dto: UpdateBookableSpaceDto,
): Promise<BaseResponseDto> {
const result = await this.bookableSpaceService.update(spaceUuid, dto);
return new SuccessResponseDto({
data: result,
message: 'Successfully updated bookable spaces',
});
}
}

View File

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

View File

@ -1,31 +0,0 @@
import { BooleanValues } from '@app/common/constants/boolean-values.enum';
import { PaginationRequestWithSearchGetListDto } from '@app/common/dto/pagination-with-search.request.dto';
import { ApiProperty, OmitType } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsNotEmpty, IsOptional } from 'class-validator';
export class BookableSpaceRequestDto extends OmitType(
PaginationRequestWithSearchGetListDto,
['includeSpaces'],
) {
@ApiProperty({
type: Boolean,
required: false,
})
@IsBoolean()
@IsOptional()
@Transform(({ obj }) => {
return obj.active === BooleanValues.TRUE;
})
active?: boolean;
@ApiProperty({
type: Boolean,
})
@IsBoolean()
@IsNotEmpty()
@Transform(({ obj }) => {
return obj.configured === BooleanValues.TRUE;
})
configured: boolean;
}

View File

@ -1,59 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Type } from 'class-transformer';
export class BookableSpaceConfigResponseDto {
@ApiProperty()
@Expose()
uuid: string;
@ApiProperty({
type: [String],
})
@Expose()
daysAvailable: string[];
@ApiProperty()
@Expose()
startTime: string;
@ApiProperty()
@Expose()
endTime: string;
@ApiProperty({
type: Boolean,
})
@Expose()
active: boolean;
@ApiProperty({
type: Number,
nullable: true,
})
@Expose()
points?: number;
}
export class BookableSpaceResponseDto {
@ApiProperty()
@Expose()
uuid: string;
@ApiProperty()
@Expose()
spaceUuid: string;
@ApiProperty()
@Expose()
spaceName: string;
@ApiProperty()
@Expose()
virtualLocation: string;
@ApiProperty({
type: BookableSpaceConfigResponseDto,
})
@Expose()
@Type(() => BookableSpaceConfigResponseDto)
bookableConfig: BookableSpaceConfigResponseDto;
}

View File

@ -1,63 +0,0 @@
import { DaysEnum } from '@app/common/constants/days.enum';
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
Min,
} from 'class-validator';
export class CreateBookableSpaceDto {
@ApiProperty({
type: 'string',
isArray: true,
example: [
'3fa85f64-5717-4562-b3fc-2c963f66afa6',
'4fa85f64-5717-4562-b3fc-2c963f66afa7',
],
})
@IsArray()
@ArrayMinSize(1, { message: 'At least one space must be selected' })
@IsUUID('all', { each: true, message: 'Invalid space UUID provided' })
spaceUuids: string[];
@ApiProperty({
enum: DaysEnum,
isArray: true,
example: [DaysEnum.MON, DaysEnum.WED, DaysEnum.FRI],
})
@IsArray()
@ArrayMinSize(1, { message: 'At least one day must be selected' })
@IsEnum(DaysEnum, { each: true, message: 'Invalid day provided' })
daysAvailable: DaysEnum[];
@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;
@ApiProperty({ example: 10, required: false })
@IsOptional()
@IsInt()
@Min(0, { message: 'Points cannot be negative' })
@Max(1000, { message: 'Points cannot exceed 1000' })
points?: number;
}

View File

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

View File

@ -1,12 +0,0 @@
import { ApiProperty, OmitType, PartialType } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { CreateBookableSpaceDto } from './create-bookable-space.dto';
export class UpdateBookableSpaceDto extends PartialType(
OmitType(CreateBookableSpaceDto, ['spaceUuids']),
) {
@ApiProperty({ type: Boolean })
@IsOptional()
@IsBoolean()
active?: boolean;
}

View File

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

View File

@ -1,197 +0,0 @@
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 { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
import { SpaceRepository } from '@app/common/modules/space/repositories/space.repository';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { In } from 'typeorm';
import { CreateBookableSpaceDto } from '../dtos';
import { BookableSpaceRequestDto } from '../dtos/bookable-space-request.dto';
import { UpdateBookableSpaceDto } from '../dtos/update-bookable-space.dto';
@Injectable()
export class BookableSpaceService {
constructor(
private readonly bookableSpaceEntityRepository: BookableSpaceEntityRepository,
private readonly spaceRepository: SpaceRepository,
) {}
async create(dto: CreateBookableSpaceDto) {
// Validate time slots first
this.validateTimeSlot(dto.startTime, dto.endTime);
// fetch spaces exist
const spaces = await this.getSpacesOrFindMissing(dto.spaceUuids);
// Validate no duplicate bookable configurations
await this.validateNoDuplicateBookableConfigs(dto.spaceUuids);
// Create and save bookable spaces
return this.createBookableSpaces(spaces, dto);
}
async findAll(
{ active, page, size, configured, search }: BookableSpaceRequestDto,
project: string,
): Promise<{
data: BaseResponseDto['data'];
pagination: PageResponseDto;
}> {
let qb = this.spaceRepository
.createQueryBuilder('space')
.leftJoinAndSelect('space.parent', 'parentSpace')
.leftJoinAndSelect('space.community', 'community')
.where('community.project = :project', { project });
if (search) {
qb = qb.andWhere(
'(space.spaceName ILIKE :search OR community.name ILIKE :search OR parentSpace.spaceName ILIKE :search)',
{ search: `%${search}%` },
);
}
if (configured) {
qb = qb
.leftJoinAndSelect('space.bookableConfig', 'bookableConfig')
.andWhere('bookableConfig.uuid IS NOT NULL');
if (active !== undefined) {
qb = qb.andWhere('bookableConfig.active = :active', { active });
}
} else {
qb = qb
.leftJoinAndSelect('space.bookableConfig', 'bookableConfig')
.andWhere('bookableConfig.uuid IS NULL');
}
const customModel = TypeORMCustomModel(this.spaceRepository);
const { baseResponseDto, paginationResponseDto } =
await customModel.findAll({ page, size, modelName: 'space' }, qb);
return {
data: baseResponseDto.data.map((space) => {
return {
...space,
virtualLocation: `${space.community?.name} - ${space.parent ? space.parent?.spaceName + ' - ' : ''}${space.spaceName}`,
};
}),
pagination: paginationResponseDto,
};
}
/**
* 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
*/
async update(spaceUuid: string, dto: UpdateBookableSpaceDto) {
// fetch spaces exist
const space = (await this.getSpacesOrFindMissing([spaceUuid]))[0];
if (!space.bookableConfig) {
throw new NotFoundException(
`Bookable configuration not found for space: ${spaceUuid}`,
);
}
if (dto.startTime || dto.endTime) {
// Validate time slots first
this.validateTimeSlot(
dto.startTime || space.bookableConfig.startTime,
dto.endTime || space.bookableConfig.endTime,
);
}
Object.assign(space.bookableConfig, dto);
return this.bookableSpaceEntityRepository.save(space.bookableConfig);
}
/**
* Fetch spaces by UUIDs and throw an error if any are missing
*/
private async getSpacesOrFindMissing(
spaceUuids: string[],
): Promise<SpaceEntity[]> {
const spaces = await this.spaceRepository.find({
where: { uuid: In(spaceUuids) },
relations: ['bookableConfig'],
});
if (spaces.length !== spaceUuids.length) {
const foundUuids = spaces.map((s) => s.uuid);
const missingUuids = spaceUuids.filter(
(uuid) => !foundUuids.includes(uuid),
);
throw new NotFoundException(
`Spaces not found: ${missingUuids.join(', ')}`,
);
}
return spaces;
}
/**
* Validate there are no existing bookable configurations for these spaces
*/
private async validateNoDuplicateBookableConfigs(
spaceUuids: string[],
): Promise<void> {
const existingBookables = await this.bookableSpaceEntityRepository.find({
where: { space: { uuid: In(spaceUuids) } },
relations: ['space'],
});
if (existingBookables.length > 0) {
const existingUuids = [
...new Set(existingBookables.map((b) => b.space.uuid)),
];
throw new ConflictException(
`Bookable configuration already exists for spaces: ${existingUuids.join(', ')}`,
);
}
}
/**
* 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}`,
);
}
}
/**
* Create bookable space entries after all validations pass
*/
private async createBookableSpaces(
spaces: SpaceEntity[],
dto: CreateBookableSpaceDto,
) {
try {
const entries = spaces.map((space) =>
this.bookableSpaceEntityRepository.create({
space,
daysAvailable: dto.daysAvailable,
startTime: dto.startTime,
endTime: dto.endTime,
points: dto.points,
}),
);
return this.bookableSpaceEntityRepository.save(entries);
} catch (error) {
if (error.code === '23505') {
throw new ConflictException(
'Duplicate bookable space configuration detected',
);
}
throw error;
}
}
}

View File

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

View File

@ -9,6 +9,7 @@ import { OccupancyService } from '@app/common/helper/services/occupancy.service'
import { PowerClampService } from '@app/common/helper/services/power.clamp.service';
import { 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],

View File

@ -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,

View File

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

View File

@ -1,71 +0,0 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import {
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiParam,
} from '@nestjs/swagger';
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
import { ControllerRoute } from '@app/common/constants/controller-route';
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
import { OccupancyService } from '../services/occupancy.service';
import {
GetOccupancyDurationBySpaceDto,
GetOccupancyHeatMapBySpaceDto,
} from '../dto/get-occupancy.dto';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
import { SpaceParamsDto } from '../dto/occupancy-params.dto';
@ApiTags('Occupancy Module')
@Controller({
version: EnableDisableStatusEnum.ENABLED,
path: ControllerRoute.Occupancy.ROUTE,
})
export class OccupancyController {
constructor(private readonly occupancyService: OccupancyService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('heat-map/space/:spaceUuid')
@ApiOperation({
summary: ControllerRoute.Occupancy.ACTIONS.GET_OCCUPANCY_HEAT_MAP_SUMMARY,
description:
ControllerRoute.Occupancy.ACTIONS.GET_OCCUPANCY_HEAT_MAP_DESCRIPTION,
})
@ApiParam({
name: 'spaceUuid',
description: 'UUID of the Space',
required: true,
})
async getOccupancyHeatMapDataBySpace(
@Param() params: SpaceParamsDto,
@Query() query: GetOccupancyHeatMapBySpaceDto,
): Promise<BaseResponseDto> {
return await this.occupancyService.getOccupancyHeatMapDataBySpace(
params,
query,
);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get('duration/space/:spaceUuid')
@ApiOperation({
summary: ControllerRoute.Occupancy.ACTIONS.GET_OCCUPANCY_HEAT_MAP_SUMMARY,
description:
ControllerRoute.Occupancy.ACTIONS.GET_OCCUPANCY_HEAT_MAP_DESCRIPTION,
})
@ApiParam({
name: 'spaceUuid',
description: 'UUID of the Space',
required: true,
})
async getOccupancyDurationDataBySpace(
@Param() params: SpaceParamsDto,
@Query() query: GetOccupancyDurationBySpaceDto,
): Promise<BaseResponseDto> {
return await this.occupancyService.getOccupancyDurationDataBySpace(
params,
query,
);
}
}

View File

@ -1,27 +0,0 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Matches, IsNotEmpty } from 'class-validator';
export class GetOccupancyHeatMapBySpaceDto {
@ApiPropertyOptional({
description: 'Input year in YYYY format to filter the data',
example: '2025',
required: false,
})
@IsNotEmpty()
@Matches(/^\d{4}$/, {
message: 'Year must be in YYYY format',
})
year: string;
}
export class GetOccupancyDurationBySpaceDto {
@ApiPropertyOptional({
description: 'Month and year in format YYYY-MM',
example: '2025-03',
required: true,
})
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/, {
message: 'monthDate must be in YYYY-MM format',
})
@IsNotEmpty()
monthDate: string;
}

View File

@ -1,7 +0,0 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
export class SpaceParamsDto {
@IsUUID('4', { message: 'Invalid UUID format' })
@IsNotEmpty()
spaceUuid: string;
}

View File

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { OccupancyController } from './controllers';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { OccupancyService } from './services';
@Module({
imports: [ConfigModule],
controllers: [OccupancyController],
providers: [OccupancyService, SqlLoaderService],
})
export class OccupancyModule {}

View File

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

View File

@ -1,103 +0,0 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import {
GetOccupancyDurationBySpaceDto,
GetOccupancyHeatMapBySpaceDto,
} from '../dto/get-occupancy.dto';
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
import { SpaceParamsDto } from '../dto/occupancy-params.dto';
import { SqlLoaderService } from '@app/common/helper/services/sql-loader.service';
import { DataSource } from 'typeorm';
import { SQL_PROCEDURES_PATH } from '@app/common/constants/sql-query-path';
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
@Injectable()
export class OccupancyService {
constructor(
private readonly sqlLoader: SqlLoaderService,
private readonly dataSource: DataSource,
) {}
async getOccupancyDurationDataBySpace(
params: SpaceParamsDto,
query: GetOccupancyDurationBySpaceDto,
): Promise<BaseResponseDto> {
const { monthDate } = query;
const { spaceUuid } = params;
try {
const data = await this.executeProcedure(
'fact_daily_space_occupancy_duration',
'procedure_select_daily_space_occupancy_duration',
[spaceUuid, monthDate],
);
const formattedData = data.map((item) => ({
...item,
event_date: new Date(item.event_date).toLocaleDateString('en-CA'), // YYYY-MM-DD
}));
return this.buildResponse(
`Occupancy duration data fetched successfully for ${spaceUuid} space`,
formattedData,
);
} catch (error) {
console.error('Failed to fetch occupancy duration data', {
error,
spaceUuid,
});
throw new HttpException(
error.response?.message || 'Failed to fetch occupancy duration data',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
async getOccupancyHeatMapDataBySpace(
params: SpaceParamsDto,
query: GetOccupancyHeatMapBySpaceDto,
): Promise<BaseResponseDto> {
const { year } = query;
const { spaceUuid } = params;
try {
const data = await this.executeProcedure(
'fact_space_occupancy_count',
'proceduce_select_fact_space_occupancy',
[spaceUuid, year],
);
const formattedData = data.map((item) => ({
...item,
event_date: new Date(item.event_date).toLocaleDateString('en-CA'), // YYYY-MM-DD
}));
return this.buildResponse(
`Occupancy heat map data fetched successfully for ${spaceUuid} space`,
formattedData,
);
} catch (error) {
console.error('Failed to fetch occupancy heat map data', {
error,
spaceUuid,
});
throw new HttpException(
error.response?.message || 'Failed to fetch occupancy heat map data',
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
private buildResponse(message: string, data: any[]) {
return new SuccessResponseDto({
message,
data,
statusCode: HttpStatus.OK,
});
}
private async executeProcedure(
procedureFolderName: string,
procedureFileName: string,
params: (string | number | null)[],
): Promise<any[]> {
const query = this.loadQuery(procedureFolderName, procedureFileName);
return await this.dataSource.query(query, params);
}
private loadQuery(folderName: string, fileName: string): string {
return this.sqlLoader.loadQuery(folderName, fileName, SQL_PROCEDURES_PATH);
}
}

View File

@ -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',

View File

@ -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],

View File

@ -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,
);
}
}
}
}

View File

@ -28,7 +28,7 @@ import { PasswordType } from '@app/common/constants/password-type.enum';
import { VisitorPasswordEnum } from '@app/common/constants/visitor-password.enum';
import { 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';

View File

@ -1,39 +1,39 @@
import { Module } from '@nestjs/common';
import { VisitorPasswordService } from './services/visitor-password.service';
import { VisitorPasswordController } from './controllers/visitor-password.controller';
import { ConfigModule } from '@nestjs/config';
import { DeviceRepositoryModule } from '@app/common/modules/device';
import { DeviceRepository } from '@app/common/modules/device/repositories';
import { EmailService } from '@app/common/util/email.service';
import { PasswordEncryptionService } from 'src/door-lock/services/encryption.services';
import { DoorLockModule } from 'src/door-lock/door.lock.module';
import { DeviceService } from 'src/device/services';
import { ProductRepository } from '@app/common/modules/product/repositories';
import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service';
import { 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],

0
test-ai.txt Normal file
View File