mirror of
https://github.com/SyncrowIOT/backend.git
synced 2025-07-17 03:05:13 +00:00
Compare commits
8 Commits
fix/remove
...
test/ai-pr
Author | SHA1 | Date | |
---|---|---|---|
434d9dd421 | |||
e849c0e6d8 | |||
e63c0434ab | |||
ee489633ab | |||
fffa27b6ee | |||
12579fcd6e | |||
036c8bea17 | |||
e5970c02c1 |
54
.github/workflows/pr-description.yml
vendored
Normal file
54
.github/workflows/pr-description.yml
vendored
Normal 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 }}
|
@ -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,
|
||||
|
@ -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, {
|
||||
|
8
libs/common/src/util/email/batch-email.interface.ts
Normal file
8
libs/common/src/util/email/batch-email.interface.ts
Normal 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;
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
7
libs/common/src/util/email/single-email.interface.ts
Normal file
7
libs/common/src/util/email/single-email.interface.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export 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,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 {}
|
@ -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);
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './aqi.controller';
|
@ -1,7 +0,0 @@
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||
|
||||
export class SpaceParamsDto {
|
||||
@IsUUID('4', { message: 'Invalid UUID format' })
|
||||
@IsNotEmpty()
|
||||
spaceUuid: string;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './aqi.service';
|
@ -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],
|
||||
|
@ -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';
|
||||
|
@ -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 {}
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './bookable-space.controller';
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './create-bookable-space.dto';
|
@ -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;
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './booking.module';
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from './bookable-space.service';
|
@ -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],
|
||||
|
@ -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,
|
||||
|
@ -1 +0,0 @@
|
||||
export * from './occupancy.controller';
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||
|
||||
export class SpaceParamsDto {
|
||||
@IsUUID('4', { message: 'Invalid UUID format' })
|
||||
@IsNotEmpty()
|
||||
spaceUuid: string;
|
||||
}
|
@ -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 {}
|
@ -1 +0,0 @@
|
||||
export * from './occupancy.service';
|
@ -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);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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
0
test-ai.txt
Normal file
Reference in New Issue
Block a user