Compare commits

...

30 Commits

Author SHA1 Message Date
dcdb10a1fb add contract dates to invite user & return them in user details 2025-07-25 10:18:00 +03:00
2aa6a40af7 Merge pull request #484 from SyncrowIOT/fix/community-v2-search
Fix/community v2 search
2025-07-24 10:07:26 +03:00
7ae826eb71 remove space join if not required 2025-07-24 10:01:42 +03:00
beed6fcfb7 update community v2 search behavior 2025-07-24 09:25:04 +03:00
015470b5ea Merge pull request #483 from SyncrowIOT/refactor/duplicate-space-response
return proper response in duplicate space API
2025-07-23 13:43:33 +03:00
36640b104b remove console logs 2025-07-23 12:34:10 +03:00
d4af9eaccc return proper response in duplicate space API 2025-07-23 12:29:41 +03:00
f447cfa065 Merge pull request #470 from SyncrowIOT/fix/remove-x-y-from-space
SP-1853: Space Fixes
2025-07-23 09:02:54 +03:00
9d952d2fb0 Merge pull request #479 from SyncrowIOT/fix/booking-fixes
SP-1757, SP-1859 fixes
2025-07-22 13:39:03 +03:00
c0849c2252 Merge pull request #477 from SyncrowIOT/task/implement-duplicate-space-apo
SP-1822: implement duplicate space API
2025-07-22 13:38:38 +03:00
9254db08f9 Merge pull request #480 from SyncrowIOT/main
Update dev from main
2025-07-21 10:11:02 +03:00
b9c4308d1c Merge pull request #478 from SyncrowIOT/hotfix/booking-filter
Hotfix/booking filter
2025-07-21 09:38:32 +03:00
db95cc0dab fixes 2025-07-20 16:36:12 +03:00
87c380ab6f fix validation 2025-07-20 13:49:15 +03:00
212d0d1974 change month format from MM/YYYY to MM-YYYY
change month format from MM/YYYY to MM-YYYY
2025-07-20 13:28:25 +03:00
a060f92208 implement duplicate space API 2025-07-20 11:23:43 +03:00
6d529ee0ae change month format from MM/YYYY to MM-YYYY 2025-07-17 15:22:20 +04:00
85687e7950 fix: refactor device status logging and improve property handling in batch processing 2025-07-16 20:05:58 -06:00
7e2c3136cf fix: update workflow to use OpenAI for PR description generation 2025-07-16 00:28:20 -06:00
61348aa351 fix: update workflow to use HuggingFace Falcon model for PR description generation 2025-07-16 00:08:08 -06:00
dea942f11e fix: update workflow to use HuggingFace for PR description generation 2025-07-16 00:05:33 -06:00
d62e620828 fix: update workflow name and enhance OpenAI request handling in AI PR Description 2025-07-15 23:47:45 -06:00
f0556813ac fix: update workflow name and enhance commit message handling in AI PR Description 2025-07-15 23:45:16 -06:00
6d2252a403 fix: add debug information to AI PR Description workflow 2025-07-15 23:43:00 -06:00
8d265c9105 fix: update workflow name and change GitHub token secret 2025-07-15 23:38:57 -06:00
a4095c837b fix: rename workflow and update AI description generation method 2025-07-15 23:19:22 -06:00
65d4a56135 fix: update GitHub CLI installation method and refine AI PR description prompt 2025-07-15 19:00:23 -06:00
fffa27b6ee Add AI PR Description Action 2025-07-15 18:48:43 -06:00
12579fcd6e fix nodemailer import (#473) 2025-07-15 16:46:50 +03:00
2c3b985594 remove x & y from space, add validation on space naming 2025-07-14 16:03:48 +03:00
36 changed files with 737 additions and 152 deletions

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

@ -0,0 +1,64 @@
name: 🤖 AI PR Description Commenter (100% Safe with jq)
on:
pull_request:
types: [opened, edited]
jobs:
generate-description:
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v4
- name: Install GitHub CLI and jq
run: |
sudo apt-get update
sudo apt-get install gh jq -y
- name: Fetch PR Commits
id: fetch_commits
run: |
COMMITS=$(gh pr view ${{ github.event.pull_request.number }} --json commits --jq '.commits[].message' | sed 's/^/- /')
echo "commits<<EOF" >> $GITHUB_ENV
echo "$COMMITS" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
env:
GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}
- name: Generate PR Description with OpenAI (Safe JSON with jq)
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 \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d "$REQUEST_BODY")
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" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
commits: ${{ env.commits }}
- name: Post AI Generated Description as Comment
run: |
gh pr comment ${{ github.event.pull_request.number }} --body "${{ env.description }}"
env:
GH_TOKEN: ${{ secrets.GH_PERSONAL_TOKEN }}

