mirror of
https://github.com/SyncrowIOT/backend.git
synced 2025-11-26 16:04:53 +00:00
Compare commits
28 Commits
test/ai-pr
...
task/add-a
| Author | SHA1 | Date | |
|---|---|---|---|
| dcdb10a1fb | |||
| 2aa6a40af7 | |||
| 7ae826eb71 | |||
| beed6fcfb7 | |||
| 015470b5ea | |||
| 36640b104b | |||
| d4af9eaccc | |||
| f447cfa065 | |||
| 9d952d2fb0 | |||
| c0849c2252 | |||
| 9254db08f9 | |||
| b9c4308d1c | |||
| db95cc0dab | |||
| 87c380ab6f | |||
| 212d0d1974 | |||
| a060f92208 | |||
| 6d529ee0ae | |||
| 85687e7950 | |||
| 7e2c3136cf | |||
| 61348aa351 | |||
| dea942f11e | |||
| d62e620828 | |||
| f0556813ac | |||
| 6d2252a403 | |||
| 8d265c9105 | |||
| a4095c837b | |||
| 65d4a56135 | |||
| 2c3b985594 |
42
.github/workflows/pr-description.yml
vendored
42
.github/workflows/pr-description.yml
vendored
@ -1,4 +1,4 @@
|
|||||||
name: 🤖 AI PR Description Generator (with Template)
|
name: 🤖 AI PR Description Commenter (100% Safe with jq)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@ -12,8 +12,10 @@ jobs:
|
|||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install GitHub CLI
|
- name: Install GitHub CLI and jq
|
||||||
uses: cli/cli-action@v2
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install gh jq -y
|
||||||
|
|
||||||
- name: Fetch PR Commits
|
- name: Fetch PR Commits
|
||||||
id: fetch_commits
|
id: fetch_commits
|
||||||
@ -23,23 +25,31 @@ jobs:
|
|||||||
echo "$COMMITS" >> $GITHUB_ENV
|
echo "$COMMITS" >> $GITHUB_ENV
|
||||||
echo "EOF" >> $GITHUB_ENV
|
echo "EOF" >> $GITHUB_ENV
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||||
|
|
||||||
- name: Generate PR Description with OpenAI
|
- name: Generate PR Description with OpenAI (Safe JSON with jq)
|
||||||
id: generate_description
|
|
||||||
run: |
|
run: |
|
||||||
|
REQUEST_BODY=$(jq -n \
|
||||||
|
--arg model "gpt-4o" \
|
||||||
|
--arg content "Given the following commit messages:\n\n${commits}\n\nGenerate a clear and professional pull request description." \
|
||||||
|
'{
|
||||||
|
model: $model,
|
||||||
|
messages: [{ role: "user", content: $content }]
|
||||||
|
}'
|
||||||
|
)
|
||||||
|
|
||||||
RESPONSE=$(curl -s https://api.openai.com/v1/chat/completions \
|
RESPONSE=$(curl -s https://api.openai.com/v1/chat/completions \
|
||||||
-H "Authorization: Bearer $OPENAI_API_KEY" \
|
-H "Authorization: Bearer $OPENAI_API_KEY" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d "$REQUEST_BODY")
|
||||||
"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')
|
DESCRIPTION=$(echo "$RESPONSE" | jq -r '.choices[0].message.content')
|
||||||
|
|
||||||
|
echo "---------- OpenAI Raw Response ----------"
|
||||||
|
echo "$RESPONSE"
|
||||||
|
echo "---------- Extracted Description ----------"
|
||||||
|
echo "$DESCRIPTION"
|
||||||
|
|
||||||
echo "description<<EOF" >> $GITHUB_ENV
|
echo "description<<EOF" >> $GITHUB_ENV
|
||||||
echo "$DESCRIPTION" >> $GITHUB_ENV
|
echo "$DESCRIPTION" >> $GITHUB_ENV
|
||||||
echo "EOF" >> $GITHUB_ENV
|
echo "EOF" >> $GITHUB_ENV
|
||||||
@ -47,8 +57,8 @@ jobs:
|
|||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
commits: ${{ env.commits }}
|
commits: ${{ env.commits }}
|
||||||
|
|
||||||
- name: Update PR Body with AI Description
|
- name: Post AI Generated Description as Comment
|
||||||
run: |
|
run: |
|
||||||
gh pr edit ${{ github.event.pull_request.number }} --body "${{ env.description }}"
|
gh pr comment ${{ github.event.pull_request.number }} --body "${{ env.description }}"
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
|
||||||
|
|||||||
@ -223,6 +223,10 @@ export class ControllerRoute {
|
|||||||
public static readonly CREATE_SPACE_DESCRIPTION =
|
public static readonly CREATE_SPACE_DESCRIPTION =
|
||||||
'This endpoint allows you to create a space in a specified community. Optionally, you can specify a parent space to nest the new space under it.';
|
'This endpoint allows you to create a space in a specified community. Optionally, you can specify a parent space to nest the new space under it.';
|
||||||
|
|
||||||
|
public static readonly DUPLICATE_SPACE_SUMMARY = 'Duplicate a space';
|
||||||
|
public static readonly DUPLICATE_SPACE_DESCRIPTION =
|
||||||
|
'This endpoint allows you to create a copy of an existing space in a specified community.';
|
||||||
|
|
||||||
public static readonly LIST_SPACE_SUMMARY = 'List spaces in community';
|
public static readonly LIST_SPACE_SUMMARY = 'List spaces in community';
|
||||||
public static readonly LIST_SPACE_DESCRIPTION =
|
public static readonly LIST_SPACE_DESCRIPTION =
|
||||||
'List spaces in specified community by community id';
|
'List spaces in specified community by community id';
|
||||||
|
|||||||
3
libs/common/src/constants/timer-job-type.enum.ts
Normal file
3
libs/common/src/constants/timer-job-type.enum.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export enum TimerJobTypeEnum {
|
||||||
|
INVITE_USER_EMAIL = 'INVITE_USER_EMAIL',
|
||||||
|
}
|
||||||
@ -54,6 +54,7 @@ import { SpaceEntity } from '../modules/space/entities/space.entity';
|
|||||||
import { SubspaceProductAllocationEntity } from '../modules/space/entities/subspace/subspace-product-allocation.entity';
|
import { SubspaceProductAllocationEntity } from '../modules/space/entities/subspace/subspace-product-allocation.entity';
|
||||||
import { SubspaceEntity } from '../modules/space/entities/subspace/subspace.entity';
|
import { SubspaceEntity } from '../modules/space/entities/subspace/subspace.entity';
|
||||||
import { NewTagEntity } from '../modules/tag/entities/tag.entity';
|
import { NewTagEntity } from '../modules/tag/entities/tag.entity';
|
||||||
|
import { TimerEntity } from '../modules/timer/entities/timer.entity';
|
||||||
import { TimeZoneEntity } from '../modules/timezone/entities';
|
import { TimeZoneEntity } from '../modules/timezone/entities';
|
||||||
import {
|
import {
|
||||||
UserNotificationEntity,
|
UserNotificationEntity,
|
||||||
@ -121,6 +122,7 @@ import { VisitorPasswordEntity } from '../modules/visitor-password/entities';
|
|||||||
SpaceDailyOccupancyDurationEntity,
|
SpaceDailyOccupancyDurationEntity,
|
||||||
BookableSpaceEntity,
|
BookableSpaceEntity,
|
||||||
BookingEntity,
|
BookingEntity,
|
||||||
|
TimerEntity,
|
||||||
],
|
],
|
||||||
namingStrategy: new SnakeNamingStrategy(),
|
namingStrategy: new SnakeNamingStrategy(),
|
||||||
synchronize: Boolean(JSON.parse(configService.get('DB_SYNC'))),
|
synchronize: Boolean(JSON.parse(configService.get('DB_SYNC'))),
|
||||||
|
|||||||
@ -1,15 +1,13 @@
|
|||||||
|
import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories';
|
||||||
|
import { DeviceRepository } from '@app/common/modules/device/repositories';
|
||||||
import {
|
import {
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AddDeviceStatusDto } from '../dtos/add.devices-status.dto';
|
|
||||||
import { DeviceRepository } from '@app/common/modules/device/repositories';
|
|
||||||
import { GetDeviceDetailsFunctionsStatusInterface } from 'src/device/interfaces/get.device.interface';
|
|
||||||
import { TuyaContext } from '@tuya/tuya-connector-nodejs';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { firebaseDataBase } from '../../firebase.config';
|
import { TuyaContext } from '@tuya/tuya-connector-nodejs';
|
||||||
import {
|
import {
|
||||||
Database,
|
Database,
|
||||||
DataSnapshot,
|
DataSnapshot,
|
||||||
@ -17,7 +15,9 @@ import {
|
|||||||
ref,
|
ref,
|
||||||
runTransaction,
|
runTransaction,
|
||||||
} from 'firebase/database';
|
} from 'firebase/database';
|
||||||
import { DeviceStatusLogRepository } from '@app/common/modules/device-status-log/repositories';
|
import { GetDeviceDetailsFunctionsStatusInterface } from 'src/device/interfaces/get.device.interface';
|
||||||
|
import { firebaseDataBase } from '../../firebase.config';
|
||||||
|
import { AddDeviceStatusDto } from '../dtos/add.devices-status.dto';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DeviceStatusFirebaseService {
|
export class DeviceStatusFirebaseService {
|
||||||
private tuya: TuyaContext;
|
private tuya: TuyaContext;
|
||||||
@ -79,64 +79,77 @@ export class DeviceStatusFirebaseService {
|
|||||||
device: any;
|
device: any;
|
||||||
}[],
|
}[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const allLogs = [];
|
|
||||||
|
|
||||||
console.log(`🔁 Preparing logs from batch of ${batch.length} items...`);
|
console.log(`🔁 Preparing logs from batch of ${batch.length} items...`);
|
||||||
|
|
||||||
|
const allLogs = [];
|
||||||
|
|
||||||
for (const item of batch) {
|
for (const item of batch) {
|
||||||
const device = item.device;
|
const device = item.device;
|
||||||
|
|
||||||
if (!device?.uuid) {
|
if (!device?.uuid) {
|
||||||
console.log(`⛔ Skipped unknown device: ${item.deviceTuyaUuid}`);
|
console.log(`⛔ Skipped unknown device: ${item.deviceTuyaUuid}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logs = item.log.properties.map((property) =>
|
// Determine properties based on environment
|
||||||
|
const properties =
|
||||||
|
this.isDevEnv && Array.isArray(item.log?.properties)
|
||||||
|
? item.log.properties
|
||||||
|
: Array.isArray(item.status)
|
||||||
|
? item.status
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!properties) {
|
||||||
|
console.log(
|
||||||
|
`⛔ Skipped invalid status/properties for device: ${item.deviceTuyaUuid}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = properties.map((property) =>
|
||||||
this.deviceStatusLogRepository.create({
|
this.deviceStatusLogRepository.create({
|
||||||
deviceId: device.uuid,
|
deviceId: device.uuid,
|
||||||
deviceTuyaId: item.deviceTuyaUuid,
|
deviceTuyaId: item.deviceTuyaUuid,
|
||||||
productId: item.log.productId,
|
productId: device.productDevice?.uuid,
|
||||||
log: item.log,
|
log: item.log,
|
||||||
code: property.code,
|
code: property.code,
|
||||||
value: property.value,
|
value: property.value,
|
||||||
eventId: item.log.dataId,
|
eventId: item.log?.dataId,
|
||||||
eventTime: new Date(property.time).toISOString(),
|
eventTime: new Date(
|
||||||
|
this.isDevEnv ? property.time : property.t,
|
||||||
|
).toISOString(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
allLogs.push(...logs);
|
allLogs.push(...logs);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📝 Total logs to insert: ${allLogs.length}`);
|
console.log(`📝 Total logs to insert: ${allLogs.length}`);
|
||||||
|
|
||||||
const insertLogsPromise = (async () => {
|
const chunkSize = 300;
|
||||||
const chunkSize = 300;
|
let insertedCount = 0;
|
||||||
let insertedCount = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < allLogs.length; i += chunkSize) {
|
for (let i = 0; i < allLogs.length; i += chunkSize) {
|
||||||
const chunk = allLogs.slice(i, i + chunkSize);
|
const chunk = allLogs.slice(i, i + chunkSize);
|
||||||
try {
|
try {
|
||||||
const result = await this.deviceStatusLogRepository
|
const result = await this.deviceStatusLogRepository
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.insert()
|
.insert()
|
||||||
.into('device-status-log') // or use DeviceStatusLogEntity
|
.into('device-status-log')
|
||||||
.values(chunk)
|
.values(chunk)
|
||||||
.orIgnore() // skip duplicates
|
.orIgnore()
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
insertedCount += result.identifiers.length;
|
insertedCount += result.identifiers.length;
|
||||||
console.log(
|
console.log(
|
||||||
`✅ Inserted ${result.identifiers.length} / ${chunk.length} logs (chunk)`,
|
`✅ Inserted ${result.identifiers.length} / ${chunk.length} logs (chunk)`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Insert error (skipped chunk):', error.message);
|
console.error('❌ Insert error (skipped chunk):', error.message);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(`✅ Total logs inserted: ${insertedCount} / ${allLogs.length}`);
|
||||||
`✅ Total logs inserted: ${insertedCount} / ${allLogs.length}`,
|
|
||||||
);
|
|
||||||
})();
|
|
||||||
|
|
||||||
await insertLogsPromise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async addDeviceStatusToFirebase(
|
async addDeviceStatusToFirebase(
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
|
||||||
import TuyaWebsocket from '../../config/tuya-web-socket-config';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service';
|
import { DeviceStatusFirebaseService } from '@app/common/firebase/devices-status/services/devices-status.service';
|
||||||
import { SosHandlerService } from './sos.handler.service';
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as NodeCache from 'node-cache';
|
import * as NodeCache from 'node-cache';
|
||||||
|
import TuyaWebsocket from '../../config/tuya-web-socket-config';
|
||||||
|
import { SosHandlerService } from './sos.handler.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TuyaWebSocketService implements OnModuleInit {
|
export class TuyaWebSocketService implements OnModuleInit {
|
||||||
@ -74,7 +74,12 @@ export class TuyaWebSocketService implements OnModuleInit {
|
|||||||
this.client.message(async (ws: WebSocket, message: any) => {
|
this.client.message(async (ws: WebSocket, message: any) => {
|
||||||
try {
|
try {
|
||||||
const { devId, status, logData } = this.extractMessageData(message);
|
const { devId, status, logData } = this.extractMessageData(message);
|
||||||
if (!Array.isArray(logData?.properties)) {
|
// console.log(
|
||||||
|
// `📬 Received message for device: ${devId}, status:`,
|
||||||
|
// status,
|
||||||
|
// logData,
|
||||||
|
// );
|
||||||
|
if (!Array.isArray(status)) {
|
||||||
this.client.ackMessage(message.messageId);
|
this.client.ackMessage(message.messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -162,6 +167,8 @@ export class TuyaWebSocketService implements OnModuleInit {
|
|||||||
status: any;
|
status: any;
|
||||||
logData: any;
|
logData: any;
|
||||||
} {
|
} {
|
||||||
|
// console.log('Received message:', message);
|
||||||
|
|
||||||
const payloadData = message.payload.data;
|
const payloadData = message.payload.data;
|
||||||
|
|
||||||
if (this.isDevEnv) {
|
if (this.isDevEnv) {
|
||||||
|
|||||||
@ -15,17 +15,16 @@ import { ProjectEntity } from '../../project/entities';
|
|||||||
import { RoleTypeEntity } from '../../role-type/entities';
|
import { RoleTypeEntity } from '../../role-type/entities';
|
||||||
import { SpaceEntity } from '../../space/entities/space.entity';
|
import { SpaceEntity } from '../../space/entities/space.entity';
|
||||||
import { UserEntity } from '../../user/entities';
|
import { UserEntity } from '../../user/entities';
|
||||||
import { InviteUserDto, InviteUserSpaceDto } from '../dtos';
|
|
||||||
|
|
||||||
@Entity({ name: 'invite-user' })
|
@Entity({ name: 'invite-user' })
|
||||||
@Unique(['email', 'project'])
|
@Unique(['email', 'project'])
|
||||||
export class InviteUserEntity extends AbstractEntity<InviteUserDto> {
|
export class InviteUserEntity extends AbstractEntity {
|
||||||
@Column({
|
@Column({
|
||||||
type: 'uuid',
|
type: 'uuid',
|
||||||
default: () => 'gen_random_uuid()',
|
default: () => 'gen_random_uuid()',
|
||||||
nullable: false,
|
nullable: false,
|
||||||
})
|
})
|
||||||
public uuid: string;
|
uuid: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
nullable: false,
|
nullable: false,
|
||||||
@ -49,50 +48,67 @@ export class InviteUserEntity extends AbstractEntity<InviteUserDto> {
|
|||||||
status: string;
|
status: string;
|
||||||
|
|
||||||
@Column()
|
@Column()
|
||||||
public firstName: string;
|
firstName: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
nullable: false,
|
nullable: false,
|
||||||
})
|
})
|
||||||
public lastName: string;
|
lastName: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
public phoneNumber: string;
|
phoneNumber: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
nullable: false,
|
nullable: false,
|
||||||
default: true,
|
default: true,
|
||||||
})
|
})
|
||||||
public isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
nullable: false,
|
nullable: false,
|
||||||
default: true,
|
default: true,
|
||||||
})
|
})
|
||||||
public isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
nullable: false,
|
nullable: false,
|
||||||
unique: true,
|
unique: true,
|
||||||
})
|
})
|
||||||
public invitationCode: string;
|
invitationCode: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
default: new Date(),
|
||||||
|
type: 'date',
|
||||||
|
})
|
||||||
|
accessStartDate: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'date',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
accessEndDate?: Date;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
nullable: false,
|
nullable: false,
|
||||||
enum: Object.values(RoleType),
|
enum: Object.values(RoleType),
|
||||||
})
|
})
|
||||||
public invitedBy: string;
|
invitedBy: string;
|
||||||
|
|
||||||
@ManyToOne(() => RoleTypeEntity, (roleType) => roleType.invitedUsers, {
|
@ManyToOne(() => RoleTypeEntity, (roleType) => roleType.invitedUsers, {
|
||||||
nullable: false,
|
nullable: false,
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
public roleType: RoleTypeEntity;
|
roleType: RoleTypeEntity;
|
||||||
|
|
||||||
@OneToOne(() => UserEntity, (user) => user.inviteUser, {
|
@OneToOne(() => UserEntity, (user) => user.inviteUser, {
|
||||||
nullable: true,
|
nullable: true,
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
@JoinColumn({ name: 'user_uuid' })
|
@JoinColumn({ name: 'user_uuid' })
|
||||||
user: UserEntity;
|
user: UserEntity;
|
||||||
|
|
||||||
@OneToMany(
|
@OneToMany(
|
||||||
() => InviteUserSpaceEntity,
|
() => InviteUserSpaceEntity,
|
||||||
(inviteUserSpace) => inviteUserSpace.inviteUser,
|
(inviteUserSpace) => inviteUserSpace.inviteUser,
|
||||||
@ -103,32 +119,34 @@ export class InviteUserEntity extends AbstractEntity<InviteUserDto> {
|
|||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
@JoinColumn({ name: 'project_uuid' })
|
@JoinColumn({ name: 'project_uuid' })
|
||||||
public project: ProjectEntity;
|
project: ProjectEntity;
|
||||||
|
|
||||||
constructor(partial: Partial<InviteUserEntity>) {
|
constructor(partial: Partial<InviteUserEntity>) {
|
||||||
super();
|
super();
|
||||||
Object.assign(this, partial);
|
Object.assign(this, partial);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity({ name: 'invite-user-space' })
|
@Entity({ name: 'invite-user-space' })
|
||||||
@Unique(['inviteUser', 'space'])
|
@Unique(['inviteUser', 'space'])
|
||||||
export class InviteUserSpaceEntity extends AbstractEntity<InviteUserSpaceDto> {
|
export class InviteUserSpaceEntity extends AbstractEntity {
|
||||||
@Column({
|
@Column({
|
||||||
type: 'uuid',
|
type: 'uuid',
|
||||||
default: () => 'gen_random_uuid()',
|
default: () => 'gen_random_uuid()',
|
||||||
nullable: false,
|
nullable: false,
|
||||||
})
|
})
|
||||||
public uuid: string;
|
uuid: string;
|
||||||
|
|
||||||
@ManyToOne(() => InviteUserEntity, (inviteUser) => inviteUser.spaces, {
|
@ManyToOne(() => InviteUserEntity, (inviteUser) => inviteUser.spaces, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
@JoinColumn({ name: 'invite_user_uuid' })
|
@JoinColumn({ name: 'invite_user_uuid' })
|
||||||
public inviteUser: InviteUserEntity;
|
inviteUser: InviteUserEntity;
|
||||||
|
|
||||||
@ManyToOne(() => SpaceEntity, (space) => space.invitedUsers)
|
@ManyToOne(() => SpaceEntity, (space) => space.invitedUsers)
|
||||||
@JoinColumn({ name: 'space_uuid' })
|
@JoinColumn({ name: 'space_uuid' })
|
||||||
public space: SpaceEntity;
|
space: SpaceEntity;
|
||||||
|
|
||||||
constructor(partial: Partial<InviteUserSpaceEntity>) {
|
constructor(partial: Partial<InviteUserSpaceEntity>) {
|
||||||
super();
|
super();
|
||||||
Object.assign(this, partial);
|
Object.assign(this, partial);
|
||||||
|
|||||||
@ -22,6 +22,11 @@ export class SpaceProductAllocationEntity extends AbstractEntity<SpaceProductAll
|
|||||||
})
|
})
|
||||||
public space: SpaceEntity;
|
public space: SpaceEntity;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'space_uuid',
|
||||||
|
})
|
||||||
|
public spaceUuid: string;
|
||||||
|
|
||||||
@ManyToOne(() => SpaceModelProductAllocationEntity, {
|
@ManyToOne(() => SpaceModelProductAllocationEntity, {
|
||||||
nullable: true,
|
nullable: true,
|
||||||
onDelete: 'SET NULL',
|
onDelete: 'SET NULL',
|
||||||
@ -31,9 +36,19 @@ export class SpaceProductAllocationEntity extends AbstractEntity<SpaceProductAll
|
|||||||
@ManyToOne(() => ProductEntity, { nullable: false, onDelete: 'CASCADE' })
|
@ManyToOne(() => ProductEntity, { nullable: false, onDelete: 'CASCADE' })
|
||||||
public product: ProductEntity;
|
public product: ProductEntity;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'product_uuid',
|
||||||
|
})
|
||||||
|
public productUuid: string;
|
||||||
|
|
||||||
@ManyToOne(() => NewTagEntity, { nullable: true, onDelete: 'CASCADE' })
|
@ManyToOne(() => NewTagEntity, { nullable: true, onDelete: 'CASCADE' })
|
||||||
public tag: NewTagEntity;
|
public tag: NewTagEntity;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'tag_uuid',
|
||||||
|
})
|
||||||
|
public tagUuid: string;
|
||||||
|
|
||||||
constructor(partial: Partial<SpaceProductAllocationEntity>) {
|
constructor(partial: Partial<SpaceProductAllocationEntity>) {
|
||||||
super();
|
super();
|
||||||
Object.assign(this, partial);
|
Object.assign(this, partial);
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
|
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
|
||||||
import { AqiSpaceDailyPollutantStatsEntity } from '../../aqi/entities';
|
import { AqiSpaceDailyPollutantStatsEntity } from '../../aqi/entities';
|
||||||
import { BookableSpaceEntity } from '../../booking/entities/bookable-space.entity';
|
import { BookableSpaceEntity } from '../../booking/entities/bookable-space.entity';
|
||||||
|
import { BookingEntity } from '../../booking/entities/booking.entity';
|
||||||
import { CommunityEntity } from '../../community/entities';
|
import { CommunityEntity } from '../../community/entities';
|
||||||
import { DeviceEntity } from '../../device/entities';
|
import { DeviceEntity } from '../../device/entities';
|
||||||
import { InviteUserSpaceEntity } from '../../Invite-user/entities';
|
import { InviteUserSpaceEntity } from '../../Invite-user/entities';
|
||||||
@ -20,7 +21,6 @@ import { UserSpaceEntity } from '../../user/entities';
|
|||||||
import { SpaceDto } from '../dtos';
|
import { SpaceDto } from '../dtos';
|
||||||
import { SpaceProductAllocationEntity } from './space-product-allocation.entity';
|
import { SpaceProductAllocationEntity } from './space-product-allocation.entity';
|
||||||
import { SubspaceEntity } from './subspace/subspace.entity';
|
import { SubspaceEntity } from './subspace/subspace.entity';
|
||||||
import { BookingEntity } from '../../booking/entities/booking.entity';
|
|
||||||
|
|
||||||
@Entity({ name: 'space' })
|
@Entity({ name: 'space' })
|
||||||
export class SpaceEntity extends AbstractEntity<SpaceDto> {
|
export class SpaceEntity extends AbstractEntity<SpaceDto> {
|
||||||
@ -47,12 +47,26 @@ export class SpaceEntity extends AbstractEntity<SpaceDto> {
|
|||||||
@JoinColumn({ name: 'community_id' })
|
@JoinColumn({ name: 'community_id' })
|
||||||
community: CommunityEntity;
|
community: CommunityEntity;
|
||||||
|
|
||||||
@ManyToOne(() => SpaceEntity, (space) => space.children, { nullable: true })
|
@Column({
|
||||||
|
name: 'community_id',
|
||||||
|
})
|
||||||
|
communityId: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => SpaceEntity, (space) => space.children, {
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
parent: SpaceEntity;
|
parent: SpaceEntity;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'parent_uuid',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
public parentUuid: string;
|
||||||
|
|
||||||
@OneToMany(() => SpaceEntity, (space) => space.parent, {
|
@OneToMany(() => SpaceEntity, (space) => space.parent, {
|
||||||
nullable: false,
|
nullable: false,
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
|
cascade: true,
|
||||||
})
|
})
|
||||||
children: SpaceEntity[];
|
children: SpaceEntity[];
|
||||||
|
|
||||||
@ -73,16 +87,10 @@ export class SpaceEntity extends AbstractEntity<SpaceDto> {
|
|||||||
|
|
||||||
@OneToMany(() => SubspaceEntity, (subspace) => subspace.space, {
|
@OneToMany(() => SubspaceEntity, (subspace) => subspace.space, {
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
cascade: true,
|
||||||
})
|
})
|
||||||
subspaces?: SubspaceEntity[];
|
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(
|
@OneToMany(
|
||||||
() => DeviceEntity,
|
() => DeviceEntity,
|
||||||
(devicesSpaceEntity) => devicesSpaceEntity.spaceDevice,
|
(devicesSpaceEntity) => devicesSpaceEntity.spaceDevice,
|
||||||
|
|||||||
@ -22,6 +22,11 @@ export class SubspaceProductAllocationEntity extends AbstractEntity<SubspaceProd
|
|||||||
})
|
})
|
||||||
public subspace: SubspaceEntity;
|
public subspace: SubspaceEntity;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'subspace_uuid',
|
||||||
|
})
|
||||||
|
public subspaceUuid: string;
|
||||||
|
|
||||||
@ManyToOne(() => SubspaceModelProductAllocationEntity, {
|
@ManyToOne(() => SubspaceModelProductAllocationEntity, {
|
||||||
nullable: true,
|
nullable: true,
|
||||||
onDelete: 'SET NULL',
|
onDelete: 'SET NULL',
|
||||||
@ -31,9 +36,19 @@ export class SubspaceProductAllocationEntity extends AbstractEntity<SubspaceProd
|
|||||||
@ManyToOne(() => ProductEntity, { nullable: false, onDelete: 'CASCADE' })
|
@ManyToOne(() => ProductEntity, { nullable: false, onDelete: 'CASCADE' })
|
||||||
public product: ProductEntity;
|
public product: ProductEntity;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'product_uuid',
|
||||||
|
})
|
||||||
|
public productUuid: string;
|
||||||
|
|
||||||
@ManyToOne(() => NewTagEntity, { nullable: true, onDelete: 'CASCADE' })
|
@ManyToOne(() => NewTagEntity, { nullable: true, onDelete: 'CASCADE' })
|
||||||
public tag: NewTagEntity;
|
public tag: NewTagEntity;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'tag_uuid',
|
||||||
|
})
|
||||||
|
public tagUuid: string;
|
||||||
|
|
||||||
constructor(partial: Partial<SubspaceProductAllocationEntity>) {
|
constructor(partial: Partial<SubspaceProductAllocationEntity>) {
|
||||||
super();
|
super();
|
||||||
Object.assign(this, partial);
|
Object.assign(this, partial);
|
||||||
|
|||||||
@ -26,6 +26,11 @@ export class SubspaceEntity extends AbstractEntity<SubspaceDto> {
|
|||||||
@JoinColumn({ name: 'space_uuid' })
|
@JoinColumn({ name: 'space_uuid' })
|
||||||
space: SpaceEntity;
|
space: SpaceEntity;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'space_uuid',
|
||||||
|
})
|
||||||
|
public spaceUuid: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
nullable: false,
|
nullable: false,
|
||||||
default: false,
|
default: false,
|
||||||
|
|||||||
37
libs/common/src/modules/timer/entities/timer.entity.ts
Normal file
37
libs/common/src/modules/timer/entities/timer.entity.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { TimerJobTypeEnum } from '@app/common/constants/timer-job-type.enum';
|
||||||
|
import { Column, Entity } from 'typeorm';
|
||||||
|
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
|
||||||
|
|
||||||
|
@Entity({ name: 'timer' })
|
||||||
|
export class TimerEntity extends AbstractEntity {
|
||||||
|
@Column({
|
||||||
|
type: 'uuid',
|
||||||
|
default: () => 'gen_random_uuid()',
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
uuid: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
nullable: false,
|
||||||
|
enum: Object.values(TimerJobTypeEnum),
|
||||||
|
type: String,
|
||||||
|
})
|
||||||
|
type: TimerJobTypeEnum;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
nullable: false,
|
||||||
|
type: 'date',
|
||||||
|
})
|
||||||
|
triggerDate: Date;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'jsonb',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
constructor(partial: Partial<TimerEntity>) {
|
||||||
|
super();
|
||||||
|
Object.assign(this, partial);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { TimerEntity } from '../entities/timer.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TimerRepository extends Repository<TimerEntity> {
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
super(TimerEntity, dataSource.createEntityManager());
|
||||||
|
}
|
||||||
|
}
|
||||||
12
libs/common/src/modules/timer/timer.repository.module.ts
Normal file
12
libs/common/src/modules/timer/timer.repository.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { TimerEntity } from './entities/timer.entity';
|
||||||
|
import { TimerRepository } from './repositories/timer.repository';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([TimerEntity])],
|
||||||
|
providers: [TimerRepository],
|
||||||
|
exports: [TimerRepository],
|
||||||
|
})
|
||||||
|
export class TimerRepositoryModule {}
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { OtpType } from '../../../../src/constants/otp-type.enum';
|
import { OtpType } from '../../../../src/constants/otp-type.enum';
|
||||||
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
|
import { AbstractEntity } from '../../abstract/entities/abstract.entity';
|
||||||
|
import { BookingEntity } from '../../booking/entities/booking.entity';
|
||||||
import { ClientEntity } from '../../client/entities';
|
import { ClientEntity } from '../../client/entities';
|
||||||
import {
|
import {
|
||||||
DeviceNotificationEntity,
|
DeviceNotificationEntity,
|
||||||
@ -29,7 +30,6 @@ import {
|
|||||||
UserOtpDto,
|
UserOtpDto,
|
||||||
UserSpaceDto,
|
UserSpaceDto,
|
||||||
} from '../dtos';
|
} from '../dtos';
|
||||||
import { BookingEntity } from '../../booking/entities/booking.entity';
|
|
||||||
|
|
||||||
@Entity({ name: 'user' })
|
@Entity({ name: 'user' })
|
||||||
export class UserEntity extends AbstractEntity<UserDto> {
|
export class UserEntity extends AbstractEntity<UserDto> {
|
||||||
@ -101,6 +101,9 @@ export class UserEntity extends AbstractEntity<UserDto> {
|
|||||||
@Column({ type: 'timestamp', nullable: true })
|
@Column({ type: 'timestamp', nullable: true })
|
||||||
appAgreementAcceptedAt: Date;
|
appAgreementAcceptedAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: Boolean, default: false })
|
||||||
|
bookingEnabled: boolean;
|
||||||
|
|
||||||
@OneToMany(() => UserSpaceEntity, (userSpace) => userSpace.user, {
|
@OneToMany(() => UserSpaceEntity, (userSpace) => userSpace.user, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
|
|||||||
31
package-lock.json
generated
31
package-lock.json
generated
@ -48,6 +48,7 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"winston": "^3.17.0",
|
"winston": "^3.17.0",
|
||||||
"ws": "^8.17.0"
|
"ws": "^8.17.0"
|
||||||
},
|
},
|
||||||
@ -2365,6 +2366,18 @@
|
|||||||
"rxjs": "^7.2.0"
|
"rxjs": "^7.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/cqrs/node_modules/uuid": {
|
||||||
|
"version": "11.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.2.tgz",
|
||||||
|
"integrity": "sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/esm/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/jwt": {
|
"node_modules/@nestjs/jwt": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz",
|
||||||
@ -12749,18 +12762,6 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typeorm/node_modules/uuid": {
|
|
||||||
"version": "11.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
|
||||||
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/broofa",
|
|
||||||
"https://github.com/sponsors/ctavan"
|
|
||||||
],
|
|
||||||
"bin": {
|
|
||||||
"uuid": "dist/esm/bin/uuid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
@ -12896,9 +12897,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/uuid": {
|
"node_modules/uuid": {
|
||||||
"version": "11.0.2",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
"integrity": "sha512-14FfcOJmqdjbBPdDjFQyk/SdT4NySW4eM0zcG+HqbHP5jzuH56xO3J1DGhgs/cEMCfwYi3HQI1gnTO62iaG+tQ==",
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/broofa",
|
"https://github.com/sponsors/broofa",
|
||||||
"https://github.com/sponsors/ctavan"
|
"https://github.com/sponsors/ctavan"
|
||||||
|
|||||||
@ -60,6 +60,7 @@
|
|||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"winston": "^3.17.0",
|
"winston": "^3.17.0",
|
||||||
"ws": "^8.17.0"
|
"ws": "^8.17.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -35,16 +35,18 @@ import { UserNotificationModule } from './user-notification/user-notification.mo
|
|||||||
import { UserModule } from './users/user.module';
|
import { UserModule } from './users/user.module';
|
||||||
import { VisitorPasswordModule } from './vistor-password/visitor-password.module';
|
import { VisitorPasswordModule } from './vistor-password/visitor-password.module';
|
||||||
|
|
||||||
|
import { TimerRepositoryModule } from '@app/common/modules/timer/timer.repository.module';
|
||||||
|
import { ScheduleModule as NestScheduleModule } from '@nestjs/schedule';
|
||||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||||
import { ThrottlerModule } from '@nestjs/throttler/dist/throttler.module';
|
import { ThrottlerModule } from '@nestjs/throttler/dist/throttler.module';
|
||||||
import { isArray } from 'class-validator';
|
import { isArray } from 'class-validator';
|
||||||
import { winstonLoggerOptions } from '../libs/common/src/logger/services/winston.logger';
|
import { winstonLoggerOptions } from '../libs/common/src/logger/services/winston.logger';
|
||||||
import { AqiModule } from './aqi/aqi.module';
|
import { AqiModule } from './aqi/aqi.module';
|
||||||
import { OccupancyModule } from './occupancy/occupancy.module';
|
|
||||||
import { WeatherModule } from './weather/weather.module';
|
|
||||||
import { ScheduleModule as NestScheduleModule } from '@nestjs/schedule';
|
|
||||||
import { SchedulerModule } from './scheduler/scheduler.module';
|
|
||||||
import { BookingModule } from './booking';
|
import { BookingModule } from './booking';
|
||||||
|
import { OccupancyModule } from './occupancy/occupancy.module';
|
||||||
|
import { SchedulerModule } from './scheduler/scheduler.module';
|
||||||
|
import { TimerModule } from './timer/timer.module';
|
||||||
|
import { WeatherModule } from './weather/weather.module';
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
@ -63,6 +65,8 @@ import { BookingModule } from './booking';
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
WinstonModule.forRoot(winstonLoggerOptions),
|
WinstonModule.forRoot(winstonLoggerOptions),
|
||||||
|
TimerModule,
|
||||||
|
TimerRepositoryModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
AuthenticationModule,
|
AuthenticationModule,
|
||||||
UserModule,
|
UserModule,
|
||||||
|
|||||||
11
src/aqi/aqi.module.ts
Normal file
11
src/aqi/aqi.module.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
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 {}
|
||||||
64
src/aqi/controllers/aqi.controller.ts
Normal file
64
src/aqi/controllers/aqi.controller.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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
src/aqi/controllers/index.ts
Normal file
1
src/aqi/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './aqi.controller';
|
||||||
7
src/aqi/dto/aqi-params.dto.ts
Normal file
7
src/aqi/dto/aqi-params.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class SpaceParamsDto {
|
||||||
|
@IsUUID('4', { message: 'Invalid UUID format' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
spaceUuid: string;
|
||||||
|
}
|
||||||
37
src/aqi/dto/get-aqi.dto.ts
Normal file
37
src/aqi/dto/get-aqi.dto.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
138
src/aqi/services/aqi.service.ts
Normal file
138
src/aqi/services/aqi.service.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
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
src/aqi/services/index.ts
Normal file
1
src/aqi/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './aqi.service';
|
||||||
29
src/booking/booking.module.ts
Normal file
29
src/booking/booking.module.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { BookingRepositoryModule } from '@app/common/modules/booking/booking.repository.module';
|
||||||
|
import { BookableSpaceEntityRepository } from '@app/common/modules/booking/repositories/bookable-space.repository';
|
||||||
|
import { BookingEntityRepository } from '@app/common/modules/booking/repositories/booking.repository';
|
||||||
|
import { SpaceRepository } from '@app/common/modules/space';
|
||||||
|
import { UserRepository } from '@app/common/modules/user/repositories';
|
||||||
|
import { EmailService } from '@app/common/util/email/email.service';
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { BookableSpaceController } from './controllers/bookable-space.controller';
|
||||||
|
import { BookingController } from './controllers/booking.controller';
|
||||||
|
import { BookableSpaceService } from './services/bookable-space.service';
|
||||||
|
import { BookingService } from './services/booking.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [BookingRepositoryModule],
|
||||||
|
controllers: [BookableSpaceController, BookingController],
|
||||||
|
providers: [
|
||||||
|
BookableSpaceService,
|
||||||
|
BookingService,
|
||||||
|
EmailService,
|
||||||
|
BookableSpaceEntityRepository,
|
||||||
|
BookingEntityRepository,
|
||||||
|
|
||||||
|
SpaceRepository,
|
||||||
|
UserRepository,
|
||||||
|
],
|
||||||
|
exports: [BookableSpaceService, BookingService],
|
||||||
|
})
|
||||||
|
export class BookingModule {}
|
||||||
107
src/booking/controllers/bookable-space.controller.ts
Normal file
107
src/booking/controllers/bookable-space.controller.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { ControllerRoute } from '@app/common/constants/controller-route';
|
||||||
|
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
|
||||||
|
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
|
||||||
|
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
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 { BookableSpaceRequestDto } from '../dtos/bookable-space-request.dto';
|
||||||
|
import { BookableSpaceResponseDto } from '../dtos/bookable-space-response.dto';
|
||||||
|
import { CreateBookableSpaceDto } from '../dtos/create-bookable-space.dto';
|
||||||
|
import { UpdateBookableSpaceDto } from '../dtos/update-bookable-space.dto';
|
||||||
|
import { BookableSpaceService } from '../services/bookable-space.service';
|
||||||
|
|
||||||
|
@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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/booking/controllers/booking.controller.ts
Normal file
107
src/booking/controllers/booking.controller.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { ControllerRoute } from '@app/common/constants/controller-route';
|
||||||
|
import { EnableDisableStatusEnum } from '@app/common/constants/days.enum';
|
||||||
|
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
|
||||||
|
import { JwtAuthGuard } from '@app/common/guards/jwt.auth.guard';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
|
||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { AdminRoleGuard } from 'src/guards/admin.role.guard';
|
||||||
|
import { BookingRequestDto } from '../dtos/booking-request.dto';
|
||||||
|
import { BookingResponseDto } from '../dtos/booking-response.dto';
|
||||||
|
import { CreateBookingDto } from '../dtos/create-booking.dto';
|
||||||
|
import { MyBookingRequestDto } from '../dtos/my-booking-request.dto';
|
||||||
|
import { BookingService } from '../services/booking.service';
|
||||||
|
|
||||||
|
@ApiTags('Booking Module')
|
||||||
|
@Controller({
|
||||||
|
version: EnableDisableStatusEnum.ENABLED,
|
||||||
|
path: ControllerRoute.BOOKING.ROUTE,
|
||||||
|
})
|
||||||
|
export class BookingController {
|
||||||
|
constructor(private readonly bookingService: BookingService) {}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: ControllerRoute.BOOKING.ACTIONS.ADD_BOOKING_SUMMARY,
|
||||||
|
description: ControllerRoute.BOOKING.ACTIONS.ADD_BOOKING_DESCRIPTION,
|
||||||
|
})
|
||||||
|
async create(
|
||||||
|
@Body() dto: CreateBookingDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
): Promise<BaseResponseDto> {
|
||||||
|
const userUuid = req['user']?.uuid;
|
||||||
|
if (!userUuid) {
|
||||||
|
throw new Error('User UUID is required in the request');
|
||||||
|
}
|
||||||
|
const result = await this.bookingService.create(userUuid, dto);
|
||||||
|
return new SuccessResponseDto({
|
||||||
|
data: result,
|
||||||
|
message: 'Successfully created booking',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(AdminRoleGuard)
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: ControllerRoute.BOOKING.ACTIONS.GET_ALL_BOOKINGS_SUMMARY,
|
||||||
|
description: ControllerRoute.BOOKING.ACTIONS.GET_ALL_BOOKINGS_DESCRIPTION,
|
||||||
|
})
|
||||||
|
async findAll(
|
||||||
|
@Query() query: BookingRequestDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
): Promise<BaseResponseDto> {
|
||||||
|
const project = req['user']?.project?.uuid;
|
||||||
|
if (!project) {
|
||||||
|
throw new Error('Project UUID is required in the request');
|
||||||
|
}
|
||||||
|
const result = await this.bookingService.findAll(query, project);
|
||||||
|
return new SuccessResponseDto({
|
||||||
|
data: plainToInstance(BookingResponseDto, result, {
|
||||||
|
excludeExtraneousValues: true,
|
||||||
|
}),
|
||||||
|
message: 'Successfully fetched all bookings',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Get('my-bookings')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: ControllerRoute.BOOKING.ACTIONS.GET_MY_BOOKINGS_SUMMARY,
|
||||||
|
description: ControllerRoute.BOOKING.ACTIONS.GET_MY_BOOKINGS_DESCRIPTION,
|
||||||
|
})
|
||||||
|
async findMyBookings(
|
||||||
|
@Query() query: MyBookingRequestDto,
|
||||||
|
@Req() req: Request,
|
||||||
|
): Promise<BaseResponseDto> {
|
||||||
|
const userUuid = req['user']?.uuid;
|
||||||
|
const project = req['user']?.project?.uuid;
|
||||||
|
if (!project) {
|
||||||
|
throw new Error('Project UUID is required in the request');
|
||||||
|
}
|
||||||
|
const result = await this.bookingService.findMyBookings(
|
||||||
|
query,
|
||||||
|
userUuid,
|
||||||
|
project,
|
||||||
|
);
|
||||||
|
return new SuccessResponseDto({
|
||||||
|
data: plainToInstance(BookingResponseDto, result, {
|
||||||
|
excludeExtraneousValues: true,
|
||||||
|
}),
|
||||||
|
message: 'Successfully fetched all bookings',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/booking/dtos/bookable-space-request.dto.ts
Normal file
31
src/booking/dtos/bookable-space-request.dto.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
59
src/booking/dtos/bookable-space-response.dto.ts
Normal file
59
src/booking/dtos/bookable-space-response.dto.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
23
src/booking/dtos/booking-request.dto.ts
Normal file
23
src/booking/dtos/booking-request.dto.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsOptional, IsUUID, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class BookingRequestDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Month in MM-YYYY format',
|
||||||
|
example: '07-2025',
|
||||||
|
})
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Matches(/^(0[1-9]|1[0-2])\-\d{4}$/, {
|
||||||
|
message: 'Date must be in MM/YYYY format',
|
||||||
|
})
|
||||||
|
month: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Space UUID',
|
||||||
|
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID('4')
|
||||||
|
space?: string;
|
||||||
|
}
|
||||||
88
src/booking/dtos/booking-response.dto.ts
Normal file
88
src/booking/dtos/booking-response.dto.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Expose, Transform, Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class BookingUserResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@Expose()
|
||||||
|
uuid: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@Expose()
|
||||||
|
firstName: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@Expose()
|
||||||
|
lastName: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Expose()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Expose()
|
||||||
|
@Transform(({ obj }) => obj.inviteUser?.companyName || null)
|
||||||
|
companyName: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
@Expose()
|
||||||
|
phoneNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingSpaceResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@Expose()
|
||||||
|
uuid: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@Expose()
|
||||||
|
spaceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@Expose()
|
||||||
|
uuid: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: Date,
|
||||||
|
})
|
||||||
|
@Expose()
|
||||||
|
date: Date;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@Expose()
|
||||||
|
startTime: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@Expose()
|
||||||
|
endTime: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: Number,
|
||||||
|
})
|
||||||
|
@Expose()
|
||||||
|
cost: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: BookingUserResponseDto,
|
||||||
|
})
|
||||||
|
@Type(() => BookingUserResponseDto)
|
||||||
|
@Expose()
|
||||||
|
user: BookingUserResponseDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: BookingSpaceResponseDto,
|
||||||
|
})
|
||||||
|
@Type(() => BookingSpaceResponseDto)
|
||||||
|
@Expose()
|
||||||
|
space: BookingSpaceResponseDto;
|
||||||
|
}
|
||||||
63
src/booking/dtos/create-bookable-space.dto.ts
Normal file
63
src/booking/dtos/create-bookable-space.dto.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
43
src/booking/dtos/create-booking.dto.ts
Normal file
43
src/booking/dtos/create-booking.dto.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
IsDate,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
MinDate,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBookingDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'string',
|
||||||
|
example: '4fa85f64-5717-4562-b3fc-2c963f66afa7',
|
||||||
|
})
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsUUID('4', { message: 'Invalid space UUID provided' })
|
||||||
|
spaceUuid: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: Date,
|
||||||
|
})
|
||||||
|
@IsNotEmpty()
|
||||||
|
@IsDate()
|
||||||
|
@MinDate(new Date())
|
||||||
|
date: Date;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '09:00' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'Start time cannot be empty' })
|
||||||
|
@Matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, {
|
||||||
|
message: 'Start time must be in HH:mm format (24-hour)',
|
||||||
|
})
|
||||||
|
startTime: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '17:00' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: 'End time cannot be empty' })
|
||||||
|
@Matches(/^([01]?[0-9]|2[0-3]):[0-5][0-9]$/, {
|
||||||
|
message: 'End time must be in HH:mm format (24-hour)',
|
||||||
|
})
|
||||||
|
endTime: string;
|
||||||
|
}
|
||||||
14
src/booking/dtos/my-booking-request.dto.ts
Normal file
14
src/booking/dtos/my-booking-request.dto.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class MyBookingRequestDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Filter bookings by time period',
|
||||||
|
example: 'past',
|
||||||
|
enum: ['past', 'future'],
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['past', 'future'])
|
||||||
|
when?: 'past' | 'future';
|
||||||
|
}
|
||||||
12
src/booking/dtos/update-bookable-space.dto.ts
Normal file
12
src/booking/dtos/update-bookable-space.dto.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
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
src/booking/index.ts
Normal file
1
src/booking/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './booking.module';
|
||||||
371
src/booking/services/bookable-space.service.ts
Normal file
371
src/booking/services/bookable-space.service.ts
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
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/bookable-space.repository';
|
||||||
|
import { BookingEntityRepository } from '@app/common/modules/booking/repositories/booking.repository';
|
||||||
|
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
|
||||||
|
import { SpaceRepository } from '@app/common/modules/space/repositories/space.repository';
|
||||||
|
import { EmailService } from '@app/common/util/email/email.service';
|
||||||
|
import { to12HourFormat } from '@app/common/util/time-to-12-hours-convetion';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Brackets, In } from 'typeorm';
|
||||||
|
import { BookableSpaceRequestDto } from '../dtos/bookable-space-request.dto';
|
||||||
|
import { CreateBookableSpaceDto } from '../dtos/create-bookable-space.dto';
|
||||||
|
import { UpdateBookableSpaceDto } from '../dtos/update-bookable-space.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookableSpaceService {
|
||||||
|
constructor(
|
||||||
|
private readonly emailService: EmailService,
|
||||||
|
private readonly bookableSpaceEntityRepository: BookableSpaceEntityRepository,
|
||||||
|
private readonly bookingEntityRepository: BookingEntityRepository,
|
||||||
|
private readonly spaceRepository: SpaceRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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('space.disabled = :disabled', { disabled: false })
|
||||||
|
.andWhere('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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update bookable space configuration
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
dto.startTime != space.bookableConfig.startTime ||
|
||||||
|
dto.endTime != space.bookableConfig.endTime ||
|
||||||
|
dto.daysAvailable != space.bookableConfig.daysAvailable
|
||||||
|
) {
|
||||||
|
this.handleTimingUpdate(
|
||||||
|
{
|
||||||
|
daysAvailable:
|
||||||
|
dto.daysAvailable || space.bookableConfig.daysAvailable,
|
||||||
|
startTime: dto.startTime || space.bookableConfig.startTime,
|
||||||
|
endTime: dto.endTime || space.bookableConfig.endTime,
|
||||||
|
},
|
||||||
|
space,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
dto.active !== undefined &&
|
||||||
|
dto.active !== space.bookableConfig.active
|
||||||
|
) {
|
||||||
|
this.handleAvailabilityUpdate(dto.active, space);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(space.bookableConfig, dto);
|
||||||
|
return this.bookableSpaceEntityRepository.save(space.bookableConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleTimingUpdate(
|
||||||
|
dto: UpdateBookableSpaceDto,
|
||||||
|
space: SpaceEntity,
|
||||||
|
): Promise<void> {
|
||||||
|
const affectedUsers = await this.getAffectedBookings(space.uuid);
|
||||||
|
if (!affectedUsers.length) return;
|
||||||
|
|
||||||
|
const groupedParams = this.groupBookingsByUser(affectedUsers);
|
||||||
|
|
||||||
|
return this.emailService.sendUpdateBookingTimingEmailWithTemplate(
|
||||||
|
groupedParams,
|
||||||
|
{
|
||||||
|
space_name: space.spaceName,
|
||||||
|
start_time: to12HourFormat(dto.startTime),
|
||||||
|
end_time: to12HourFormat(dto.endTime),
|
||||||
|
days: dto.daysAvailable.join(', '),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAffectedBookings(spaceUuid: string) {
|
||||||
|
const today = new Date();
|
||||||
|
const nowTime = format(today, 'HH:mm');
|
||||||
|
|
||||||
|
const bookingWithDayCte = this.bookingEntityRepository
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.select('b.*')
|
||||||
|
.addSelect(
|
||||||
|
`
|
||||||
|
CASE EXTRACT(DOW FROM b.date)
|
||||||
|
WHEN 0 THEN 'Sun'
|
||||||
|
WHEN 1 THEN 'Mon'
|
||||||
|
WHEN 2 THEN 'Tue'
|
||||||
|
WHEN 3 THEN 'Wed'
|
||||||
|
WHEN 4 THEN 'Thu'
|
||||||
|
WHEN 5 THEN 'Fri'
|
||||||
|
WHEN 6 THEN 'Sat'
|
||||||
|
END::"bookable-space_days_available_enum"
|
||||||
|
`,
|
||||||
|
'booking_day',
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
`(DATE(b.date) > :today OR (DATE(b.date) = :today AND b.startTime >= :nowTime))`,
|
||||||
|
{ today, nowTime },
|
||||||
|
)
|
||||||
|
.andWhere('b.space_uuid = :spaceUuid', { spaceUuid });
|
||||||
|
|
||||||
|
const query = this.bookableSpaceEntityRepository
|
||||||
|
.createQueryBuilder('bs')
|
||||||
|
.distinct(true)
|
||||||
|
.addCommonTableExpression(bookingWithDayCte, 'booking_with_day')
|
||||||
|
.select('u.first_name', 'name')
|
||||||
|
.addSelect('u.email', 'email')
|
||||||
|
.addSelect('DATE(bwd.date)', 'date')
|
||||||
|
.addSelect('bwd.start_time', 'start_time')
|
||||||
|
.addSelect('bwd.end_time', 'end_time')
|
||||||
|
.from('booking_with_day', 'bwd')
|
||||||
|
.innerJoin('user', 'u', 'u.uuid = bwd.user_uuid')
|
||||||
|
.where('bs.space_uuid = :spaceUuid', { spaceUuid })
|
||||||
|
.andWhere(
|
||||||
|
new Brackets((qb) => {
|
||||||
|
qb.where('NOT (bwd.booking_day = ANY(bs.days_available))')
|
||||||
|
.orWhere('bwd.start_time < bs.start_time')
|
||||||
|
.orWhere('bwd.end_time > bs.end_time');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return query.getRawMany<{
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
}>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private groupBookingsByUser(
|
||||||
|
bookings: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
}[],
|
||||||
|
): {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
bookings: { date: string; start_time: string; end_time: string }[];
|
||||||
|
}[] {
|
||||||
|
const grouped: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
bookings: { date: string; start_time: string; end_time: string }[];
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
|
||||||
|
for (const { name, email, date, start_time, end_time } of bookings) {
|
||||||
|
const formattedDate = format(new Date(date), 'yyyy-MM-dd');
|
||||||
|
const formattedStartTime = to12HourFormat(start_time);
|
||||||
|
const formattedEndTime = to12HourFormat(end_time);
|
||||||
|
|
||||||
|
if (!grouped[email]) {
|
||||||
|
grouped[email] = {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
bookings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped[email].bookings.push({
|
||||||
|
date: formattedDate,
|
||||||
|
start_time: formattedStartTime,
|
||||||
|
end_time: formattedEndTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(grouped);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleAvailabilityUpdate(
|
||||||
|
active: boolean,
|
||||||
|
space: SpaceEntity,
|
||||||
|
): Promise<void> {
|
||||||
|
space = await this.spaceRepository.findOne({
|
||||||
|
where: { uuid: space.uuid },
|
||||||
|
relations: ['userSpaces', 'userSpaces.user'],
|
||||||
|
});
|
||||||
|
const emails = space.userSpaces.map((userSpace) => ({
|
||||||
|
email: userSpace.user.email,
|
||||||
|
name: userSpace.user.firstName,
|
||||||
|
}));
|
||||||
|
if (!emails.length) return Promise.resolve();
|
||||||
|
|
||||||
|
return this.emailService.sendUpdateBookingAvailabilityEmailWithTemplate(
|
||||||
|
emails,
|
||||||
|
{
|
||||||
|
availability: active ? 'Available' : 'Unavailable',
|
||||||
|
space_name: space.spaceName,
|
||||||
|
isAvailable: active,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch spaces by UUIDs and throw an error if any are missing
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
src/booking/services/booking.service.ts
Normal file
218
src/booking/services/booking.service.ts
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import { DaysEnum } from '@app/common/constants/days.enum';
|
||||||
|
import { timeToMinutes } from '@app/common/helper/timeToMinutes';
|
||||||
|
import { BookingEntityRepository } from '@app/common/modules/booking/repositories/booking.repository';
|
||||||
|
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
|
||||||
|
import { SpaceRepository } from '@app/common/modules/space/repositories/space.repository';
|
||||||
|
import { UserRepository } from '@app/common/modules/user/repositories/user.repository';
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Between } from 'typeorm/find-options/operator/Between';
|
||||||
|
import { BookingRequestDto } from '../dtos/booking-request.dto';
|
||||||
|
import { CreateBookingDto } from '../dtos/create-booking.dto';
|
||||||
|
import { MyBookingRequestDto } from '../dtos/my-booking-request.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingService {
|
||||||
|
constructor(
|
||||||
|
private readonly bookingEntityRepository: BookingEntityRepository,
|
||||||
|
private readonly spaceRepository: SpaceRepository,
|
||||||
|
private readonly userRepository: UserRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(userUuid: string, dto: CreateBookingDto) {
|
||||||
|
console.log(userUuid);
|
||||||
|
const user = await this.userRepository.findOne({
|
||||||
|
where: { uuid: userUuid },
|
||||||
|
relations: ['userSpaces', 'userSpaces.space'],
|
||||||
|
});
|
||||||
|
console.log(user.userSpaces);
|
||||||
|
if (!user.userSpaces.some(({ space }) => space.uuid === dto.spaceUuid)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`User does not have permission to book this space: ${dto.spaceUuid}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Validate time slots first
|
||||||
|
this.validateTimeSlot(dto.startTime, dto.endTime);
|
||||||
|
|
||||||
|
// fetch spaces exist
|
||||||
|
const space = await this.getSpaceConfigurationAndBookings(dto.spaceUuid);
|
||||||
|
|
||||||
|
// Validate booking availability
|
||||||
|
this.validateBookingAvailability(space, dto);
|
||||||
|
|
||||||
|
// Create and save booking
|
||||||
|
return this.createBookings(space, userUuid, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll({ month, space }: BookingRequestDto, project: string) {
|
||||||
|
const [monthNumber, year] = month.split('-').map(Number);
|
||||||
|
const fromDate = new Date(year, monthNumber - 1, 1);
|
||||||
|
const toDate = new Date(year, monthNumber, 0, 23, 59, 59);
|
||||||
|
return this.bookingEntityRepository.find({
|
||||||
|
where: {
|
||||||
|
space: {
|
||||||
|
community: { project: { uuid: project } },
|
||||||
|
uuid: space ? space : undefined,
|
||||||
|
},
|
||||||
|
date: Between(fromDate, toDate),
|
||||||
|
},
|
||||||
|
relations: ['space', 'user', 'user.inviteUser'],
|
||||||
|
order: { date: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findMyBookings(
|
||||||
|
{ when }: MyBookingRequestDto,
|
||||||
|
userUuid: string,
|
||||||
|
project: string,
|
||||||
|
) {
|
||||||
|
const now = new Date();
|
||||||
|
const nowTime = format(now, 'HH:mm');
|
||||||
|
|
||||||
|
const query = this.bookingEntityRepository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.space', 'space')
|
||||||
|
.innerJoin(
|
||||||
|
'space.community',
|
||||||
|
'community',
|
||||||
|
'community.project = :project',
|
||||||
|
{ project },
|
||||||
|
)
|
||||||
|
.leftJoinAndSelect('booking.user', 'user')
|
||||||
|
.where('user.uuid = :userUuid', { userUuid });
|
||||||
|
|
||||||
|
if (when === 'past') {
|
||||||
|
query.andWhere(
|
||||||
|
`(DATE(booking.date) < :today OR (DATE(booking.date) = :today AND booking.startTime < :nowTime))`,
|
||||||
|
{ today: now, nowTime },
|
||||||
|
);
|
||||||
|
} else if (when === 'future') {
|
||||||
|
query.andWhere(
|
||||||
|
`(DATE(booking.date) > :today OR (DATE(booking.date) = :today AND booking.startTime >= :nowTime))`,
|
||||||
|
{ today: now, nowTime },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
query.orderBy({
|
||||||
|
'DATE(booking.date)': 'DESC',
|
||||||
|
'booking.startTime': 'DESC',
|
||||||
|
});
|
||||||
|
|
||||||
|
return query.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch space by UUID and throw an error if not found or if not configured for booking
|
||||||
|
*/
|
||||||
|
private async getSpaceConfigurationAndBookings(
|
||||||
|
spaceUuid: string,
|
||||||
|
): Promise<SpaceEntity> {
|
||||||
|
const space = await this.spaceRepository.findOne({
|
||||||
|
where: { uuid: spaceUuid },
|
||||||
|
relations: ['bookableConfig', 'bookings'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!space) {
|
||||||
|
throw new NotFoundException(`Space not found: ${spaceUuid}`);
|
||||||
|
}
|
||||||
|
if (!space.bookableConfig) {
|
||||||
|
throw new NotFoundException(
|
||||||
|
`This space is not configured for booking: ${spaceUuid}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return space;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the slot start time is before the end time
|
||||||
|
*/
|
||||||
|
private validateTimeSlot(startTime: string, endTime: string): void {
|
||||||
|
const start = timeToMinutes(startTime);
|
||||||
|
const end = timeToMinutes(endTime);
|
||||||
|
|
||||||
|
if (start >= end) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`End time must be after start time for slot: ${startTime}-${endTime}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* check if the space is available for booking on the requested day
|
||||||
|
* and if the requested time slot is within the available hours
|
||||||
|
*/
|
||||||
|
private validateBookingAvailability(
|
||||||
|
space: SpaceEntity,
|
||||||
|
dto: CreateBookingDto,
|
||||||
|
): void {
|
||||||
|
// Check if the space is available for booking on the requested day
|
||||||
|
const availableDays = space.bookableConfig?.daysAvailable || [];
|
||||||
|
const requestedDay = new Date(dto.date).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
}) as DaysEnum;
|
||||||
|
|
||||||
|
if (!availableDays.includes(requestedDay)) {
|
||||||
|
const dayFullName = new Date(dto.date).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
});
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Space is not available for booking on ${dayFullName}s`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dtoStartTimeInMinutes = timeToMinutes(dto.startTime);
|
||||||
|
const dtoEndTimeInMinutes = timeToMinutes(dto.endTime);
|
||||||
|
|
||||||
|
if (
|
||||||
|
dtoStartTimeInMinutes < timeToMinutes(space.bookableConfig.startTime) ||
|
||||||
|
dtoEndTimeInMinutes > timeToMinutes(space.bookableConfig.endTime)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking time must be within the available hours for space: ${space.spaceName}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousBookings = space.bookings.filter(
|
||||||
|
(booking) =>
|
||||||
|
timeToMinutes(booking.startTime) < dtoEndTimeInMinutes &&
|
||||||
|
timeToMinutes(booking.endTime) > dtoStartTimeInMinutes &&
|
||||||
|
format(new Date(booking.date), 'yyyy-MM-dd') ===
|
||||||
|
format(new Date(dto.date), 'yyyy-MM-dd'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (previousBookings.length > 0) {
|
||||||
|
// tell the user what time is unavailable
|
||||||
|
const unavailableTimes = previousBookings.map((booking) => {
|
||||||
|
return `${booking.startTime}-${booking.endTime}`;
|
||||||
|
});
|
||||||
|
throw new ConflictException(
|
||||||
|
`Space is already booked during this times: ${unavailableTimes.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Create bookable space entries after all validations pass
|
||||||
|
*/
|
||||||
|
private async createBookings(
|
||||||
|
space: SpaceEntity,
|
||||||
|
user: string,
|
||||||
|
{ spaceUuid, date, ...dto }: CreateBookingDto,
|
||||||
|
) {
|
||||||
|
const entry = this.bookingEntityRepository.create({
|
||||||
|
space: { uuid: spaceUuid },
|
||||||
|
user: { uuid: user },
|
||||||
|
...dto,
|
||||||
|
date: new Date(date),
|
||||||
|
cost: space.bookableConfig?.points || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.bookingEntityRepository.save(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,7 +25,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { SpaceService } from 'src/space/services';
|
import { SpaceService } from 'src/space/services';
|
||||||
import { QueryRunner, SelectQueryBuilder } from 'typeorm';
|
import { Brackets, QueryRunner, SelectQueryBuilder } from 'typeorm';
|
||||||
import { AddCommunityDto, GetCommunityParams, ProjectParam } from '../dtos';
|
import { AddCommunityDto, GetCommunityParams, ProjectParam } from '../dtos';
|
||||||
import { UpdateCommunityNameDto } from '../dtos/update.community.dto';
|
import { UpdateCommunityNameDto } from '../dtos/update.community.dto';
|
||||||
|
|
||||||
@ -184,18 +184,46 @@ export class CommunityService {
|
|||||||
|
|
||||||
let qb: undefined | SelectQueryBuilder<CommunityEntity> = undefined;
|
let qb: undefined | SelectQueryBuilder<CommunityEntity> = undefined;
|
||||||
|
|
||||||
|
const matchingCommunityIdsQb = this.communityRepository
|
||||||
|
.createQueryBuilder('c')
|
||||||
|
.select('c.uuid')
|
||||||
|
.where('c.project = :projectUuid', { projectUuid })
|
||||||
|
.andWhere('c.name != :orphanCommunityName', {
|
||||||
|
orphanCommunityName: `${ORPHAN_COMMUNITY_NAME}-${project.name}`,
|
||||||
|
})
|
||||||
|
.distinct(true);
|
||||||
|
if (includeSpaces) {
|
||||||
|
matchingCommunityIdsQb.leftJoin('c.spaces', 'space');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
matchingCommunityIdsQb
|
||||||
|
.andWhere(
|
||||||
|
new Brackets((qb) => {
|
||||||
|
qb.where('c.name ILIKE :search');
|
||||||
|
if (includeSpaces) qb.orWhere('space.spaceName ILIKE :search');
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.setParameter('search', `%${search}%`);
|
||||||
|
}
|
||||||
|
|
||||||
qb = this.communityRepository
|
qb = this.communityRepository
|
||||||
.createQueryBuilder('c')
|
.createQueryBuilder('c')
|
||||||
.where('c.project = :projectUuid', { projectUuid })
|
.where('c.project = :projectUuid', { projectUuid })
|
||||||
.andWhere(`c.name != '${ORPHAN_COMMUNITY_NAME}-${project.name}'`)
|
.andWhere('c.name != :orphanCommunityName', {
|
||||||
.distinct(true);
|
orphanCommunityName: `${ORPHAN_COMMUNITY_NAME}-${project.name}`,
|
||||||
|
})
|
||||||
|
.andWhere(`c.uuid IN (${matchingCommunityIdsQb.getQuery()})`)
|
||||||
|
.setParameters(matchingCommunityIdsQb.getParameters());
|
||||||
if (includeSpaces) {
|
if (includeSpaces) {
|
||||||
qb.leftJoinAndSelect(
|
qb.leftJoinAndSelect(
|
||||||
'c.spaces',
|
'c.spaces',
|
||||||
'space',
|
'space',
|
||||||
'space.disabled = :disabled AND space.spaceName != :orphanSpaceName',
|
'space.disabled = :disabled AND space.spaceName != :orphanSpaceName',
|
||||||
{ disabled: false, orphanSpaceName: ORPHAN_SPACE_NAME },
|
{
|
||||||
|
disabled: false,
|
||||||
|
orphanSpaceName: ORPHAN_SPACE_NAME,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.leftJoinAndSelect('space.parent', 'parent')
|
.leftJoinAndSelect('space.parent', 'parent')
|
||||||
.leftJoinAndSelect(
|
.leftJoinAndSelect(
|
||||||
@ -204,16 +232,7 @@ export class CommunityService {
|
|||||||
'children.disabled = :disabled',
|
'children.disabled = :disabled',
|
||||||
{ disabled: false },
|
{ disabled: false },
|
||||||
);
|
);
|
||||||
// .leftJoinAndSelect('space.spaceModel', 'spaceModel')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
|
||||||
qb.andWhere(
|
|
||||||
`c.name ILIKE :search ${includeSpaces ? 'OR space.space_name ILIKE :search' : ''}`,
|
|
||||||
{ search: `%${search}%` },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const customModel = TypeORMCustomModel(this.communityRepository);
|
const customModel = TypeORMCustomModel(this.communityRepository);
|
||||||
|
|
||||||
const { baseResponseDto, paginationResponseDto } =
|
const { baseResponseDto, paginationResponseDto } =
|
||||||
|
|||||||
@ -2,10 +2,12 @@ import { ApiProperty } from '@nestjs/swagger';
|
|||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsDate,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
|
MinDate,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class AddUserInvitationDto {
|
export class AddUserInvitationDto {
|
||||||
@ -16,7 +18,7 @@ export class AddUserInvitationDto {
|
|||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
public firstName: string;
|
firstName: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'The last name of the user',
|
description: 'The last name of the user',
|
||||||
@ -25,7 +27,7 @@ export class AddUserInvitationDto {
|
|||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
public lastName: string;
|
lastName: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'The email of the user',
|
description: 'The email of the user',
|
||||||
@ -34,7 +36,7 @@ export class AddUserInvitationDto {
|
|||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
public email: string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'The job title of the user',
|
description: 'The job title of the user',
|
||||||
@ -43,7 +45,7 @@ export class AddUserInvitationDto {
|
|||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
public jobTitle?: string;
|
jobTitle?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'The company name of the user',
|
description: 'The company name of the user',
|
||||||
@ -52,7 +54,27 @@ export class AddUserInvitationDto {
|
|||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
public companyName?: string;
|
companyName?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Access start date',
|
||||||
|
example: new Date(),
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
@IsDate()
|
||||||
|
@MinDate(new Date())
|
||||||
|
@IsOptional()
|
||||||
|
accessStartDate?: Date;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Access start date',
|
||||||
|
example: new Date(),
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
@IsDate()
|
||||||
|
@MinDate(new Date())
|
||||||
|
@IsOptional()
|
||||||
|
accessEndDate?: Date;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'The phone number of the user',
|
description: 'The phone number of the user',
|
||||||
@ -61,7 +83,7 @@ export class AddUserInvitationDto {
|
|||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
public phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'The role uuid of the user',
|
description: 'The role uuid of the user',
|
||||||
@ -70,7 +92,7 @@ export class AddUserInvitationDto {
|
|||||||
})
|
})
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
public roleUuid: string;
|
roleUuid: string;
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'The project uuid of the user',
|
description: 'The project uuid of the user',
|
||||||
example: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
|
example: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
|
||||||
@ -78,7 +100,7 @@ export class AddUserInvitationDto {
|
|||||||
})
|
})
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
public projectUuid: string;
|
projectUuid: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'The array of space UUIDs (at least one required)',
|
description: 'The array of space UUIDs (at least one required)',
|
||||||
@ -88,7 +110,7 @@ export class AddUserInvitationDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsUUID('4', { each: true })
|
@IsUUID('4', { each: true })
|
||||||
@ArrayMinSize(1)
|
@ArrayMinSize(1)
|
||||||
public spaceUuids: string[];
|
spaceUuids: string[];
|
||||||
constructor(dto: Partial<AddUserInvitationDto>) {
|
constructor(dto: Partial<AddUserInvitationDto>) {
|
||||||
Object.assign(this, dto);
|
Object.assign(this, dto);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { RoleType } from '@app/common/constants/role.type.enum';
|
import { RoleType } from '@app/common/constants/role.type.enum';
|
||||||
|
import { TimerJobTypeEnum } from '@app/common/constants/timer-job-type.enum';
|
||||||
import { UserStatusEnum } from '@app/common/constants/user-status.enum';
|
import { UserStatusEnum } from '@app/common/constants/user-status.enum';
|
||||||
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
|
import { BaseResponseDto } from '@app/common/dto/base.response.dto';
|
||||||
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
|
import { SuccessResponseDto } from '@app/common/dto/success.response.dto';
|
||||||
@ -23,6 +24,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { SpaceUserService } from 'src/space/services';
|
import { SpaceUserService } from 'src/space/services';
|
||||||
|
import { TimerService } from 'src/timer/timer.service';
|
||||||
import { UserSpaceService } from 'src/users/services';
|
import { UserSpaceService } from 'src/users/services';
|
||||||
import {
|
import {
|
||||||
DataSource,
|
DataSource,
|
||||||
@ -52,6 +54,7 @@ export class InviteUserService {
|
|||||||
private readonly spaceRepository: SpaceRepository,
|
private readonly spaceRepository: SpaceRepository,
|
||||||
private readonly roleTypeRepository: RoleTypeRepository,
|
private readonly roleTypeRepository: RoleTypeRepository,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly timerService: TimerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async createUserInvitation(
|
async createUserInvitation(
|
||||||
@ -68,8 +71,14 @@ export class InviteUserService {
|
|||||||
roleUuid,
|
roleUuid,
|
||||||
spaceUuids,
|
spaceUuids,
|
||||||
projectUuid,
|
projectUuid,
|
||||||
|
accessStartDate,
|
||||||
|
accessEndDate,
|
||||||
} = dto;
|
} = dto;
|
||||||
|
if (accessStartDate && accessEndDate && accessEndDate <= accessStartDate) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'accessEndDate must be after accessStartDate',
|
||||||
|
);
|
||||||
|
}
|
||||||
const invitationCode = generateRandomString(6);
|
const invitationCode = generateRandomString(6);
|
||||||
const queryRunner = this.dataSource.createQueryRunner();
|
const queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
|
||||||
@ -114,6 +123,8 @@ export class InviteUserService {
|
|||||||
invitationCode,
|
invitationCode,
|
||||||
invitedBy: roleType,
|
invitedBy: roleType,
|
||||||
project: { uuid: projectUuid },
|
project: { uuid: projectUuid },
|
||||||
|
accessEndDate,
|
||||||
|
accessStartDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const invitedUser = await queryRunner.manager.save(inviteUser);
|
const invitedUser = await queryRunner.manager.save(inviteUser);
|
||||||
@ -132,12 +143,26 @@ export class InviteUserService {
|
|||||||
|
|
||||||
// Send invitation email
|
// Send invitation email
|
||||||
const spaceNames = validSpaces.map((space) => space.spaceName).join(', ');
|
const spaceNames = validSpaces.map((space) => space.spaceName).join(', ');
|
||||||
await this.emailService.sendEmailWithInvitationTemplate(email, {
|
if (accessStartDate) {
|
||||||
name: firstName,
|
await this.timerService.createJob({
|
||||||
invitationCode,
|
type: TimerJobTypeEnum.INVITE_USER_EMAIL,
|
||||||
role: invitedRoleType.replace(/_/g, ' '),
|
triggerDate: accessStartDate,
|
||||||
spacesList: spaceNames,
|
metadata: {
|
||||||
});
|
email,
|
||||||
|
name: firstName,
|
||||||
|
invitationCode,
|
||||||
|
role: invitedRoleType.replace(/_/g, ' '),
|
||||||
|
spacesList: spaceNames,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.emailService.sendEmailWithInvitationTemplate(email, {
|
||||||
|
name: firstName,
|
||||||
|
invitationCode,
|
||||||
|
role: invitedRoleType.replace(/_/g, ' '),
|
||||||
|
spacesList: spaceNames,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await queryRunner.commitTransaction();
|
await queryRunner.commitTransaction();
|
||||||
|
|
||||||
|
|||||||
1
src/occupancy/controllers/index.ts
Normal file
1
src/occupancy/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './occupancy.controller';
|
||||||
71
src/occupancy/controllers/occupancy.controller.ts
Normal file
71
src/occupancy/controllers/occupancy.controller.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/occupancy/dto/get-occupancy.dto.ts
Normal file
27
src/occupancy/dto/get-occupancy.dto.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
7
src/occupancy/dto/occupancy-params.dto.ts
Normal file
7
src/occupancy/dto/occupancy-params.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class SpaceParamsDto {
|
||||||
|
@IsUUID('4', { message: 'Invalid UUID format' })
|
||||||
|
@IsNotEmpty()
|
||||||
|
spaceUuid: string;
|
||||||
|
}
|
||||||
11
src/occupancy/occupancy.module.ts
Normal file
11
src/occupancy/occupancy.module.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
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
src/occupancy/services/index.ts
Normal file
1
src/occupancy/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './occupancy.service';
|
||||||
103
src/occupancy/services/occupancy.service.ts
Normal file
103
src/occupancy/services/occupancy.service.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|||||||
import { Permissions } from 'src/decorators/permissions.decorator';
|
import { Permissions } from 'src/decorators/permissions.decorator';
|
||||||
import { PermissionsGuard } from 'src/guards/permissions.guard';
|
import { PermissionsGuard } from 'src/guards/permissions.guard';
|
||||||
import { AddSpaceDto, CommunitySpaceParam, UpdateSpaceDto } from '../dtos';
|
import { AddSpaceDto, CommunitySpaceParam, UpdateSpaceDto } from '../dtos';
|
||||||
|
import { DuplicateSpaceDto } from '../dtos/duplicate-space.dto';
|
||||||
import { GetSpaceDto } from '../dtos/get.space.dto';
|
import { GetSpaceDto } from '../dtos/get.space.dto';
|
||||||
import { GetSpaceParam } from '../dtos/get.space.param';
|
import { GetSpaceParam } from '../dtos/get.space.param';
|
||||||
import { OrderSpacesDto } from '../dtos/order.spaces.dto';
|
import { OrderSpacesDto } from '../dtos/order.spaces.dto';
|
||||||
@ -48,6 +49,26 @@ export class SpaceController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(PermissionsGuard)
|
||||||
|
@Permissions('SPACE_ADD')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: ControllerRoute.SPACE.ACTIONS.DUPLICATE_SPACE_SUMMARY,
|
||||||
|
description: ControllerRoute.SPACE.ACTIONS.DUPLICATE_SPACE_DESCRIPTION,
|
||||||
|
})
|
||||||
|
@Post(':spaceUuid/duplicate')
|
||||||
|
async duplicateSpace(
|
||||||
|
@Param('spaceUuid', ParseUUIDPipe) spaceUuid: string,
|
||||||
|
@Body() dto: DuplicateSpaceDto,
|
||||||
|
@Param() communitySpaceParam: CommunitySpaceParam,
|
||||||
|
): Promise<BaseResponseDto> {
|
||||||
|
return await this.spaceService.duplicateSpace(
|
||||||
|
spaceUuid,
|
||||||
|
communitySpaceParam,
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(PermissionsGuard)
|
@UseGuards(PermissionsGuard)
|
||||||
@Permissions('SPACE_VIEW')
|
@Permissions('SPACE_VIEW')
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
IsArray,
|
IsArray,
|
||||||
IsMongoId,
|
IsMongoId,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsNumber,
|
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
@ -48,14 +47,6 @@ export class AddSpaceDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
public icon?: string;
|
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({
|
@ApiProperty({
|
||||||
description: 'UUID of the Space Model',
|
description: 'UUID of the Space Model',
|
||||||
example: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
|
example: 'd290f1ee-6c54-4b01-90e6-d701748f0851',
|
||||||
|
|||||||
18
src/space/dtos/duplicate-space.dto.ts
Normal file
18
src/space/dtos/duplicate-space.dto.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { ORPHAN_SPACE_NAME } from '@app/common/constants/orphan-constant';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsString, NotEquals } from 'class-validator';
|
||||||
|
|
||||||
|
export class DuplicateSpaceDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Name of the space (e.g., Floor 1, Unit 101)',
|
||||||
|
example: 'Unit 101',
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@NotEquals(ORPHAN_SPACE_NAME, {
|
||||||
|
message() {
|
||||||
|
return `Space name cannot be "${ORPHAN_SPACE_NAME}". Please choose a different name.`;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
spaceName: string;
|
||||||
|
}
|
||||||
@ -4,7 +4,6 @@ import { Type } from 'class-transformer';
|
|||||||
import {
|
import {
|
||||||
ArrayUnique,
|
ArrayUnique,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsNumber,
|
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
NotEquals,
|
NotEquals,
|
||||||
@ -36,16 +35,6 @@ export class UpdateSpaceDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
public icon?: string;
|
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({
|
@ApiPropertyOptional({
|
||||||
description: 'List of subspace modifications',
|
description: 'List of subspace modifications',
|
||||||
type: [UpdateSubspaceDto],
|
type: [UpdateSubspaceDto],
|
||||||
|
|||||||
@ -133,7 +133,7 @@ export class ValidationService {
|
|||||||
'subspaces.productAllocations',
|
'subspaces.productAllocations',
|
||||||
'subspaces.productAllocations.product',
|
'subspaces.productAllocations.product',
|
||||||
'subspaces.devices',
|
'subspaces.devices',
|
||||||
'spaceModel',
|
// 'spaceModel',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { generateRandomString } from '@app/common/helper/randomString';
|
|||||||
import { removeCircularReferences } from '@app/common/helper/removeCircularReferences';
|
import { removeCircularReferences } from '@app/common/helper/removeCircularReferences';
|
||||||
import { SpaceProductAllocationEntity } from '@app/common/modules/space/entities/space-product-allocation.entity';
|
import { SpaceProductAllocationEntity } from '@app/common/modules/space/entities/space-product-allocation.entity';
|
||||||
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
|
import { SpaceEntity } from '@app/common/modules/space/entities/space.entity';
|
||||||
|
import { SubspaceProductAllocationEntity } from '@app/common/modules/space/entities/subspace/subspace-product-allocation.entity';
|
||||||
import { SubspaceEntity } from '@app/common/modules/space/entities/subspace/subspace.entity';
|
import { SubspaceEntity } from '@app/common/modules/space/entities/subspace/subspace.entity';
|
||||||
import {
|
import {
|
||||||
InviteSpaceRepository,
|
InviteSpaceRepository,
|
||||||
@ -24,6 +25,7 @@ import { DeviceService } from 'src/device/services';
|
|||||||
import { SpaceModelService } from 'src/space-model/services';
|
import { SpaceModelService } from 'src/space-model/services';
|
||||||
import { TagService } from 'src/tags/services/tags.service';
|
import { TagService } from 'src/tags/services/tags.service';
|
||||||
import { DataSource, In, Not, QueryRunner } from 'typeorm';
|
import { DataSource, In, Not, QueryRunner } from 'typeorm';
|
||||||
|
import { v4 as uuidV4 } from 'uuid';
|
||||||
import { DisableSpaceCommand } from '../commands';
|
import { DisableSpaceCommand } from '../commands';
|
||||||
import {
|
import {
|
||||||
AddSpaceDto,
|
AddSpaceDto,
|
||||||
@ -32,6 +34,7 @@ import {
|
|||||||
UpdateSpaceDto,
|
UpdateSpaceDto,
|
||||||
} from '../dtos';
|
} from '../dtos';
|
||||||
import { CreateProductAllocationDto } from '../dtos/create-product-allocation.dto';
|
import { CreateProductAllocationDto } from '../dtos/create-product-allocation.dto';
|
||||||
|
import { DuplicateSpaceDto } from '../dtos/duplicate-space.dto';
|
||||||
import { GetSpaceDto } from '../dtos/get.space.dto';
|
import { GetSpaceDto } from '../dtos/get.space.dto';
|
||||||
import { OrderSpacesDto } from '../dtos/order.spaces.dto';
|
import { OrderSpacesDto } from '../dtos/order.spaces.dto';
|
||||||
import { SpaceWithParentsDto } from '../dtos/space.parents.dto';
|
import { SpaceWithParentsDto } from '../dtos/space.parents.dto';
|
||||||
@ -93,6 +96,9 @@ export class SpaceService {
|
|||||||
parentUuid && !isRecursiveCall
|
parentUuid && !isRecursiveCall
|
||||||
? await this.validationService.validateSpace(parentUuid)
|
? await this.validationService.validateSpace(parentUuid)
|
||||||
: null;
|
: null;
|
||||||
|
if (parent) {
|
||||||
|
await this.validateNamingConflict(addSpaceDto.spaceName, parent);
|
||||||
|
}
|
||||||
|
|
||||||
const spaceModel = spaceModelUuid
|
const spaceModel = spaceModelUuid
|
||||||
? await this.validationService.validateSpaceModel(spaceModelUuid)
|
? await this.validationService.validateSpaceModel(spaceModelUuid)
|
||||||
@ -102,8 +108,6 @@ export class SpaceService {
|
|||||||
// todo: find a better way to handle this instead of naming every key
|
// todo: find a better way to handle this instead of naming every key
|
||||||
spaceName: addSpaceDto.spaceName,
|
spaceName: addSpaceDto.spaceName,
|
||||||
icon: addSpaceDto.icon,
|
icon: addSpaceDto.icon,
|
||||||
x: addSpaceDto.x,
|
|
||||||
y: addSpaceDto.y,
|
|
||||||
spaceModel,
|
spaceModel,
|
||||||
parent: isRecursiveCall
|
parent: isRecursiveCall
|
||||||
? recursiveCallParentEntity
|
? recursiveCallParentEntity
|
||||||
@ -181,6 +185,154 @@ export class SpaceService {
|
|||||||
!isRecursiveCall ? await queryRunner.release() : null;
|
!isRecursiveCall ? await queryRunner.release() : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async duplicateSpace(
|
||||||
|
spaceUuid: string,
|
||||||
|
{ communityUuid, projectUuid }: CommunitySpaceParam,
|
||||||
|
dto: DuplicateSpaceDto,
|
||||||
|
queryRunner?: QueryRunner,
|
||||||
|
): Promise<BaseResponseDto> {
|
||||||
|
if (!queryRunner) {
|
||||||
|
queryRunner = this.dataSource.createQueryRunner();
|
||||||
|
await queryRunner.connect();
|
||||||
|
await queryRunner.startTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.validationService.validateCommunityAndProject(
|
||||||
|
communityUuid,
|
||||||
|
projectUuid,
|
||||||
|
queryRunner,
|
||||||
|
);
|
||||||
|
await this.handleSpaceDuplication(spaceUuid, dto.spaceName, queryRunner);
|
||||||
|
|
||||||
|
await queryRunner.commitTransaction();
|
||||||
|
const { data } = await this.getSpacesHierarchyForCommunity(
|
||||||
|
{
|
||||||
|
projectUuid,
|
||||||
|
communityUuid,
|
||||||
|
},
|
||||||
|
{ onlyWithDevices: false },
|
||||||
|
);
|
||||||
|
return new SuccessResponseDto({
|
||||||
|
message: `Space with ID ${spaceUuid} successfully duplicated`,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await queryRunner.rollbackTransaction();
|
||||||
|
console.log((error as Error).stack);
|
||||||
|
throw new HttpException(error.message, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
} finally {
|
||||||
|
await queryRunner.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async handleSpaceDuplication(
|
||||||
|
spaceUuid: string,
|
||||||
|
newSpaceName: string | null,
|
||||||
|
queryRunner: QueryRunner,
|
||||||
|
parent?: SpaceEntity,
|
||||||
|
) {
|
||||||
|
const space = await this.spaceRepository.findOne({
|
||||||
|
where: { uuid: spaceUuid },
|
||||||
|
relations: [
|
||||||
|
'children',
|
||||||
|
'productAllocations',
|
||||||
|
'subspaces',
|
||||||
|
'subspaces.productAllocations',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const clonedSpace = structuredClone(space);
|
||||||
|
const newSpace = queryRunner.manager.create(SpaceEntity, {
|
||||||
|
...clonedSpace,
|
||||||
|
spaceName: newSpaceName || clonedSpace.spaceName,
|
||||||
|
parent,
|
||||||
|
children: undefined,
|
||||||
|
subspaces: undefined,
|
||||||
|
productAllocations: undefined,
|
||||||
|
uuid: uuidV4(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (clonedSpace.productAllocations?.length) {
|
||||||
|
newSpace.productAllocations = this.copySpaceAllocations(
|
||||||
|
newSpace,
|
||||||
|
clonedSpace.productAllocations,
|
||||||
|
queryRunner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (clonedSpace.subspaces?.length) {
|
||||||
|
newSpace.subspaces = this.copySpaceSubspaces(
|
||||||
|
newSpace,
|
||||||
|
clonedSpace.subspaces,
|
||||||
|
queryRunner,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const savedSpace = await queryRunner.manager.save(newSpace);
|
||||||
|
|
||||||
|
if (clonedSpace.children?.length) {
|
||||||
|
for (const child of clonedSpace.children) {
|
||||||
|
if (child.disabled) continue;
|
||||||
|
await this.handleSpaceDuplication(
|
||||||
|
child.uuid,
|
||||||
|
child.spaceName,
|
||||||
|
queryRunner,
|
||||||
|
savedSpace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return savedSpace;
|
||||||
|
}
|
||||||
|
private copySpaceSubspaces(
|
||||||
|
newSpace: SpaceEntity,
|
||||||
|
subspaces: SubspaceEntity[],
|
||||||
|
queryRunner: QueryRunner,
|
||||||
|
) {
|
||||||
|
const newSubspaces = [];
|
||||||
|
for (const sub of subspaces) {
|
||||||
|
if (sub.disabled) continue;
|
||||||
|
const clonedSub = structuredClone(sub);
|
||||||
|
delete clonedSub.uuid;
|
||||||
|
const newSubspace = queryRunner.manager.create(SubspaceEntity, {
|
||||||
|
...clonedSub,
|
||||||
|
space: newSpace,
|
||||||
|
productAllocations: [],
|
||||||
|
uuid: uuidV4(),
|
||||||
|
});
|
||||||
|
if (sub.productAllocations?.length) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
for (const { uuid, ...allocation } of sub.productAllocations) {
|
||||||
|
newSubspace.productAllocations.push(
|
||||||
|
queryRunner.manager.create(SubspaceProductAllocationEntity, {
|
||||||
|
...allocation,
|
||||||
|
subspace: newSubspace,
|
||||||
|
uuid: uuidV4(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newSubspaces.push(newSubspace);
|
||||||
|
}
|
||||||
|
return newSubspaces;
|
||||||
|
}
|
||||||
|
private copySpaceAllocations(
|
||||||
|
newSpace: SpaceEntity,
|
||||||
|
allocations: SpaceProductAllocationEntity[],
|
||||||
|
queryRunner: QueryRunner,
|
||||||
|
) {
|
||||||
|
const newAllocations = [];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
for (const { uuid, ...allocation } of allocations) {
|
||||||
|
newAllocations.push(
|
||||||
|
queryRunner.manager.create(SpaceProductAllocationEntity, {
|
||||||
|
...allocation,
|
||||||
|
space: newSpace,
|
||||||
|
uuid: uuidV4(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return newAllocations;
|
||||||
|
}
|
||||||
|
|
||||||
private checkDuplicateTags(allocations: CreateProductAllocationDto[]) {
|
private checkDuplicateTags(allocations: CreateProductAllocationDto[]) {
|
||||||
const tagUuidSet = new Set<string>();
|
const tagUuidSet = new Set<string>();
|
||||||
const tagNameProductSet = new Set<string>();
|
const tagNameProductSet = new Set<string>();
|
||||||
@ -505,6 +657,8 @@ export class SpaceService {
|
|||||||
spaceUuid,
|
spaceUuid,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.validateNamingConflict(updateSpaceDto.spaceName, space, true);
|
||||||
|
|
||||||
if (space.spaceModel && !updateSpaceDto.spaceModelUuid) {
|
if (space.spaceModel && !updateSpaceDto.spaceModelUuid) {
|
||||||
await queryRunner.manager.update(SpaceEntity, space.uuid, {
|
await queryRunner.manager.update(SpaceEntity, space.uuid, {
|
||||||
spaceModel: null,
|
spaceModel: null,
|
||||||
@ -655,13 +809,11 @@ export class SpaceService {
|
|||||||
updateSpaceDto: UpdateSpaceDto,
|
updateSpaceDto: UpdateSpaceDto,
|
||||||
queryRunner: QueryRunner,
|
queryRunner: QueryRunner,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { spaceName, x, y, icon } = updateSpaceDto;
|
const { spaceName, icon } = updateSpaceDto;
|
||||||
|
|
||||||
const updateFields: Partial<SpaceEntity> = {};
|
const updateFields: Partial<SpaceEntity> = {};
|
||||||
|
|
||||||
if (spaceName) updateFields.spaceName = spaceName;
|
if (spaceName) updateFields.spaceName = spaceName;
|
||||||
if (x !== undefined) updateFields.x = x;
|
|
||||||
if (y !== undefined) updateFields.y = y;
|
|
||||||
if (icon) updateFields.icon = icon;
|
if (icon) updateFields.icon = icon;
|
||||||
|
|
||||||
if (Object.keys(updateFields).length > 0) {
|
if (Object.keys(updateFields).length > 0) {
|
||||||
@ -828,4 +980,34 @@ export class SpaceService {
|
|||||||
queryRunner,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/timer/create-job.dto.ts
Normal file
20
src/timer/create-job.dto.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { TimerJobTypeEnum } from '@app/common/constants/timer-job-type.enum';
|
||||||
|
|
||||||
|
export class BaseCreateJobDto {
|
||||||
|
type: TimerJobTypeEnum;
|
||||||
|
triggerDate: Date;
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
}
|
||||||
|
// validate based on jobType
|
||||||
|
export class CreateUserInvitationJobDto extends BaseCreateJobDto {
|
||||||
|
type: TimerJobTypeEnum.INVITE_USER_EMAIL;
|
||||||
|
metadata: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
invitationCode: string;
|
||||||
|
role: string;
|
||||||
|
spacesList: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateJobDto = CreateUserInvitationJobDto;
|
||||||
10
src/timer/timer.module.ts
Normal file
10
src/timer/timer.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { EmailService } from '@app/common/util/email/email.service';
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { TimerService } from './timer.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [TimerService, EmailService],
|
||||||
|
exports: [TimerService],
|
||||||
|
})
|
||||||
|
export class TimerModule {}
|
||||||
53
src/timer/timer.service.ts
Normal file
53
src/timer/timer.service.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { TimerJobTypeEnum } from '@app/common/constants/timer-job-type.enum';
|
||||||
|
import { TimerEntity } from '@app/common/modules/timer/entities/timer.entity';
|
||||||
|
import { TimerRepository } from '@app/common/modules/timer/repositories/timer.repository';
|
||||||
|
import { EmailService } from '@app/common/util/email/email.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { In, LessThanOrEqual } from 'typeorm';
|
||||||
|
import { CreateJobDto } from './create-job.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TimerService {
|
||||||
|
constructor(
|
||||||
|
private readonly timerRepository: TimerRepository,
|
||||||
|
private readonly emailService: EmailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
createJob(job: CreateJobDto): Promise<TimerEntity> {
|
||||||
|
const timerEntity = this.timerRepository.create(job);
|
||||||
|
return this.timerRepository.save(timerEntity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||||
|
async handleCron() {
|
||||||
|
const jobsToRun = await this.timerRepository.find({
|
||||||
|
where: { triggerDate: LessThanOrEqual(new Date()) },
|
||||||
|
});
|
||||||
|
const successfulJobs = [];
|
||||||
|
for (const job of jobsToRun) {
|
||||||
|
try {
|
||||||
|
await this.handleJob(job);
|
||||||
|
|
||||||
|
successfulJobs.push(job.uuid);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Job ${job.uuid} failed:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.timerRepository.delete({ uuid: In(successfulJobs) });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleJob(job: TimerEntity) {
|
||||||
|
switch (job.type) {
|
||||||
|
case TimerJobTypeEnum.INVITE_USER_EMAIL:
|
||||||
|
return this.emailService.sendEmailWithInvitationTemplate(
|
||||||
|
job.metadata.email,
|
||||||
|
job.metadata,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
// Handle other job types as needed
|
||||||
|
default:
|
||||||
|
console.warn(`Unhandled job type: ${job.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -30,7 +30,7 @@ export class UserService {
|
|||||||
where: {
|
where: {
|
||||||
uuid: userUuid,
|
uuid: userUuid,
|
||||||
},
|
},
|
||||||
relations: ['region', 'timezone', 'roleType', 'project'],
|
relations: ['region', 'timezone', 'roleType', 'project', 'inviteUser'],
|
||||||
});
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new BadRequestException('Invalid room UUID');
|
throw new BadRequestException('Invalid room UUID');
|
||||||
@ -53,6 +53,10 @@ export class UserService {
|
|||||||
appAgreementAcceptedAt: user?.appAgreementAcceptedAt,
|
appAgreementAcceptedAt: user?.appAgreementAcceptedAt,
|
||||||
role: user?.roleType,
|
role: user?.roleType,
|
||||||
project: user?.project,
|
project: user?.project,
|
||||||
|
bookingPoints: user?.bookingPoints ?? 0,
|
||||||
|
accessStartDate: user?.inviteUser.accessStartDate,
|
||||||
|
accessEndDate: user?.inviteUser.accessEndDate,
|
||||||
|
bookingEnabled: user?.bookingEnabled,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof BadRequestException) {
|
if (err instanceof BadRequestException) {
|
||||||
|
|||||||
Reference in New Issue
Block a user