diff --git a/src/app.module.ts b/src/app.module.ts index 43c6bc20..0cd70c8d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import { HealthModule } from './modules/health/health.module'; import { RedisModule } from './redis/redis.module'; import { AuthModule } from './modules/auth/auth.module'; import { UserModule } from './modules/user/user.module'; +import { SessionsModule } from './modules/sessions/sessions.module'; import { ShutdownModule } from './common/services/shutdown.module'; @Module({ @@ -25,6 +26,7 @@ import { ShutdownModule } from './common/services/shutdown.module'; RedisModule.forRoot(), AuthModule, UserModule, + SessionsModule, HealthModule, ShutdownModule, ], diff --git a/src/database/migrations/1745800000000-CreateSessionsTable.ts b/src/database/migrations/1745800000000-CreateSessionsTable.ts new file mode 100644 index 00000000..6b813cb2 --- /dev/null +++ b/src/database/migrations/1745800000000-CreateSessionsTable.ts @@ -0,0 +1,175 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey } from 'typeorm'; + +export class CreateSessionsTable1745800000000 implements MigrationInterface { + name = 'CreateSessionsTable1745800000000'; + + public async up(queryRunner: QueryRunner): Promise { + // Create sessions table + await queryRunner.createTable( + new Table({ + name: 'sessions', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + isGenerated: true, + generationStrategy: 'uuid', + }, + { + name: 'mentorId', + type: 'uuid', + }, + { + name: 'menteeId', + type: 'uuid', + }, + { + name: 'startTime', + type: 'timestamp', + }, + { + name: 'endTime', + type: 'timestamp', + }, + { + name: 'status', + type: 'enum', + enum: ['pending', 'confirmed', 'completed', 'cancelled', 'no_show'], + default: "'pending'", + }, + { + name: 'meetingUrl', + type: 'varchar', + isNullable: true, + }, + { + name: 'notes', + type: 'text', + isNullable: true, + }, + { + name: 'rating', + type: 'int', + isNullable: true, + }, + { + name: 'review', + type: 'text', + isNullable: true, + }, + { + name: 'cancelledAt', + type: 'timestamp', + isNullable: true, + }, + { + name: 'cancellationReason', + type: 'varchar', + isNullable: true, + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }, + ], + }), + true, + ); + + // Create indexes + await queryRunner.createIndex( + 'sessions', + new TableIndex({ + name: 'IDX_SESSIONS_MENTOR_ID', + columnNames: ['mentorId'], + }), + ); + + await queryRunner.createIndex( + 'sessions', + new TableIndex({ + name: 'IDX_SESSIONS_MENTEE_ID', + columnNames: ['menteeId'], + }), + ); + + await queryRunner.createIndex( + 'sessions', + new TableIndex({ + name: 'IDX_SESSIONS_STATUS', + columnNames: ['status'], + }), + ); + + await queryRunner.createIndex( + 'sessions', + new TableIndex({ + name: 'IDX_SESSIONS_TIME_RANGE', + columnNames: ['startTime', 'endTime'], + }), + ); + + await queryRunner.createIndex( + 'sessions', + new TableIndex({ + name: 'IDX_SESSIONS_MENTOR_TIME', + columnNames: ['mentorId', 'startTime', 'endTime'], + }), + ); + + // Create foreign keys + await queryRunner.createForeignKey( + 'sessions', + new TableForeignKey({ + name: 'FK_SESSIONS_MENTOR', + columnNames: ['mentorId'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'sessions', + new TableForeignKey({ + name: 'FK_SESSIONS_MENTEE', + columnNames: ['menteeId'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop foreign keys + const table = await queryRunner.getTable('sessions'); + const mentorForeignKey = table?.foreignKeys.find((fk) => fk.name === 'FK_SESSIONS_MENTOR'); + const menteeForeignKey = table?.foreignKeys.find((fk) => fk.name === 'FK_SESSIONS_MENTEE'); + + if (mentorForeignKey) { + await queryRunner.dropForeignKey('sessions', mentorForeignKey); + } + if (menteeForeignKey) { + await queryRunner.dropForeignKey('sessions', menteeForeignKey); + } + + // Drop indexes + await queryRunner.dropIndex('sessions', 'IDX_SESSIONS_MENTOR_TIME'); + await queryRunner.dropIndex('sessions', 'IDX_SESSIONS_TIME_RANGE'); + await queryRunner.dropIndex('sessions', 'IDX_SESSIONS_STATUS'); + await queryRunner.dropIndex('sessions', 'IDX_SESSIONS_MENTEE_ID'); + await queryRunner.dropIndex('sessions', 'IDX_SESSIONS_MENTOR_ID'); + + // Drop table + await queryRunner.dropTable('sessions'); + } +} diff --git a/src/modules/auth/entities/user.entity.ts b/src/modules/auth/entities/user.entity.ts index 0a23b3ec..6a90c618 100644 --- a/src/modules/auth/entities/user.entity.ts +++ b/src/modules/auth/entities/user.entity.ts @@ -9,10 +9,12 @@ import { JoinTable, Index, OneToOne, + OneToMany, } from 'typeorm'; import { Role } from './role.entity'; import { MentorProfile } from '../../user/entities/mentor-profile.entity'; import { MenteeProfile } from '../../user/entities/mentee-profile.entity'; +import { Session } from '../../sessions/entities/session.entity'; export enum UserRole { USER = 'user', @@ -86,6 +88,12 @@ export class User { @OneToOne(() => MenteeProfile, (menteeProfile) => menteeProfile.user, { nullable: true, cascade: true }) menteeProfile: MenteeProfile; + @OneToMany(() => Session, (session) => session.mentor) + mentorSessions: Session[]; + + @OneToMany(() => Session, (session) => session.mentee) + menteeSessions: Session[]; + @CreateDateColumn() createdAt: Date; diff --git a/src/modules/sessions/dto/session.dto.ts b/src/modules/sessions/dto/session.dto.ts new file mode 100644 index 00000000..3611cc52 --- /dev/null +++ b/src/modules/sessions/dto/session.dto.ts @@ -0,0 +1,77 @@ +import { IsString, IsDateString, IsOptional, IsEnum, Min, Max, IsNumber } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateSessionDto { + @ApiProperty({ description: 'Mentor ID to book session with' }) + @IsString() + mentorId: string; + + @ApiProperty({ description: 'Session start time in ISO format' }) + @IsDateString() + startTime: string; + + @ApiProperty({ description: 'Session end time in ISO format' }) + @IsDateString() + endTime: string; + + @ApiPropertyOptional({ description: 'Session notes or agenda' }) + @IsString() + @IsOptional() + notes?: string; +} + +export class UpdateSessionStatusDto { + @ApiProperty({ + enum: ['pending', 'confirmed', 'completed', 'cancelled', 'no_show'], + description: 'New session status' + }) + @IsEnum(['pending', 'confirmed', 'completed', 'cancelled', 'no_show']) + status: string; +} + +export class CancelSessionDto { + @ApiPropertyOptional({ description: 'Reason for cancellation' }) + @IsString() + @IsOptional() + reason?: string; +} + +export class RescheduleSessionDto { + @ApiProperty({ description: 'New session start time in ISO format' }) + @IsDateString() + startTime: string; + + @ApiProperty({ description: 'New session end time in ISO format' }) + @IsDateString() + endTime: string; +} + +export class RateSessionDto { + @ApiProperty({ description: 'Rating from 1 to 5', minimum: 1, maximum: 5 }) + @IsNumber() + @Min(1) + @Max(5) + rating: number; + + @ApiPropertyOptional({ description: 'Review text' }) + @IsString() + @IsOptional() + review?: string; +} + +export class SessionQueryDto { + @ApiPropertyOptional({ description: 'Filter by status' }) + @IsEnum(['pending', 'confirmed', 'completed', 'cancelled', 'no_show']) + @IsOptional() + status?: string; + + @ApiPropertyOptional({ description: 'Page number', default: 1 }) + @IsNumber() + @IsOptional() + page?: number; + + @ApiPropertyOptional({ description: 'Items per page', default: 20 }) + @IsNumber() + @IsOptional() + limit?: number; +} diff --git a/src/modules/sessions/entities/session.entity.ts b/src/modules/sessions/entities/session.entity.ts new file mode 100644 index 00000000..8bc4a799 --- /dev/null +++ b/src/modules/sessions/entities/session.entity.ts @@ -0,0 +1,85 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../auth/entities/user.entity'; + +export enum SessionStatus { + PENDING = 'pending', + CONFIRMED = 'confirmed', + COMPLETED = 'completed', + CANCELLED = 'cancelled', + NO_SHOW = 'no_show', +} + +@Entity('sessions') +@Index(['mentorId']) +@Index(['menteeId']) +@Index(['status']) +@Index(['startTime', 'endTime']) +@Index(['mentorId', 'startTime', 'endTime']) +export class Session { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + @Index() + mentorId: string; + + @Column() + @Index() + menteeId: string; + + @Column({ type: 'timestamp' }) + @Index() + startTime: Date; + + @Column({ type: 'timestamp' }) + @Index() + endTime: Date; + + @Column({ + type: 'enum', + enum: SessionStatus, + default: SessionStatus.PENDING, + }) + status: SessionStatus; + + @Column({ nullable: true }) + meetingUrl: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ type: 'int', nullable: true }) + rating: number; // 1-5 rating + + @Column({ type: 'text', nullable: true }) + review: string; + + @Column({ type: 'timestamp', nullable: true }) + cancelledAt: Date; + + @Column({ nullable: true }) + cancellationReason: string; + + @ManyToOne(() => User, { eager: false }) + @JoinColumn({ name: 'mentorId' }) + mentor: User; + + @ManyToOne(() => User, { eager: false }) + @JoinColumn({ name: 'menteeId' }) + mentee: User; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/sessions/index.ts b/src/modules/sessions/index.ts new file mode 100644 index 00000000..24e50095 --- /dev/null +++ b/src/modules/sessions/index.ts @@ -0,0 +1,12 @@ +export { Session, SessionStatus } from './entities/session.entity'; +export { SessionsModule } from './sessions.module'; +export { SessionsService } from './sessions.service'; +export { SessionsController } from './sessions.controller'; +export { + CreateSessionDto, + UpdateSessionStatusDto, + CancelSessionDto, + RescheduleSessionDto, + RateSessionDto, + SessionQueryDto, +} from './dto/session.dto'; diff --git a/src/modules/sessions/sessions.controller.ts b/src/modules/sessions/sessions.controller.ts new file mode 100644 index 00000000..4787c4e6 --- /dev/null +++ b/src/modules/sessions/sessions.controller.ts @@ -0,0 +1,140 @@ +import { + Controller, + Get, + Post, + Patch, + Param, + Body, + Query, + UseGuards, + Request, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { SessionsService } from './sessions.service'; +import { + CreateSessionDto, + CancelSessionDto, + RescheduleSessionDto, + RateSessionDto, + SessionQueryDto, +} from './dto/session.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('Sessions') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('sessions') +export class SessionsController { + constructor(private readonly sessionsService: SessionsService) {} + + @Post() + @ApiOperation({ summary: 'Book a new session with a mentor' }) + @ApiResponse({ status: 201, description: 'Session successfully booked' }) + @ApiResponse({ status: 400, description: 'Booking conflict or invalid time slot' }) + async bookSession(@Request() req, @Body() createSessionDto: CreateSessionDto) { + const menteeId = req.user.id; + return this.sessionsService.bookSession(menteeId, createSessionDto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get session details by ID' }) + @ApiResponse({ status: 200, description: 'Session details retrieved' }) + @ApiResponse({ status: 404, description: 'Session not found' }) + async getSession(@Request() req, @Param('id') id: string) { + return this.sessionsService.getSessionById(req.user.id, id); + } + + @Get() + @ApiOperation({ summary: 'Get all sessions for current user' }) + @ApiResponse({ status: 200, description: 'List of sessions retrieved' }) + async getMySessions( + @Request() req, + @Query() query: SessionQueryDto, + @Query('role') role: 'mentor' | 'mentee', + ) { + return this.sessionsService.getUserSessions(req.user.id, query, role || 'mentee'); + } + + @Get('mentor/history') + @ApiOperation({ summary: 'Get mentor session history' }) + @ApiResponse({ status: 200, description: 'Mentor session history retrieved' }) + async getMentorHistory(@Request() req, @Query() query: SessionQueryDto) { + return this.sessionsService.getMentorHistory(req.user.id, query); + } + + @Get('mentee/history') + @ApiOperation({ summary: 'Get mentee session history' }) + @ApiResponse({ status: 200, description: 'Mentee session history retrieved' }) + async getMenteeHistory(@Request() req, @Query() query: SessionQueryDto) { + return this.sessionsService.getMenteeHistory(req.user.id, query); + } + + @Patch(':id/confirm') + @ApiOperation({ summary: 'Confirm a pending session (mentor only)' }) + @ApiResponse({ status: 200, description: 'Session confirmed' }) + @ApiResponse({ status: 403, description: 'Only mentor can confirm' }) + async confirmSession(@Request() req, @Param('id') id: string) { + return this.sessionsService.confirmSession(req.user.id, id); + } + + @Patch(':id/cancel') + @ApiOperation({ summary: 'Cancel a session' }) + @ApiResponse({ status: 200, description: 'Session cancelled' }) + @ApiResponse({ status: 400, description: 'Cannot cancel completed session' }) + async cancelSession( + @Request() req, + @Param('id') id: string, + @Body() cancelDto: CancelSessionDto, + ) { + return this.sessionsService.cancelSession(req.user.id, id, cancelDto); + } + + @Patch(':id/reschedule') + @ApiOperation({ summary: 'Reschedule a session' }) + @ApiResponse({ status: 200, description: 'Session rescheduled' }) + @ApiResponse({ status: 400, description: 'Time slot conflict' }) + async rescheduleSession( + @Request() req, + @Param('id') id: string, + @Body() rescheduleDto: RescheduleSessionDto, + ) { + return this.sessionsService.rescheduleSession(req.user.id, id, rescheduleDto); + } + + @Patch(':id/complete') + @ApiOperation({ summary: 'Mark session as completed (mentor only)' }) + @ApiResponse({ status: 200, description: 'Session marked as completed' }) + async completeSession(@Request() req, @Param('id') id: string) { + return this.sessionsService.markCompleted(req.user.id, id); + } + + @Patch(':id/no-show') + @ApiOperation({ summary: 'Mark session as no-show (mentor only)' }) + @ApiResponse({ status: 200, description: 'Session marked as no-show' }) + async markNoShow(@Request() req, @Param('id') id: string) { + return this.sessionsService.markNoShow(req.user.id, id); + } + + @Patch(':id/rate') + @ApiOperation({ summary: 'Rate and review a completed session (mentee only)' }) + @ApiResponse({ status: 200, description: 'Session rated successfully' }) + @ApiResponse({ status: 400, description: 'Can only rate completed sessions' }) + async rateSession( + @Request() req, + @Param('id') id: string, + @Body() rateDto: RateSessionDto, + ) { + return this.sessionsService.rateSession(req.user.id, id, rateDto); + } + + @Patch(':id/meeting-url') + @ApiOperation({ summary: 'Add meeting URL to session' }) + @ApiResponse({ status: 200, description: 'Meeting URL added' }) + async addMeetingUrl( + @Request() req, + @Param('id') id: string, + @Body('meetingUrl') meetingUrl: string, + ) { + return this.sessionsService.addMeetingUrl(req.user.id, id, meetingUrl); + } +} diff --git a/src/modules/sessions/sessions.module.ts b/src/modules/sessions/sessions.module.ts new file mode 100644 index 00000000..a3b1e924 --- /dev/null +++ b/src/modules/sessions/sessions.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Session } from './entities/session.entity'; +import { User } from '../auth/entities/user.entity'; +import { AvailabilitySlot, AvailabilityException } from '../availability/entities/availability.entity'; +import { SessionsService } from './sessions.service'; +import { SessionsController } from './sessions.controller'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Session, + User, + AvailabilitySlot, + AvailabilityException, + ]), + AuthModule, + ], + controllers: [SessionsController], + providers: [SessionsService], + exports: [SessionsService], +}) +export class SessionsModule {} diff --git a/src/modules/sessions/sessions.service.spec.ts b/src/modules/sessions/sessions.service.spec.ts new file mode 100644 index 00000000..1b7aaa8b --- /dev/null +++ b/src/modules/sessions/sessions.service.spec.ts @@ -0,0 +1,473 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { SessionsService } from '../sessions.service'; +import { Session, SessionStatus } from '../entities/session.entity'; +import { User } from '../../auth/entities/user.entity'; +import { AvailabilitySlot, AvailabilityException } from '../../availability/entities/availability.entity'; +import { CreateSessionDto } from '../dto/session.dto'; + +describe('SessionsService - Booking Conflict Detection', () => { + let service: SessionsService; + let sessionRepository: Repository; + let userRepository: Repository; + let availabilitySlotRepository: Repository; + let availabilityExceptionRepository: Repository; + let dataSource: DataSource; + + const mockMentorId = 'mentor-uuid-123'; + const mockMenteeId = 'mentee-uuid-456'; + + const mockMentor = { + id: mockMentorId, + walletAddress: 'mentor-wallet', + email: 'mentor@example.com', + displayName: 'Test Mentor', + }; + + const mockMentee = { + id: mockMenteeId, + walletAddress: 'mentee-wallet', + email: 'mentee@example.com', + displayName: 'Test Mentee', + }; + + const mockDataSource = { + transaction: jest.fn(async (cb) => { + const mockTransactionalEntityManager = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + return cb(mockTransactionalEntityManager); + }), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SessionsService, + { + provide: getRepositoryToken(Session), + useValue: { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }, + }, + { + provide: getRepositoryToken(User), + useValue: { + findOne: jest.fn(), + }, + }, + { + provide: getRepositoryToken(AvailabilitySlot), + useValue: { + findOne: jest.fn(), + }, + }, + { + provide: getRepositoryToken(AvailabilityException), + useValue: { + findOne: jest.fn(), + }, + }, + { + provide: DataSource, + useValue: mockDataSource, + }, + ], + }).compile(); + + service = module.get(SessionsService); + sessionRepository = module.get>(getRepositoryToken(Session)); + userRepository = module.get>(getRepositoryToken(User)); + availabilitySlotRepository = module.get>( + getRepositoryToken(AvailabilitySlot), + ); + availabilityExceptionRepository = module.get>( + getRepositoryToken(AvailabilityException), + ); + dataSource = module.get(DataSource); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('checkBookingConflict', () => { + it('should return false when no existing sessions exist', async () => { + jest.spyOn(sessionRepository, 'find').mockResolvedValue([]); + + const startTime = new Date('2026-04-30T10:00:00Z'); + const endTime = new Date('2026-04-30T11:00:00Z'); + + const hasConflict = await service.checkBookingConflict( + mockMentorId, + startTime, + endTime, + ); + + expect(hasConflict).toBe(false); + }); + + it('should return false when existing sessions do not overlap', async () => { + const nonOverlappingSessions = [ + { + id: 'session-1', + mentorId: mockMentorId, + startTime: new Date('2026-04-30T08:00:00Z'), + endTime: new Date('2026-04-30T09:00:00Z'), + status: SessionStatus.CONFIRMED, + } as Session, + { + id: 'session-2', + mentorId: mockMentorId, + startTime: new Date('2026-04-30T12:00:00Z'), + endTime: new Date('2026-04-30T13:00:00Z'), + status: SessionStatus.CONFIRMED, + } as Session, + ]; + + jest.spyOn(sessionRepository, 'find').mockResolvedValue(nonOverlappingSessions); + + const startTime = new Date('2026-04-30T10:00:00Z'); + const endTime = new Date('2026-04-30T11:00:00Z'); + + const hasConflict = await service.checkBookingConflict( + mockMentorId, + startTime, + endTime, + ); + + expect(hasConflict).toBe(false); + }); + + it('should return true when existing session overlaps with start time', async () => { + const overlappingSessions = [ + { + id: 'session-1', + mentorId: mockMentorId, + startTime: new Date('2026-04-30T09:00:00Z'), + endTime: new Date('2026-04-30T10:30:00Z'), + status: SessionStatus.CONFIRMED, + } as Session, + ]; + + jest.spyOn(sessionRepository, 'find').mockResolvedValue(overlappingSessions); + + const startTime = new Date('2026-04-30T10:00:00Z'); + const endTime = new Date('2026-04-30T11:00:00Z'); + + const hasConflict = await service.checkBookingConflict( + mockMentorId, + startTime, + endTime, + ); + + expect(hasConflict).toBe(true); + }); + + it('should return true when existing session overlaps with end time', async () => { + const overlappingSessions = [ + { + id: 'session-1', + mentorId: mockMentorId, + startTime: new Date('2026-04-30T10:30:00Z'), + endTime: new Date('2026-04-30T11:30:00Z'), + status: SessionStatus.CONFIRMED, + } as Session, + ]; + + jest.spyOn(sessionRepository, 'find').mockResolvedValue(overlappingSessions); + + const startTime = new Date('2026-04-30T10:00:00Z'); + const endTime = new Date('2026-04-30T11:00:00Z'); + + const hasConflict = await service.checkBookingConflict( + mockMentorId, + startTime, + endTime, + ); + + expect(hasConflict).toBe(true); + }); + + it('should return true when existing session is completely within requested time', async () => { + const overlappingSessions = [ + { + id: 'session-1', + mentorId: mockMentorId, + startTime: new Date('2026-04-30T10:15:00Z'), + endTime: new Date('2026-04-30T10:45:00Z'), + status: SessionStatus.PENDING, + } as Session, + ]; + + jest.spyOn(sessionRepository, 'find').mockResolvedValue(overlappingSessions); + + const startTime = new Date('2026-04-30T10:00:00Z'); + const endTime = new Date('2026-04-30T11:00:00Z'); + + const hasConflict = await service.checkBookingConflict( + mockMentorId, + startTime, + endTime, + ); + + expect(hasConflict).toBe(true); + }); + + it('should return true when requested time is completely within existing session', async () => { + const overlappingSessions = [ + { + id: 'session-1', + mentorId: mockMentorId, + startTime: new Date('2026-04-30T09:00:00Z'), + endTime: new Date('2026-04-30T12:00:00Z'), + status: SessionStatus.CONFIRMED, + } as Session, + ]; + + jest.spyOn(sessionRepository, 'find').mockResolvedValue(overlappingSessions); + + const startTime = new Date('2026-04-30T10:00:00Z'); + const endTime = new Date('2026-04-30T11:00:00Z'); + + const hasConflict = await service.checkBookingConflict( + mockMentorId, + startTime, + endTime, + ); + + expect(hasConflict).toBe(true); + }); + + it('should detect conflicts with pending sessions', async () => { + const pendingSessions = [ + { + id: 'session-1', + mentorId: mockMentorId, + startTime: new Date('2026-04-30T10:00:00Z'), + endTime: new Date('2026-04-30T11:00:00Z'), + status: SessionStatus.PENDING, + } as Session, + ]; + + jest.spyOn(sessionRepository, 'find').mockResolvedValue(pendingSessions); + + const startTime = new Date('2026-04-30T10:30:00Z'); + const endTime = new Date('2026-04-30T11:30:00Z'); + + const hasConflict = await service.checkBookingConflict( + mockMentorId, + startTime, + endTime, + ); + + expect(hasConflict).toBe(true); + }); + + it('should not detect conflicts with cancelled sessions', async () => { + const cancelledSessions = [ + { + id: 'session-1', + mentorId: mockMentorId, + startTime: new Date('2026-04-30T10:00:00Z'), + endTime: new Date('2026-04-30T11:00:00Z'), + status: SessionStatus.CANCELLED, + } as Session, + ]; + + jest.spyOn(sessionRepository, 'find').mockResolvedValue(cancelledSessions); + + const startTime = new Date('2026-04-30T10:00:00Z'); + const endTime = new Date('2026-04-30T11:00:00Z'); + + const hasConflict = await service.checkBookingConflict( + mockMentorId, + startTime, + endTime, + ); + + expect(hasConflict).toBe(false); + }); + + it('should not detect conflicts with no_show sessions', async () => { + const noShowSessions = [ + { + id: 'session-1', + mentorId: mockMentorId, + startTime: new Date('2026-04-30T10:00:00Z'), + endTime: new Date('2026-04-30T11:00:00Z'), + status: SessionStatus.NO_SHOW, + } as Session, + ]; + + jest.spyOn(sessionRepository, 'find').mockResolvedValue(noShowSessions); + + const startTime = new Date('2026-04-30T10:00:00Z'); + const endTime = new Date('2026-04-30T11:00:00Z'); + + const hasConflict = await service.checkBookingConflict( + mockMentorId, + startTime, + endTime, + ); + + expect(hasConflict).toBe(false); + }); + + it('should detect conflicts with exact same time slot', async () => { + const exactMatchSessions = [ + { + id: 'session-1', + mentorId: mockMentorId, + startTime: new Date('2026-04-30T10:00:00Z'), + endTime: new Date('2026-04-30T11:00:00Z'), + status: SessionStatus.CONFIRMED, + } as Session, + ]; + + jest.spyOn(sessionRepository, 'find').mockResolvedValue(exactMatchSessions); + + const startTime = new Date('2026-04-30T10:00:00Z'); + const endTime = new Date('2026-04-30T11:00:00Z'); + + const hasConflict = await service.checkBookingConflict( + mockMentorId, + startTime, + endTime, + ); + + expect(hasConflict).toBe(true); + }); + + it('should not detect conflicts for different mentor', async () => { + const otherMentorSessions = [ + { + id: 'session-1', + mentorId: 'other-mentor-id', + startTime: new Date('2026-04-30T10:00:00Z'), + endTime: new Date('2026-04-30T11:00:00Z'), + status: SessionStatus.CONFIRMED, + } as Session, + ]; + + jest.spyOn(sessionRepository, 'find').mockResolvedValue(otherMentorSessions); + + const startTime = new Date('2026-04-30T10:00:00Z'); + const endTime = new Date('2026-04-30T11:00:00Z'); + + const hasConflict = await service.checkBookingConflict( + mockMentorId, + startTime, + endTime, + ); + + expect(hasConflict).toBe(false); + }); + }); + + describe('bookSession - Integration with conflict detection', () => { + it('should throw BadRequestException when booking conflicts with existing session', async () => { + const mockTransactionalEntityManager = { + findOne: jest.fn().mockImplementation(async (entity, options) => { + if (entity === User) { + return options.where.id === mockMentorId ? mockMentor : mockMentee; + } + return null; + }), + create: jest.fn().mockReturnValue({ + mentorId: mockMentorId, + menteeId: mockMenteeId, + startTime: new Date('2026-04-30T10:00:00Z'), + endTime: new Date('2026-04-30T11:00:00Z'), + status: SessionStatus.PENDING, + }), + save: jest.fn().mockResolvedValue({ + id: 'new-session-id', + mentorId: mockMentorId, + menteeId: mockMenteeId, + startTime: new Date('2026-04-30T10:00:00Z'), + endTime: new Date('2026-04-30T11:00:00Z'), + status: SessionStatus.PENDING, + }), + }; + + (dataSource.transaction as jest.Mock).mockImplementation(async (cb) => { + return cb(mockTransactionalEntityManager); + }); + + // Mock conflict detection to return true + jest.spyOn(service, 'checkBookingConflict').mockResolvedValue(true); + + const createSessionDto: CreateSessionDto = { + mentorId: mockMentorId, + startTime: '2026-04-30T10:00:00Z', + endTime: '2026-04-30T11:00:00Z', + }; + + await expect(service.bookSession(mockMenteeId, createSessionDto)).rejects.toThrow( + BadRequestException, + ); + + await expect(service.bookSession(mockMenteeId, createSessionDto)).rejects.toThrow( + 'Time slot is already booked or conflicts with another session', + ); + }); + + it('should successfully book session when no conflicts exist', async () => { + const mockTransactionalEntityManager = { + findOne: jest.fn().mockImplementation(async (entity, options) => { + if (entity === User) { + return options.where.id === mockMentorId ? mockMentor : mockMentee; + } + return null; + }), + create: jest.fn().mockReturnValue({ + mentorId: mockMentorId, + menteeId: mockMenteeId, + startTime: new Date('2026-04-30T14:00:00Z'), + endTime: new Date('2026-04-30T15:00:00Z'), + status: SessionStatus.PENDING, + }), + save: jest.fn().mockResolvedValue({ + id: 'new-session-id', + mentorId: mockMentorId, + menteeId: mockMenteeId, + startTime: new Date('2026-04-30T14:00:00Z'), + endTime: new Date('2026-04-30T15:00:00Z'), + status: SessionStatus.PENDING, + }), + }; + + (dataSource.transaction as jest.Mock).mockImplementation(async (cb) => { + return cb(mockTransactionalEntityManager); + }); + + // Mock no conflict + jest.spyOn(service, 'checkBookingConflict').mockResolvedValue(false); + + // Mock availability check + jest.spyOn(service as any, 'checkMentorAvailability').mockResolvedValue(true); + + const createSessionDto: CreateSessionDto = { + mentorId: mockMentorId, + startTime: '2026-04-30T14:00:00Z', + endTime: '2026-04-30T15:00:00Z', + notes: 'Test session', + }; + + const result = await service.bookSession(mockMenteeId, createSessionDto); + + expect(result).toBeDefined(); + expect(result.mentorId).toBe(mockMentorId); + expect(result.menteeId).toBe(mockMenteeId); + expect(result.status).toBe(SessionStatus.PENDING); + }); + }); +}); diff --git a/src/modules/sessions/sessions.service.ts b/src/modules/sessions/sessions.service.ts new file mode 100644 index 00000000..ad356332 --- /dev/null +++ b/src/modules/sessions/sessions.service.ts @@ -0,0 +1,541 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + ForbiddenException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource, LessThan, MoreThan } from 'typeorm'; +import { Session, SessionStatus } from './entities/session.entity'; +import { User } from '../auth/entities/user.entity'; +import { AvailabilitySlot, AvailabilityException } from '../availability/entities/availability.entity'; +import { + CreateSessionDto, + CancelSessionDto, + RescheduleSessionDto, + RateSessionDto, + SessionQueryDto, +} from './dto/session.dto'; + +@Injectable() +export class SessionsService { + constructor( + @InjectRepository(Session) + private sessionRepository: Repository, + @InjectRepository(User) + private userRepository: Repository, + @InjectRepository(AvailabilitySlot) + private availabilitySlotRepository: Repository, + @InjectRepository(AvailabilityException) + private availabilityExceptionRepository: Repository, + private dataSource: DataSource, + ) {} + + /** + * Book a new session with a mentor + */ + async bookSession(menteeId: string, createSessionDto: CreateSessionDto): Promise { + const { mentorId, startTime, endTime, notes } = createSessionDto; + + // Use transaction with locking to prevent double-booking + return await this.dataSource.transaction(async (transactionalEntityManager) => { + // Verify mentor exists + const mentor = await transactionalEntityManager.findOne(User, { + where: { id: mentorId }, + }); + if (!mentor) { + throw new NotFoundException('Mentor not found'); + } + + // Verify mentee exists + const mentee = await transactionalEntityManager.findOne(User, { + where: { id: menteeId }, + }); + if (!mentee) { + throw new NotFoundException('Mentee not found'); + } + + // Validate session times + const start = new Date(startTime); + const end = new Date(endTime); + + if (start >= end) { + throw new BadRequestException('End time must be after start time'); + } + + if (start <= new Date()) { + throw new BadRequestException('Session start time must be in the future'); + } + + // Check for booking conflicts with database lock + const hasConflict = await this.checkBookingConflict( + mentorId, + start, + end, + transactionalEntityManager, + ); + + if (hasConflict) { + throw new BadRequestException('Time slot is already booked or conflicts with another session'); + } + + // Check mentor availability + const isAvailable = await this.checkMentorAvailability(mentorId, start, end); + if (!isAvailable) { + throw new BadRequestException('Mentor is not available during the requested time slot'); + } + + // Create the session + const session = transactionalEntityManager.create(Session, { + mentorId, + menteeId, + startTime: start, + endTime: end, + status: SessionStatus.PENDING, + notes, + }); + + const savedSession = await transactionalEntityManager.save(session); + + // TODO: Send notification to mentor (email/WebSocket placeholder) + // await this.notificationService.sendBookingNotification(mentor, savedSession); + + return savedSession; + }); + } + + /** + * Check for booking conflicts + */ + async checkBookingConflict( + mentorId: string, + startTime: Date, + endTime: Date, + entityManager?: any, + ): Promise { + const repo = entityManager || this.sessionRepository; + + // Check for overlapping sessions (pending, confirmed, or completed) + const conflictingSessions = await repo.find(Session, { + where: [ + { + mentorId, + status: SessionStatus.PENDING, + }, + { + mentorId, + status: SessionStatus.CONFIRMED, + }, + { + mentorId, + status: SessionStatus.COMPLETED, + }, + ], + }); + + // Check if any session overlaps with the requested time + const hasOverlap = conflictingSessions.some((session) => { + return startTime < session.endTime && endTime > session.startTime; + }); + + return hasOverlap; + } + + /** + * Check if mentor is available during the requested time + */ + private async checkMentorAvailability( + mentorId: string, + startTime: Date, + endTime: Date, + ): Promise { + const dayOfWeek = startTime.getUTCDay(); + const startHours = startTime.getUTCHours(); + const startMinutes = startTime.getUTCMinutes(); + const endHours = endTime.getUTCHours(); + const endMinutes = endTime.getUTCMinutes(); + + const startTimeStr = `${String(startHours).padStart(2, '0')}:${String(startMinutes).padStart(2, '0')}`; + const endTimeStr = `${String(endHours).padStart(2, '0')}:${String(endMinutes).padStart(2, '0')}`; + + // Check if there's an availability exception for this date + const dateStr = startTime.toISOString().split('T')[0]; + const exception = await this.availabilityExceptionRepository.findOne({ + where: { + mentorId, + exceptionDate: dateStr, + }, + }); + + // If exception exists and blocks the time slot + if (exception) { + if (!exception.startTime || !exception.endTime) { + // Full day blocked + return false; + } + + const exceptionStart = exception.startTime; + const exceptionEnd = exception.endTime; + + if (startTimeStr >= exceptionStart && endTimeStr <= exceptionEnd) { + return true; // Time slot is within exception allowed time + } + return false; + } + + // Check regular availability slots + const availabilitySlot = await this.availabilitySlotRepository.findOne({ + where: { + mentorId, + dayOfWeek, + isActive: true, + }, + }); + + if (!availabilitySlot) { + return false; // No availability set for this day + } + + // Check if requested time falls within availability slot + if ( + startTimeStr >= availabilitySlot.startTime && + endTimeStr <= availabilitySlot.endTime + ) { + return true; + } + + return false; + } + + /** + * Cancel a session with 24-hour policy enforcement + */ + async cancelSession( + userId: string, + sessionId: string, + cancelDto: CancelSessionDto, + ): Promise { + return await this.dataSource.transaction(async (transactionalEntityManager) => { + const session = await transactionalEntityManager.findOne(Session, { + where: { id: sessionId }, + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + // Only mentor or mentee can cancel + if (session.mentorId !== userId && session.menteeId !== userId) { + throw new ForbiddenException('You can only cancel your own sessions'); + } + + // Cannot cancel already cancelled or completed sessions + if ( + session.status === SessionStatus.CANCELLED || + session.status === SessionStatus.COMPLETED + ) { + throw new BadRequestException('Cannot cancel a completed or already cancelled session'); + } + + // Check 24-hour cancellation policy + const hoursUntilSession = (session.startTime.getTime() - new Date().getTime()) / (1000 * 60 * 60); + + if (hoursUntilSession < 24 && hoursUntilSession > 0) { + // TODO: Apply penalty or warning (placeholder) + // await this.penaltyService.applyLateCancellationPenalty(userId, session); + console.warn(`Late cancellation by ${userId} for session ${sessionId} (less than 24 hours notice)`); + } + + // Update session status + session.status = SessionStatus.CANCELLED; + session.cancelledAt = new Date(); + session.cancellationReason = cancelDto.reason || 'Cancelled by user'; + + const updatedSession = await transactionalEntityManager.save(session); + + // TODO: Send cancellation notification + // const otherUserId = session.mentorId === userId ? session.menteeId : session.mentorId; + // await this.notificationService.sendCancellationNotification(otherUserId, updatedSession); + + return updatedSession; + }); + } + + /** + * Reschedule a session + */ + async rescheduleSession( + userId: string, + sessionId: string, + rescheduleDto: RescheduleSessionDto, + ): Promise { + return await this.dataSource.transaction(async (transactionalEntityManager) => { + const session = await transactionalEntityManager.findOne(Session, { + where: { id: sessionId }, + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + // Only mentor or mentee can reschedule + if (session.mentorId !== userId && session.menteeId !== userId) { + throw new ForbiddenException('You can only reschedule your own sessions'); + } + + // Cannot reschedule cancelled or completed sessions + if ( + session.status === SessionStatus.CANCELLED || + session.status === SessionStatus.COMPLETED + ) { + throw new BadRequestException('Cannot reschedule a completed or cancelled session'); + } + + const newStartTime = new Date(rescheduleDto.startTime); + const newEndTime = new Date(rescheduleDto.endTime); + + // Validate new times + if (newStartTime >= newEndTime) { + throw new BadRequestException('End time must be after start time'); + } + + if (newStartTime <= new Date()) { + throw new BadRequestException('Session start time must be in the future'); + } + + // Check for conflicts with new time + const hasConflict = await this.checkBookingConflict( + session.mentorId, + newStartTime, + newEndTime, + transactionalEntityManager, + ); + + if (hasConflict) { + throw new BadRequestException('New time slot conflicts with another session'); + } + + // Check mentor availability for new time + const isAvailable = await this.checkMentorAvailability( + session.mentorId, + newStartTime, + newEndTime, + ); + + if (!isAvailable) { + throw new BadRequestException('Mentor is not available during the new time slot'); + } + + // Update session times + session.startTime = newStartTime; + session.endTime = newEndTime; + session.status = SessionStatus.PENDING; // Reset to pending for reconfirmation + + const updatedSession = await transactionalEntityManager.save(session); + + // TODO: Send reschedule notification + // const otherUserId = session.mentorId === userId ? session.menteeId : session.mentorId; + // await this.notificationService.sendRescheduleNotification(otherUserId, updatedSession); + + return updatedSession; + }); + } + + /** + * Confirm a session (mentor only) + */ + async confirmSession(userId: string, sessionId: string): Promise { + const session = await this.sessionRepository.findOne({ + where: { id: sessionId }, + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + // Only mentor can confirm + if (session.mentorId !== userId) { + throw new ForbiddenException('Only the mentor can confirm the session'); + } + + if (session.status !== SessionStatus.PENDING) { + throw new BadRequestException('Only pending sessions can be confirmed'); + } + + session.status = SessionStatus.CONFIRMED; + return await this.sessionRepository.save(session); + } + + /** + * Rate and review a completed session + */ + async rateSession( + userId: string, + sessionId: string, + rateDto: RateSessionDto, + ): Promise { + const session = await this.sessionRepository.findOne({ + where: { id: sessionId }, + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + // Only mentee can rate + if (session.menteeId !== userId) { + throw new ForbiddenException('Only the mentee can rate the session'); + } + + if (session.status !== SessionStatus.COMPLETED) { + throw new BadRequestException('Can only rate completed sessions'); + } + + session.rating = rateDto.rating; + session.review = rateDto.review || null; + + // TODO: Update mentor's average rating + // await this.mentorService.updateAverageRating(session.mentorId); + + return await this.sessionRepository.save(session); + } + + /** + * Get session by ID + */ + async getSessionById(userId: string, sessionId: string): Promise { + const session = await this.sessionRepository.findOne({ + where: { id: sessionId }, + relations: ['mentor', 'mentee'], + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + // Only participants can view the session + if (session.mentorId !== userId && session.menteeId !== userId) { + throw new ForbiddenException('You can only view your own sessions'); + } + + return session; + } + + /** + * Get sessions for a user (mentor or mentee) + */ + async getUserSessions( + userId: string, + query: SessionQueryDto, + role: 'mentor' | 'mentee', + ): Promise<{ sessions: Session[]; total: number }> { + const { status, page = 1, limit = 20 } = query; + const skip = (page - 1) * limit; + + const whereCondition: any = {}; + whereCondition[role === 'mentor' ? 'mentorId' : 'menteeId'] = userId; + + if (status) { + whereCondition.status = status; + } + + const [sessions, total] = await this.sessionRepository.findAndCount({ + where: whereCondition, + relations: ['mentor', 'mentee'], + order: { startTime: 'DESC' }, + skip, + take: limit, + }); + + return { sessions, total }; + } + + /** + * Get mentee's session history + */ + async getMenteeHistory(menteeId: string, query: SessionQueryDto): Promise<{ sessions: Session[]; total: number }> { + return this.getUserSessions(menteeId, query, 'mentee'); + } + + /** + * Get mentor's session history + */ + async getMentorHistory(mentorId: string, query: SessionQueryDto): Promise<{ sessions: Session[]; total: number }> { + return this.getUserSessions(mentorId, query, 'mentor'); + } + + /** + * Mark session as no-show (mentor only) + */ + async markNoShow(userId: string, sessionId: string): Promise { + const session = await this.sessionRepository.findOne({ + where: { id: sessionId }, + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + // Only mentor can mark no-show + if (session.mentorId !== userId) { + throw new ForbiddenException('Only the mentor can mark a session as no-show'); + } + + if (session.status !== SessionStatus.CONFIRMED) { + throw new BadRequestException('Can only mark confirmed sessions as no-show'); + } + + session.status = SessionStatus.NO_SHOW; + return await this.sessionRepository.save(session); + } + + /** + * Mark session as completed (mentor only) + */ + async markCompleted(userId: string, sessionId: string): Promise { + const session = await this.sessionRepository.findOne({ + where: { id: sessionId }, + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + // Only mentor can mark as completed + if (session.mentorId !== userId) { + throw new ForbiddenException('Only the mentor can mark a session as completed'); + } + + if (session.status !== SessionStatus.CONFIRMED) { + throw new BadRequestException('Can only mark confirmed sessions as completed'); + } + + session.status = SessionStatus.COMPLETED; + return await this.sessionRepository.save(session); + } + + /** + * Add meeting URL to session + */ + async addMeetingUrl( + userId: string, + sessionId: string, + meetingUrl: string, + ): Promise { + const session = await this.sessionRepository.findOne({ + where: { id: sessionId }, + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + // Only mentor or mentee can add meeting URL + if (session.mentorId !== userId && session.menteeId !== userId) { + throw new ForbiddenException('You can only update your own sessions'); + } + + session.meetingUrl = meetingUrl; + return await this.sessionRepository.save(session); + } +}