From dcdb10a1fb7f3a3a8950406331b0105e17c481df Mon Sep 17 00:00:00 2001 From: Mhd Zayd Skaff Date: Fri, 25 Jul 2025 10:18:00 +0300 Subject: [PATCH] add contract dates to invite user & return them in user details --- .../src/constants/timer-job-type.enum.ts | 3 ++ libs/common/src/database/database.module.ts | 2 + .../entities/Invite-user.entity.ts | 50 +++++++++++------ .../modules/timer/entities/timer.entity.ts | 37 +++++++++++++ .../timer/repositories/timer.repository.ts | 10 ++++ .../modules/timer/timer.repository.module.ts | 12 +++++ .../src/modules/user/entities/user.entity.ts | 5 +- src/app.module.ts | 12 +++-- src/invite-user/dtos/add.invite-user.dto.ts | 40 ++++++++++---- .../services/invite-user.service.ts | 39 +++++++++++--- src/timer/create-job.dto.ts | 20 +++++++ src/timer/timer.module.ts | 10 ++++ src/timer/timer.service.ts | 53 +++++++++++++++++++ src/users/services/user.service.ts | 6 ++- 14 files changed, 261 insertions(+), 38 deletions(-) create mode 100644 libs/common/src/constants/timer-job-type.enum.ts create mode 100644 libs/common/src/modules/timer/entities/timer.entity.ts create mode 100644 libs/common/src/modules/timer/repositories/timer.repository.ts create mode 100644 libs/common/src/modules/timer/timer.repository.module.ts create mode 100644 src/timer/create-job.dto.ts create mode 100644 src/timer/timer.module.ts create mode 100644 src/timer/timer.service.ts diff --git a/libs/common/src/constants/timer-job-type.enum.ts b/libs/common/src/constants/timer-job-type.enum.ts new file mode 100644 index 0000000..6eb98b6 --- /dev/null +++ b/libs/common/src/constants/timer-job-type.enum.ts @@ -0,0 +1,3 @@ +export enum TimerJobTypeEnum { + INVITE_USER_EMAIL = 'INVITE_USER_EMAIL', +} diff --git a/libs/common/src/database/database.module.ts b/libs/common/src/database/database.module.ts index 6aab302..4599f00 100644 --- a/libs/common/src/database/database.module.ts +++ b/libs/common/src/database/database.module.ts @@ -54,6 +54,7 @@ import { SpaceEntity } from '../modules/space/entities/space.entity'; import { SubspaceProductAllocationEntity } from '../modules/space/entities/subspace/subspace-product-allocation.entity'; import { SubspaceEntity } from '../modules/space/entities/subspace/subspace.entity'; import { NewTagEntity } from '../modules/tag/entities/tag.entity'; +import { TimerEntity } from '../modules/timer/entities/timer.entity'; import { TimeZoneEntity } from '../modules/timezone/entities'; import { UserNotificationEntity, @@ -121,6 +122,7 @@ import { VisitorPasswordEntity } from '../modules/visitor-password/entities'; SpaceDailyOccupancyDurationEntity, BookableSpaceEntity, BookingEntity, + TimerEntity, ], namingStrategy: new SnakeNamingStrategy(), synchronize: Boolean(JSON.parse(configService.get('DB_SYNC'))), diff --git a/libs/common/src/modules/Invite-user/entities/Invite-user.entity.ts b/libs/common/src/modules/Invite-user/entities/Invite-user.entity.ts index 07b3151..7d01d84 100644 --- a/libs/common/src/modules/Invite-user/entities/Invite-user.entity.ts +++ b/libs/common/src/modules/Invite-user/entities/Invite-user.entity.ts @@ -15,17 +15,16 @@ import { ProjectEntity } from '../../project/entities'; import { RoleTypeEntity } from '../../role-type/entities'; import { SpaceEntity } from '../../space/entities/space.entity'; import { UserEntity } from '../../user/entities'; -import { InviteUserDto, InviteUserSpaceDto } from '../dtos'; @Entity({ name: 'invite-user' }) @Unique(['email', 'project']) -export class InviteUserEntity extends AbstractEntity { +export class InviteUserEntity extends AbstractEntity { @Column({ type: 'uuid', default: () => 'gen_random_uuid()', nullable: false, }) - public uuid: string; + uuid: string; @Column({ nullable: false, @@ -49,50 +48,67 @@ export class InviteUserEntity extends AbstractEntity { status: string; @Column() - public firstName: string; + firstName: string; @Column({ nullable: false, }) - public lastName: string; + lastName: string; + @Column({ nullable: true, }) - public phoneNumber: string; + phoneNumber: string; @Column({ nullable: false, default: true, }) - public isActive: boolean; + isActive: boolean; + @Column({ nullable: false, default: true, }) - public isEnabled: boolean; + isEnabled: boolean; + @Column({ nullable: false, unique: true, }) - public invitationCode: string; + invitationCode: string; + + @Column({ + default: new Date(), + type: 'date', + }) + accessStartDate: Date; + + @Column({ + type: 'date', + nullable: true, + }) + accessEndDate?: Date; @Column({ nullable: false, enum: Object.values(RoleType), }) - public invitedBy: string; + invitedBy: string; @ManyToOne(() => RoleTypeEntity, (roleType) => roleType.invitedUsers, { nullable: false, onDelete: 'CASCADE', }) - public roleType: RoleTypeEntity; + roleType: RoleTypeEntity; + @OneToOne(() => UserEntity, (user) => user.inviteUser, { nullable: true, onDelete: 'CASCADE', }) @JoinColumn({ name: 'user_uuid' }) user: UserEntity; + @OneToMany( () => InviteUserSpaceEntity, (inviteUserSpace) => inviteUserSpace.inviteUser, @@ -103,32 +119,34 @@ export class InviteUserEntity extends AbstractEntity { nullable: true, }) @JoinColumn({ name: 'project_uuid' }) - public project: ProjectEntity; + project: ProjectEntity; constructor(partial: Partial) { super(); Object.assign(this, partial); } } + @Entity({ name: 'invite-user-space' }) @Unique(['inviteUser', 'space']) -export class InviteUserSpaceEntity extends AbstractEntity { +export class InviteUserSpaceEntity extends AbstractEntity { @Column({ type: 'uuid', default: () => 'gen_random_uuid()', nullable: false, }) - public uuid: string; + uuid: string; @ManyToOne(() => InviteUserEntity, (inviteUser) => inviteUser.spaces, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'invite_user_uuid' }) - public inviteUser: InviteUserEntity; + inviteUser: InviteUserEntity; @ManyToOne(() => SpaceEntity, (space) => space.invitedUsers) @JoinColumn({ name: 'space_uuid' }) - public space: SpaceEntity; + space: SpaceEntity; + constructor(partial: Partial) { super(); Object.assign(this, partial); diff --git a/libs/common/src/modules/timer/entities/timer.entity.ts b/libs/common/src/modules/timer/entities/timer.entity.ts new file mode 100644 index 0000000..d41df79 --- /dev/null +++ b/libs/common/src/modules/timer/entities/timer.entity.ts @@ -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; + + constructor(partial: Partial) { + super(); + Object.assign(this, partial); + } +} diff --git a/libs/common/src/modules/timer/repositories/timer.repository.ts b/libs/common/src/modules/timer/repositories/timer.repository.ts new file mode 100644 index 0000000..230e73f --- /dev/null +++ b/libs/common/src/modules/timer/repositories/timer.repository.ts @@ -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 { + constructor(private dataSource: DataSource) { + super(TimerEntity, dataSource.createEntityManager()); + } +} diff --git a/libs/common/src/modules/timer/timer.repository.module.ts b/libs/common/src/modules/timer/timer.repository.module.ts new file mode 100644 index 0000000..b10495a --- /dev/null +++ b/libs/common/src/modules/timer/timer.repository.module.ts @@ -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 {} diff --git a/libs/common/src/modules/user/entities/user.entity.ts b/libs/common/src/modules/user/entities/user.entity.ts index fe6aa27..f0a14ef 100644 --- a/libs/common/src/modules/user/entities/user.entity.ts +++ b/libs/common/src/modules/user/entities/user.entity.ts @@ -11,6 +11,7 @@ import { } from 'typeorm'; import { OtpType } from '../../../../src/constants/otp-type.enum'; import { AbstractEntity } from '../../abstract/entities/abstract.entity'; +import { BookingEntity } from '../../booking/entities/booking.entity'; import { ClientEntity } from '../../client/entities'; import { DeviceNotificationEntity, @@ -29,7 +30,6 @@ import { UserOtpDto, UserSpaceDto, } from '../dtos'; -import { BookingEntity } from '../../booking/entities/booking.entity'; @Entity({ name: 'user' }) export class UserEntity extends AbstractEntity { @@ -101,6 +101,9 @@ export class UserEntity extends AbstractEntity { @Column({ type: 'timestamp', nullable: true }) appAgreementAcceptedAt: Date; + @Column({ type: Boolean, default: false }) + bookingEnabled: boolean; + @OneToMany(() => UserSpaceEntity, (userSpace) => userSpace.user, { onDelete: 'CASCADE', }) diff --git a/src/app.module.ts b/src/app.module.ts index f88b556..6d54ed5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -35,16 +35,18 @@ import { UserNotificationModule } from './user-notification/user-notification.mo import { UserModule } from './users/user.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 { ThrottlerModule } from '@nestjs/throttler/dist/throttler.module'; import { isArray } from 'class-validator'; import { winstonLoggerOptions } from '../libs/common/src/logger/services/winston.logger'; 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 { OccupancyModule } from './occupancy/occupancy.module'; +import { SchedulerModule } from './scheduler/scheduler.module'; +import { TimerModule } from './timer/timer.module'; +import { WeatherModule } from './weather/weather.module'; @Module({ imports: [ ConfigModule.forRoot({ @@ -63,6 +65,8 @@ import { BookingModule } from './booking'; }, }), WinstonModule.forRoot(winstonLoggerOptions), + TimerModule, + TimerRepositoryModule, ClientModule, AuthenticationModule, UserModule, diff --git a/src/invite-user/dtos/add.invite-user.dto.ts b/src/invite-user/dtos/add.invite-user.dto.ts index 689720c..bd7e9c7 100644 --- a/src/invite-user/dtos/add.invite-user.dto.ts +++ b/src/invite-user/dtos/add.invite-user.dto.ts @@ -2,10 +2,12 @@ import { ApiProperty } from '@nestjs/swagger'; import { ArrayMinSize, IsArray, + IsDate, IsNotEmpty, IsOptional, IsString, IsUUID, + MinDate, } from 'class-validator'; export class AddUserInvitationDto { @@ -16,7 +18,7 @@ export class AddUserInvitationDto { }) @IsString() @IsNotEmpty() - public firstName: string; + firstName: string; @ApiProperty({ description: 'The last name of the user', @@ -25,7 +27,7 @@ export class AddUserInvitationDto { }) @IsString() @IsNotEmpty() - public lastName: string; + lastName: string; @ApiProperty({ description: 'The email of the user', @@ -34,7 +36,7 @@ export class AddUserInvitationDto { }) @IsString() @IsNotEmpty() - public email: string; + email: string; @ApiProperty({ description: 'The job title of the user', @@ -43,7 +45,7 @@ export class AddUserInvitationDto { }) @IsString() @IsOptional() - public jobTitle?: string; + jobTitle?: string; @ApiProperty({ description: 'The company name of the user', @@ -52,7 +54,27 @@ export class AddUserInvitationDto { }) @IsString() @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({ description: 'The phone number of the user', @@ -61,7 +83,7 @@ export class AddUserInvitationDto { }) @IsString() @IsOptional() - public phoneNumber?: string; + phoneNumber?: string; @ApiProperty({ description: 'The role uuid of the user', @@ -70,7 +92,7 @@ export class AddUserInvitationDto { }) @IsUUID('4') @IsNotEmpty() - public roleUuid: string; + roleUuid: string; @ApiProperty({ description: 'The project uuid of the user', example: 'd290f1ee-6c54-4b01-90e6-d701748f0851', @@ -78,7 +100,7 @@ export class AddUserInvitationDto { }) @IsUUID('4') @IsNotEmpty() - public projectUuid: string; + projectUuid: string; @ApiProperty({ description: 'The array of space UUIDs (at least one required)', @@ -88,7 +110,7 @@ export class AddUserInvitationDto { @IsArray() @IsUUID('4', { each: true }) @ArrayMinSize(1) - public spaceUuids: string[]; + spaceUuids: string[]; constructor(dto: Partial) { Object.assign(this, dto); } diff --git a/src/invite-user/services/invite-user.service.ts b/src/invite-user/services/invite-user.service.ts index d9b4b66..ac56696 100644 --- a/src/invite-user/services/invite-user.service.ts +++ b/src/invite-user/services/invite-user.service.ts @@ -1,4 +1,5 @@ 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 { BaseResponseDto } from '@app/common/dto/base.response.dto'; import { SuccessResponseDto } from '@app/common/dto/success.response.dto'; @@ -23,6 +24,7 @@ import { Injectable, } from '@nestjs/common'; import { SpaceUserService } from 'src/space/services'; +import { TimerService } from 'src/timer/timer.service'; import { UserSpaceService } from 'src/users/services'; import { DataSource, @@ -52,6 +54,7 @@ export class InviteUserService { private readonly spaceRepository: SpaceRepository, private readonly roleTypeRepository: RoleTypeRepository, private readonly dataSource: DataSource, + private readonly timerService: TimerService, ) {} async createUserInvitation( @@ -68,8 +71,14 @@ export class InviteUserService { roleUuid, spaceUuids, projectUuid, + accessStartDate, + accessEndDate, } = dto; - + if (accessStartDate && accessEndDate && accessEndDate <= accessStartDate) { + throw new BadRequestException( + 'accessEndDate must be after accessStartDate', + ); + } const invitationCode = generateRandomString(6); const queryRunner = this.dataSource.createQueryRunner(); @@ -114,6 +123,8 @@ export class InviteUserService { invitationCode, invitedBy: roleType, project: { uuid: projectUuid }, + accessEndDate, + accessStartDate, }); const invitedUser = await queryRunner.manager.save(inviteUser); @@ -132,12 +143,26 @@ export class InviteUserService { // Send invitation email const spaceNames = validSpaces.map((space) => space.spaceName).join(', '); - await this.emailService.sendEmailWithInvitationTemplate(email, { - name: firstName, - invitationCode, - role: invitedRoleType.replace(/_/g, ' '), - spacesList: spaceNames, - }); + if (accessStartDate) { + await this.timerService.createJob({ + type: TimerJobTypeEnum.INVITE_USER_EMAIL, + triggerDate: accessStartDate, + 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(); diff --git a/src/timer/create-job.dto.ts b/src/timer/create-job.dto.ts new file mode 100644 index 0000000..a2cb7ef --- /dev/null +++ b/src/timer/create-job.dto.ts @@ -0,0 +1,20 @@ +import { TimerJobTypeEnum } from '@app/common/constants/timer-job-type.enum'; + +export class BaseCreateJobDto { + type: TimerJobTypeEnum; + triggerDate: Date; + metadata: Record; +} +// 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; diff --git a/src/timer/timer.module.ts b/src/timer/timer.module.ts new file mode 100644 index 0000000..4b4392a --- /dev/null +++ b/src/timer/timer.module.ts @@ -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 {} diff --git a/src/timer/timer.service.ts b/src/timer/timer.service.ts new file mode 100644 index 0000000..7eacbad --- /dev/null +++ b/src/timer/timer.service.ts @@ -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 { + 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}`); + } + } +} diff --git a/src/users/services/user.service.ts b/src/users/services/user.service.ts index 89c06f8..ca5165a 100644 --- a/src/users/services/user.service.ts +++ b/src/users/services/user.service.ts @@ -30,7 +30,7 @@ export class UserService { where: { uuid: userUuid, }, - relations: ['region', 'timezone', 'roleType', 'project'], + relations: ['region', 'timezone', 'roleType', 'project', 'inviteUser'], }); if (!user) { throw new BadRequestException('Invalid room UUID'); @@ -53,6 +53,10 @@ export class UserService { appAgreementAcceptedAt: user?.appAgreementAcceptedAt, role: user?.roleType, project: user?.project, + bookingPoints: user?.bookingPoints ?? 0, + accessStartDate: user?.inviteUser.accessStartDate, + accessEndDate: user?.inviteUser.accessEndDate, + bookingEnabled: user?.bookingEnabled, }; } catch (err) { if (err instanceof BadRequestException) {