View File

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

View File

@ -0,0 +1,3 @@
export enum TimerJobTypeEnum {
INVITE_USER_EMAIL = 'INVITE_USER_EMAIL',
}

View File

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

View File

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

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);
}
}

View File

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

View 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 {}

View File

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

View File

@ -1,7 +1,7 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import axios from 'axios'; import axios from 'axios';
import nodemailer from 'nodemailer'; import * as nodemailer from 'nodemailer';
import Mail from 'nodemailer/lib/mailer'; import Mail from 'nodemailer/lib/mailer';
import { BatchEmailData } from './batch-email.interface'; import { BatchEmailData } from './batch-email.interface';
import { SingleEmailData } from './single-email.interface'; import { SingleEmailData } from './single-email.interface';

31
package-lock.json generated
View File

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

View File

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

View File

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

View File

@ -3,11 +3,11 @@ import { IsNotEmpty, IsOptional, IsUUID, Matches } from 'class-validator';
export class BookingRequestDto { export class BookingRequestDto {
@ApiProperty({ @ApiProperty({
description: 'Month in MM/YYYY format', description: 'Month in MM-YYYY format',
example: '07/2025', example: '07-2025',
}) })
@IsNotEmpty() @IsNotEmpty()
@Matches(/^(0[1-9]|1[0-2])\/\d{4}$/, { @Matches(/^(0[1-9]|1[0-2])\-\d{4}$/, {
message: 'Date must be in MM/YYYY format', message: 'Date must be in MM/YYYY format',
}) })
month: string; month: string;

View File

@ -1,5 +1,12 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsDate, IsNotEmpty, IsString, IsUUID, Matches } from 'class-validator'; import {
IsDate,
IsNotEmpty,
IsString,
IsUUID,
Matches,
MinDate,
} from 'class-validator';
export class CreateBookingDto { export class CreateBookingDto {
@ApiProperty({ @ApiProperty({
@ -15,6 +22,7 @@ export class CreateBookingDto {
}) })
@IsNotEmpty() @IsNotEmpty()
@IsDate() @IsDate()
@MinDate(new Date())
date: Date; date: Date;
@ApiProperty({ example: '09:00' }) @ApiProperty({ example: '09:00' })

View File

@ -54,7 +54,8 @@ export class BookableSpaceService {
.createQueryBuilder('space') .createQueryBuilder('space')
.leftJoinAndSelect('space.parent', 'parentSpace') .leftJoinAndSelect('space.parent', 'parentSpace')
.leftJoinAndSelect('space.community', 'community') .leftJoinAndSelect('space.community', 'community')
.where('community.project = :project', { project }); .where('space.disabled = :disabled', { disabled: false })
.andWhere('community.project = :project', { project });
if (search) { if (search) {
qb = qb.andWhere( qb = qb.andWhere(

View File

@ -51,7 +51,7 @@ export class BookingService {
} }
async findAll({ month, space }: BookingRequestDto, project: string) { async findAll({ month, space }: BookingRequestDto, project: string) {
const [monthNumber, year] = month.split('/').map(Number); const [monthNumber, year] = month.split('-').map(Number);
const fromDate = new Date(year, monthNumber - 1, 1); const fromDate = new Date(year, monthNumber - 1, 1);
const toDate = new Date(year, monthNumber, 0, 23, 59, 59); const toDate = new Date(year, monthNumber, 0, 23, 59, 59);
return this.bookingEntityRepository.find({ return this.bookingEntityRepository.find({

View File

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

View File

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

View File

@ -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();

View File

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

View File

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

View 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;
}

View File

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

View File

@ -133,7 +133,7 @@ export class ValidationService {
'subspaces.productAllocations', 'subspaces.productAllocations',
'subspaces.productAllocations.product', 'subspaces.productAllocations.product',
'subspaces.devices', 'subspaces.devices',
'spaceModel', // 'spaceModel',
], ],
}); });

View File

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

View 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
View 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 {}

View 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}`);
}
}
}

View File

@ -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) {