add contract dates to invite user & return them in user details

This commit is contained in:
Mhd Zayd Skaff
2025-07-25 10:18:00 +03:00
parent 2aa6a40af7
commit dcdb10a1fb
14 changed files with 261 additions and 38 deletions

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

View File

@ -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<InviteUserDto> {
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<InviteUserDto> {
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<InviteUserDto> {
nullable: true,
})
@JoinColumn({ name: 'project_uuid' })
public project: ProjectEntity;
project: ProjectEntity;
constructor(partial: Partial<InviteUserEntity>) {
super();
Object.assign(this, partial);
}
}
@Entity({ name: 'invite-user-space' })
@Unique(['inviteUser', 'space'])
export class InviteUserSpaceEntity extends AbstractEntity<InviteUserSpaceDto> {
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<InviteUserSpaceEntity>) {
super();
Object.assign(this, partial);

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';
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<UserDto> {
@ -101,6 +101,9 @@ export class UserEntity extends AbstractEntity<UserDto> {
@Column({ type: 'timestamp', nullable: true })
appAgreementAcceptedAt: Date;
@Column({ type: Boolean, default: false })
bookingEnabled: boolean;
@OneToMany(() => UserSpaceEntity, (userSpace) => userSpace.user, {
onDelete: 'CASCADE',
})

View File

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

View File

@ -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<AddUserInvitationDto>) {
Object.assign(this, dto);
}

View File

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

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