diff --git a/src/adapters/api/controller/talk.controller.ts b/src/adapters/api/controller/talk.controller.ts index 906a6e9..a306cee 100644 --- a/src/adapters/api/controller/talk.controller.ts +++ b/src/adapters/api/controller/talk.controller.ts @@ -34,11 +34,16 @@ import { GetAllTalksWithRoomDetailResponse } from '../response/get-all-talks-wit import { GetAllTalksWithRoomDetailMapper } from '../mapper/get-all-talks-with-room-detail.mapper'; import { UserType } from '../../../core/domain/type/UserType'; import { Roles } from '../decorator/roles.decorator'; +import { UpdateTalkRequest } from '../request/update-talk.request'; +import { UpdateTalkMapper } from '../mapper/update-talk.mapper'; +import { UpdateTalkCreationRequestUseCase } from '../../../core/usecases/update-talk-creation-request.use-case'; +import { UpdateTalkResponse } from '../response/update-talk.response'; @Controller('/talks') export class TalkController { constructor( private readonly createTalkUseCase: CreateTalkCreationRequestUseCase, + private readonly updateTalkUseCase: UpdateTalkCreationRequestUseCase, private readonly approveOrRejectTalkUseCase: ApproveOrRejectTalkUseCase, private readonly getAllTalksByStatusUseCase: GetAllTalksByStatusUseCase, ) {} @@ -101,6 +106,40 @@ export class TalkController { return CreateTalkMapper.fromDomain(talk); } + @UseGuards(JwtAuthGuard) + @Post('/:talkId') + @Roles(UserType.PLANNER, UserType.SPEAKER) + @ApiOperation({ summary: 'Update a talk' }) + @ApiCreatedResponse({ + description: 'Talk successfully updated', + type: UpdateTalkResponse, + }) + @ApiBadRequestResponse({ + description: 'Invalid input or validation error', + }) + @ApiNotFoundResponse({ + description: + 'Referenced resource not found (e.g. linked room if applicable)', + }) + @ApiConflictResponse({ + description: + 'Talk creation failed due to conflict (e.g. duplicate talk or scheduling overlap)', + }) + @ApiInternalServerErrorResponse({ + description: 'Internal server error', + }) + @ApiUnauthorizedResponse({ + description: 'Unauthorized access', + }) + async updateTalk( + @Param('talkId') talkId: string, + @Body() body: UpdateTalkRequest, + ): Promise { + const command = UpdateTalkMapper.toDomain(talkId, body); + const talk = await this.updateTalkUseCase.execute(command); + return UpdateTalkMapper.fromDomain(talk); + } + @UseGuards(JwtAuthGuard) @Roles(UserType.PLANNER) @Post('/:talkId/approve-or-reject') diff --git a/src/adapters/api/mapper/update-talk.mapper.ts b/src/adapters/api/mapper/update-talk.mapper.ts new file mode 100644 index 0000000..336b569 --- /dev/null +++ b/src/adapters/api/mapper/update-talk.mapper.ts @@ -0,0 +1,40 @@ +import { Talk } from '../../../core/domain/model/Talk'; +import { UpdateTalkRequest } from '../request/update-talk.request'; +import { UpdateTalkCommand } from '../../../core/usecases/update-talk-creation-request.use-case'; +import { UpdateTalkResponse } from '../response/update-talk.response'; + +export class UpdateTalkMapper { + static toDomain( + talkId: string, + request: UpdateTalkRequest, + ): UpdateTalkCommand { + return { + talkId: talkId, + title: request.title, + subject: request.subject, + description: request.description, + speaker: request.speaker, + roomId: request.roomId, + level: request.level, + startTime: new Date(request.startTime), + endTime: new Date(request.endTime), + }; + } + + static fromDomain(talk: Talk): UpdateTalkResponse { + return { + id: talk.id, + status: talk.status, + title: talk.title, + subject: talk.subject, + description: talk.description, + speaker: talk.speaker, + roomId: talk.roomId, + level: talk.level, + startTime: talk.startTime.toISOString(), + endTime: talk.endTime.toISOString(), + createdAt: talk.createdAt, + updatedAt: talk.updatedAt, + }; + } +} diff --git a/src/adapters/api/request/update-talk.request.ts b/src/adapters/api/request/update-talk.request.ts new file mode 100644 index 0000000..71e365e --- /dev/null +++ b/src/adapters/api/request/update-talk.request.ts @@ -0,0 +1,71 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { TalkLevel } from '../../../core/domain/type/TalkLevel'; +import { TalkSubject } from '../../../core/domain/type/TalkSubject'; +import { + IsDateString, + IsEnum, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; + +export class UpdateTalkRequest { + @ApiProperty() + @IsString() + @MinLength(2) + @MaxLength(64) + title: string; + + @ApiProperty({ enum: TalkSubject }) + @IsEnum(TalkSubject) + subject: TalkSubject; + + @ApiProperty() + @IsString() + @MinLength(2) + @MaxLength(512) + description: string; + + @ApiProperty() + @IsString() + @MinLength(2) + @MaxLength(64) + speaker: string; + + @ApiProperty() + @IsUUID() + roomId: string; + + @ApiProperty({ enum: TalkLevel }) + @IsEnum(TalkLevel) + level: TalkLevel; + + @ApiProperty() + @IsDateString() + startTime: string; + + @ApiProperty() + @IsDateString() + endTime: string; + + constructor( + title: string, + subject: TalkSubject, + description: string, + speaker: string, + roomId: string, + level: TalkLevel, + startTime: string, + endTime: string, + ) { + this.title = title; + this.subject = subject; + this.description = description; + this.speaker = speaker; + this.roomId = roomId; + this.level = level; + this.startTime = startTime; + this.endTime = endTime; + } +} diff --git a/src/adapters/api/response/update-talk.response.ts b/src/adapters/api/response/update-talk.response.ts new file mode 100644 index 0000000..4b05703 --- /dev/null +++ b/src/adapters/api/response/update-talk.response.ts @@ -0,0 +1,70 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { TalkStatus } from '../../../core/domain/type/TalkStatus'; +import { TalkSubject } from '../../../core/domain/type/TalkSubject'; +import { TalkLevel } from '../../../core/domain/type/TalkLevel'; + +export class UpdateTalkResponse { + @ApiProperty() + id: string; + + @ApiProperty({ enum: TalkStatus }) + status: TalkStatus; + + @ApiProperty() + title: string; + + @ApiProperty({ enum: TalkSubject }) + subject: TalkSubject; + + @ApiProperty() + description: string; + + @ApiProperty() + speaker: string; + + @ApiProperty() + roomId: string; + + @ApiProperty({ enum: TalkLevel }) + level: TalkLevel; + + @ApiProperty() + startTime: string; + + @ApiProperty() + endTime: string; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; + + constructor( + id: string, + status: TalkStatus, + title: string, + subject: TalkSubject, + description: string, + speaker: string, + roomId: string, + level: TalkLevel, + startTime: string, + endTime: string, + createdAt: Date, + updatedAt: Date, + ) { + this.id = id; + this.status = status; + this.title = title; + this.subject = subject; + this.description = description; + this.speaker = speaker; + this.roomId = roomId; + this.level = level; + this.startTime = startTime; + this.endTime = endTime; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } +} diff --git a/src/app.module.ts b/src/app.module.ts index 439673f..ab57fb4 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -23,6 +23,7 @@ import { ApproveOrRejectTalkUseCase } from './core/usecases/approve-or-reject-ta import { JwtAuthGuard } from './adapters/api/guards/jwt-auth.guard'; import { GetAllTalksByStatusUseCase } from './core/usecases/get-all-talks-by-status.use-case'; import { GetRoomByIdUseCase } from './core/usecases/get-room-by-id.use-case'; +import { UpdateTalkCreationRequestUseCase } from './core/usecases/update-talk-creation-request.use-case'; @Module({ imports: [JwtModule.register({})], @@ -82,6 +83,14 @@ import { GetRoomByIdUseCase } from './core/usecases/get-room-by-id.use-case'; ) => new CreateTalkCreationRequestUseCase(talkRepository, roomRepository), inject: [TalkRepository, RoomRepository], }, + { + provide: UpdateTalkCreationRequestUseCase, + useFactory: ( + talkRepository: TalkRepository, + roomRepository: RoomRepository, + ) => new UpdateTalkCreationRequestUseCase(talkRepository, roomRepository), + inject: [TalkRepository, RoomRepository], + }, { provide: ApproveOrRejectTalkUseCase, useFactory: (talkRepository: TalkRepository) => diff --git a/src/core/usecases/__test__/update-talk-creation-request.use-case.spec.ts b/src/core/usecases/__test__/update-talk-creation-request.use-case.spec.ts new file mode 100644 index 0000000..42d1ed3 --- /dev/null +++ b/src/core/usecases/__test__/update-talk-creation-request.use-case.spec.ts @@ -0,0 +1,308 @@ +import { TalkRepository } from '../../domain/repository/talk.repository'; +import { InMemoryTalkRepository } from '../../../adapters/in-memory/in-memory-talk.repository'; +import { InMemoryRoomRepository } from '../../../adapters/in-memory/in-memory-room.repository'; +import { RoomRepository } from '../../domain/repository/room.repository'; +import { RoomNotFoundError } from '../../domain/error/RoomNotFoundError'; +import { TalkSubject } from '../../domain/type/TalkSubject'; +import { TalkStatus } from '../../domain/type/TalkStatus'; +import { TalkLevel } from '../../domain/type/TalkLevel'; +import { + UpdateTalkCommand, + UpdateTalkCreationRequestUseCase, +} from '../update-talk-creation-request.use-case'; +import { TalkNotFoundError } from '../../domain/error/TalkNotFoundError'; + +describe('UpdateTalkCreationRequestUseCase', () => { + let talkRepository: TalkRepository; + let roomRepository: RoomRepository; + let updateTalkUseCase: UpdateTalkCreationRequestUseCase; + + beforeEach(async () => { + roomRepository = new InMemoryRoomRepository(); + talkRepository = new InMemoryTalkRepository(roomRepository); + await talkRepository.removeAll(); + await roomRepository.removeAll(); + updateTalkUseCase = new UpdateTalkCreationRequestUseCase( + talkRepository, + roomRepository, + ); + }); + + it('should be defined', () => { + expect(updateTalkUseCase).toBeDefined(); + }); + + it('should return the updated talk', async () => { + // Given + const talkId = crypto.randomUUID(); + await createTalk( + talkId, + TalkStatus.PENDING_APPROVAL, + new Date('2023-10-01T10:00:00Z'), + new Date('2023-10-01T11:00:00Z'), + ); + const command: UpdateTalkCommand = { + talkId, + title: 'La Clean Architecture pour les nuls', + subject: TalkSubject.WEB_DEVELOPMENT, + description: + 'Une introduction à la Clean Architecture dans le monde TypeScript.', + speaker: 'John Doe', + roomId: 'room-1', + level: TalkLevel.BEGINNER, + startTime: new Date('2023-10-01T10:00:00Z'), + endTime: new Date('2023-10-01T11:00:00Z'), + }; + + // When + const talk = await updateTalkUseCase.execute(command); + + // Then + const talks = await talkRepository.findAll(); + expect(talks.length).toEqual(1); + expect(talk).toEqual({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + id: expect.any(String), + status: TalkStatus.PENDING_APPROVAL, + title: 'La Clean Architecture pour les nuls', + subject: TalkSubject.WEB_DEVELOPMENT, + description: + 'Une introduction à la Clean Architecture dans le monde TypeScript.', + speaker: 'John Doe', + roomId: 'room-1', + level: TalkLevel.BEGINNER, + startTime: new Date('2023-10-01T10:00:00Z'), + endTime: new Date('2023-10-01T11:00:00Z'), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + updatedAt: expect.any(Date), + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + createdAt: expect.any(Date), + }); + }); + + it('should throw error when talk not found', async () => { + // Given + const command: UpdateTalkCommand = { + talkId: 'talk-1', + title: 'La Clean Architecture pour les nuls', + subject: TalkSubject.WEB_DEVELOPMENT, + description: + 'Une introduction à la Clean Architecture dans le monde TypeScript.', + speaker: 'John Doe', + roomId: 'room-1', + level: TalkLevel.BEGINNER, + startTime: new Date('2023-10-01T10:00:00Z'), + endTime: new Date('2023-10-01T11:00:00Z'), + }; + + // When + try { + await updateTalkUseCase.execute(command); + } catch (error) { + // Then + expect(error).toBeInstanceOf(TalkNotFoundError); + expect((error as Error).message).toEqual( + 'Talk with ID talk-1 not found!', + ); + } + }); + + it('should throw error when room not found', async () => { + // Given + const talkId = crypto.randomUUID(); + await createTalk( + talkId, + TalkStatus.PENDING_APPROVAL, + new Date('2023-10-01T10:00:00Z'), + new Date('2023-10-01T11:00:00Z'), + ); + const command: UpdateTalkCommand = { + talkId, + title: 'La Clean Architecture pour les nuls', + subject: TalkSubject.WEB_DEVELOPMENT, + description: + 'Une introduction à la Clean Architecture dans le monde TypeScript.', + speaker: 'John Doe', + roomId: 'room-2', + level: TalkLevel.BEGINNER, + startTime: new Date('2023-10-01T10:00:00Z'), + endTime: new Date('2023-10-01T11:00:00Z'), + }; + + // When + try { + await updateTalkUseCase.execute(command); + } catch (error) { + // Then + expect(error).toBeInstanceOf(RoomNotFoundError); + expect((error as Error).message).toEqual( + 'Room with ID room-2 not found!', + ); + } + }); + + it('should throw error when start time is after end time', async () => { + // Given + const talkId = crypto.randomUUID(); + await createTalk( + talkId, + TalkStatus.PENDING_APPROVAL, + new Date('2023-10-01T10:00:00Z'), + new Date('2023-10-01T11:00:00Z'), + ); + const command: UpdateTalkCommand = { + talkId, + title: 'La Clean Architecture pour les nuls', + subject: TalkSubject.WEB_DEVELOPMENT, + description: + 'Une introduction à la Clean Architecture dans le monde TypeScript.', + speaker: 'John Doe', + roomId: 'room-1', + level: TalkLevel.BEGINNER, + startTime: new Date('2023-10-01T11:00:00Z'), + endTime: new Date('2023-10-01T10:00:00Z'), + }; + + // When + const execute = async () => await updateTalkUseCase.execute(command); + + // Then + await expect(execute).rejects.toThrow( + 'Talk start time must be before end time.', + ); + }); + + it('should throw error when talk time is outside of allowed hours', async () => { + // Given + const talkId = crypto.randomUUID(); + await createTalk( + talkId, + TalkStatus.PENDING_APPROVAL, + new Date('2023-10-01T10:00:00Z'), + new Date('2023-10-01T11:00:00Z'), + ); + const command: UpdateTalkCommand = { + talkId, + title: 'La Clean Architecture pour les nuls', + subject: TalkSubject.WEB_DEVELOPMENT, + description: + 'Une introduction à la Clean Architecture dans le monde TypeScript.', + speaker: 'John Doe', + roomId: 'room-1', + level: TalkLevel.BEGINNER, + startTime: new Date('2023-10-01T08:00:00Z'), + endTime: new Date('2023-10-01T10:00:00Z'), + }; + + // When + const execute = async () => await updateTalkUseCase.execute(command); + + // Then + await expect(execute).rejects.toThrow( + 'Talks must be between 9 and 19 hours.', + ); + }); + + it('should throw error when talk overlaps with existing talk', async () => { + // Given + const talkId = crypto.randomUUID(); + await createTalk( + talkId, + TalkStatus.PENDING_APPROVAL, + new Date('2023-10-01T10:00:00Z'), + new Date('2023-10-01T11:00:00Z'), + ); + await createTalk( + crypto.randomUUID(), + TalkStatus.PENDING_APPROVAL, + new Date('2023-10-01T10:00:00Z'), + new Date('2023-10-01T11:00:00Z'), + ); + const command: UpdateTalkCommand = { + talkId, + title: 'La Clean Architecture pour les nuls', + subject: TalkSubject.WEB_DEVELOPMENT, + description: + 'Une introduction à la Clean Architecture dans le monde TypeScript.', + speaker: 'John Doe', + roomId: 'room-1', + level: TalkLevel.BEGINNER, + startTime: new Date('2023-10-01T10:30:00Z'), + endTime: new Date('2023-10-01T11:30:00Z'), + }; + + // When + const execute = async () => await updateTalkUseCase.execute(command); + + // Then + await expect(execute).rejects.toThrow( + 'Talk overlap another talk in the same room.', + ); + }); + + it('should not throw overlap error if existing talk is REJECTED', async () => { + // Given + const talkId = crypto.randomUUID(); + await createTalk( + talkId, + TalkStatus.PENDING_APPROVAL, + new Date('2023-10-01T10:00:00Z'), + new Date('2023-10-01T11:00:00Z'), + ); + await createTalk( + crypto.randomUUID(), + TalkStatus.REJECTED, + new Date('2023-10-01T10:00:00Z'), + new Date('2023-10-01T11:00:00Z'), + ); + const command: UpdateTalkCommand = { + talkId, + title: 'La Clean Architecture pour les nuls', + subject: TalkSubject.WEB_DEVELOPMENT, + description: + 'Une introduction à la Clean Architecture dans le monde TypeScript.', + speaker: 'John Doe', + roomId: 'room-1', + level: TalkLevel.BEGINNER, + startTime: new Date('2023-10-01T10:30:00Z'), + endTime: new Date('2023-10-01T11:30:00Z'), + }; + + // When + const talk = await updateTalkUseCase.execute(command); + + // Then + expect(talk).toBeDefined(); + }); + + async function createTalk( + id: string, + status: TalkStatus, + startTime: Date, + endTime: Date, + ): Promise { + await roomRepository.create({ + id: 'room-1', + name: 'Room 1', + capacity: 10, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await talkRepository.create({ + id, + status, + title: 'La Clean Architecture pour les nuls', + subject: TalkSubject.WEB_DEVELOPMENT, + description: + 'Une introduction à la Clean Architecture dans le monde TypeScript.', + speaker: 'John Doe', + roomId: 'room-1', + level: TalkLevel.BEGINNER, + startTime, + endTime, + createdAt: new Date(), + updatedAt: new Date(), + }); + } +}); diff --git a/src/core/usecases/create-talk-creation-request.use-case.ts b/src/core/usecases/create-talk-creation-request.use-case.ts index a805e65..96227cf 100644 --- a/src/core/usecases/create-talk-creation-request.use-case.ts +++ b/src/core/usecases/create-talk-creation-request.use-case.ts @@ -82,6 +82,6 @@ export class CreateTalkCreationRequestUseCase command.endTime, ); - return await this.talkRepository.create(talk); + return this.talkRepository.create(talk); } } diff --git a/src/core/usecases/update-talk-creation-request.use-case.ts b/src/core/usecases/update-talk-creation-request.use-case.ts new file mode 100644 index 0000000..53be7f8 --- /dev/null +++ b/src/core/usecases/update-talk-creation-request.use-case.ts @@ -0,0 +1,107 @@ +import { UseCase } from '../base/use-case'; +import { Talk } from '../domain/model/Talk'; +import { TalkRepository } from '../domain/repository/talk.repository'; +import { InvalidTalkTimeError } from '../domain/error/InvalidTalkTimeError'; +import { TalkOverlapError } from '../domain/error/TalkOverlapError'; +import { RoomRepository } from '../domain/repository/room.repository'; +import { RoomNotFoundError } from '../domain/error/RoomNotFoundError'; +import { TalkSubject } from '../domain/type/TalkSubject'; +import { TalkStatus } from '../domain/type/TalkStatus'; +import { TalkLevel } from '../domain/type/TalkLevel'; +import { TalkAlreadyApprovedOrRejectedError } from '../domain/error/TalkAlreadyApprovedOrRejectedError'; +import { TalkNotFoundError } from '../domain/error/TalkNotFoundError'; + +export type UpdateTalkCommand = { + talkId: string; + title: string; + subject: TalkSubject; + description: string; + speaker: string; + roomId: string; + level: TalkLevel; + startTime: Date; + endTime: Date; +}; + +export class UpdateTalkCreationRequestUseCase + implements UseCase +{ + // TODO: Move to config + private readonly MINIMAL_HOUR = 9; + private readonly MAXIMAL_HOUR = 19; + + constructor( + private readonly talkRepository: TalkRepository, + private readonly roomRepository: RoomRepository, + ) {} + + async execute(command: UpdateTalkCommand): Promise { + const existingTalk = await this.talkRepository.findById(command.talkId); + if (!existingTalk) { + throw new TalkNotFoundError(command.talkId); + } + if (existingTalk.status !== TalkStatus.PENDING_APPROVAL) { + throw new TalkAlreadyApprovedOrRejectedError( + existingTalk.id, + existingTalk.status, + ); + } + + if (command.startTime >= command.endTime) { + throw new InvalidTalkTimeError( + 'Talk start time must be before end time.', + ); + } + + const startHour = command.startTime.getUTCHours(); + const endHour = command.endTime.getUTCHours(); + + if (startHour < this.MINIMAL_HOUR || endHour > this.MAXIMAL_HOUR) { + throw new InvalidTalkTimeError( + `Talks must be between ${this.MINIMAL_HOUR} and ${this.MAXIMAL_HOUR} hours.`, + ); + } + + const room = await this.roomRepository.findById(command.roomId); + if (!room) { + throw new RoomNotFoundError(command.roomId); + } + + const existingTalks = await this.talkRepository.findByRoomIdAndStatuses( + command.roomId, + [TalkStatus.PENDING_APPROVAL, TalkStatus.APPROVED], + ); + + for (const talk of existingTalks.filter( + (talk) => talk.id !== command.talkId, + )) { + if ( + command.startTime < talk.endTime && + command.endTime > talk.startTime + ) { + throw new TalkOverlapError( + 'Talk overlap another talk in the same room.', + ); + } + } + + const talk = new Talk( + crypto.randomUUID(), + TalkStatus.PENDING_APPROVAL, + command.title, + command.subject, + command.description, + command.speaker, + command.roomId, + command.level, + command.startTime, + command.endTime, + ); + + const updatedTalk = await this.talkRepository.update(command.talkId, talk); + if (!updatedTalk) { + throw new TalkNotFoundError(command.talkId); + } + return updatedTalk; + } +}