diff --git a/api/src/mocks/data.ts b/api/src/mocks/data.ts index 96dd1d2..3c60b65 100644 --- a/api/src/mocks/data.ts +++ b/api/src/mocks/data.ts @@ -12,6 +12,9 @@ import { PhdApplication, } from 'src/application/application.entity'; import { UpdateApplicationDto } from 'src/application/update-application.dto'; +import { RecruitmentSessionState } from '@hkrecruitment/shared/recruitment-session'; +import { CreateRecruitmentSessionDto } from 'src/recruitment-session/create-recruitment-session.dto'; +import { UpdateRecruitmentSessionDto } from 'src/recruitment-session/update-recruitment-session.dto'; export const testDate = new Date(2023, 0, 1, 10, 0, 0); export const testDateTimeStart = new Date(2023, 0, 1, 10, 30, 0); @@ -25,6 +28,39 @@ export const mockTimeSlot = { id: 1, }; +export const testInterviewStart = '11:55' as unknown as Date; +export const testInterviewEnd = '20:35' as unknown as Date; +export const testDay1 = '2024-10-20' as unknown as Date; +export const testDay2 = '2024-10-21' as unknown as Date; +export const testDay3 = '2024-10-22' as unknown as Date; +export const testDateCreatedAt = '2024-9-10' as unknown as Date; +export const testDateLastModified = '2024-9-12' as unknown as Date; + +export const mockRecruitmentSession = { + id: 1, + state: RecruitmentSessionState.Active, + slotDuration: 50, + interviewStart: testInterviewStart, + interviewEnd: testInterviewEnd, + days: [testDay1, testDay2, testDay3], + createdAt: testDateCreatedAt, + lastModified: testDateLastModified, +}; + +export const mockCreateRecruitmentSessionDto = { + slotDuration: 50, + interviewStart: testInterviewStart, + interviewEnd: testInterviewEnd, + days: [testDay1, testDay2, testDay3], +} as CreateRecruitmentSessionDto; + +export const mockUpdateRecruitmentSessionDto = { + slotDuration: 50, + interviewStart: testInterviewStart, + interviewEnd: testInterviewEnd, + days: [testDay1, testDay2, testDay3], +} as UpdateRecruitmentSessionDto; + export const baseFile = { encoding: '7bit', mimetype: 'application/pdf', diff --git a/api/src/recruitment-session/create-recruitment-session.dto.ts b/api/src/recruitment-session/create-recruitment-session.dto.ts new file mode 100644 index 0000000..81b6399 --- /dev/null +++ b/api/src/recruitment-session/create-recruitment-session.dto.ts @@ -0,0 +1,18 @@ +import { RecruitmentSession } from '@hkrecruitment/shared/recruitment-session'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateRecruitmentSessionDto + implements Partial +{ + @ApiProperty() + slotDuration: number; + + @ApiProperty() + interviewStart: Date; + + @ApiProperty() + interviewEnd: Date; + + @ApiProperty({ isArray: true }) + days: Date[]; +} diff --git a/api/src/recruitment-session/recruitment-session-response.dto.ts b/api/src/recruitment-session/recruitment-session-response.dto.ts new file mode 100644 index 0000000..f13d57f --- /dev/null +++ b/api/src/recruitment-session/recruitment-session-response.dto.ts @@ -0,0 +1,14 @@ +import { + RecruitmentSession, + RecruitmentSessionState, +} from '@hkrecruitment/shared/recruitment-session'; +import { Exclude, Expose } from 'class-transformer'; + +@Exclude() +export class RecruitmentSessionResponseDto + implements Partial +{ + @Expose() id: number; + @Expose() createdAt: Date; + @Expose() state: RecruitmentSessionState; +} diff --git a/api/src/recruitment-session/recruitment-session.controller.spec.ts b/api/src/recruitment-session/recruitment-session.controller.spec.ts new file mode 100644 index 0000000..719202d --- /dev/null +++ b/api/src/recruitment-session/recruitment-session.controller.spec.ts @@ -0,0 +1,355 @@ +import { createMockAbility } from '@hkrecruitment/shared/abilities.spec'; +import { RecruitmentSessionController } from './recruitment-session.controller'; +import { RecruitmentSessionService } from './recruitment-session.service'; +import { Action, RecruitmentSessionState } from '@hkrecruitment/shared'; +import { TestBed } from '@automock/jest'; +import { RecruitmentSessionResponseDto } from './recruitment-session-response.dto'; +import { RecruitmentSession } from './recruitment-session.entity'; +import { + mockRecruitmentSession, + mockUpdateRecruitmentSessionDto, + mockCreateRecruitmentSessionDto, + testDate, +} from '@mocks/data'; +import { + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; +import { UpdateRecruitmentSessionDto } from './update-recruitment-session.dto'; + +describe('RecruitmentSessionController', () => { + let controller: RecruitmentSessionController; + let service: RecruitmentSessionService; + + /************* Test setup ************/ + + beforeAll(() => { + jest + .spyOn(global, 'Date') + .mockImplementation(() => testDate as unknown as string); + }); + + beforeEach(async () => { + const { unit, unitRef } = TestBed.create( + RecruitmentSessionController, + ).compile(); + + controller = unit; + service = unitRef.get(RecruitmentSessionService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + expect(service).toBeDefined(); + }); + + describe('getActive RecruitmentSession', () => { + it('should return an active recruitment session if it exists', async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Read, 'RecruitmentSession'); + }); + jest + .spyOn(service, 'findActiveRecruitmentSession') + .mockResolvedValue(mockRecruitmentSession); + const result = await controller.findActive(mockAbility); + const expectedApp = { + ...mockRecruitmentSession, + } as RecruitmentSessionResponseDto; + expect(result).toEqual(expectedApp); + expect(service.findActiveRecruitmentSession).toHaveBeenCalledTimes(1); + expect(mockAbility.can).toHaveBeenCalled(); + }); + + it("should throw a ForbiddenException if the user can't read the recruitment session", async () => { + const mockAbility = createMockAbility(({ cannot }) => { + cannot(Action.Read, 'RecruitmentSession'); + }); + jest + .spyOn(service, 'findActiveRecruitmentSession') + .mockResolvedValue({ ...mockRecruitmentSession }); + const result = controller.findActive(mockAbility); + await expect(result).rejects.toThrow(ForbiddenException); + expect(service.findActiveRecruitmentSession).toHaveBeenCalledTimes(1); + expect(mockAbility.can).toHaveBeenCalled(); + }); + }); + + describe('createRecruitmentSession', () => { + it('should create a recruitment session', async () => { + const expectedRecruitmentSession = { + ...mockRecruitmentSession, + } as RecruitmentSessionResponseDto; + jest + .spyOn(service, 'createRecruitmentSession') + .mockResolvedValue(mockRecruitmentSession); + const result = await controller.createRecruitmentSession( + mockCreateRecruitmentSessionDto, + ); + expect(result).toEqual(expectedRecruitmentSession); + expect(service.createRecruitmentSession).toHaveBeenCalledTimes(1); + expect(service.createRecruitmentSession).toHaveBeenCalledWith( + mockCreateRecruitmentSessionDto, + ); + }); + + it('should throw a ConflictException if there is already an active recruitment session', async () => { + jest + .spyOn(service, 'findActiveRecruitmentSession') + .mockResolvedValue(mockRecruitmentSession); + const result = controller.createRecruitmentSession( + mockCreateRecruitmentSessionDto, + ); + await expect(result).rejects.toThrow(ConflictException); + expect(service.findActiveRecruitmentSession).toHaveBeenCalledTimes(1); + }); + }); + + describe('updateRecruitmentSession', () => { + it('should update a recruitment session', async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Update, 'RecruitmentSession'); + }); + const mockUpdatedRecruitmentSession = { + ...mockRecruitmentSession, + ...mockUpdateRecruitmentSessionDto, + } as RecruitmentSession; + const expectedRecruitmentSession = { + id: mockUpdatedRecruitmentSession.id, + state: mockUpdatedRecruitmentSession.state, + createdAt: mockUpdatedRecruitmentSession.createdAt, + } as RecruitmentSessionResponseDto; + jest + .spyOn(service, 'findRecruitmentSessionById') + .mockResolvedValue(mockRecruitmentSession); + jest + .spyOn(service, 'updateRecruitmentSession') + .mockResolvedValue(mockUpdatedRecruitmentSession); + const result = await controller.updateRecruitmentSession( + mockRecruitmentSession.id, + mockUpdateRecruitmentSessionDto, + mockAbility, + ); + expect(result).toEqual(expectedRecruitmentSession); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + expect(service.updateRecruitmentSession).toHaveBeenCalledTimes(1); + expect(service.updateRecruitmentSession).toHaveBeenCalledWith({ + ...mockRecruitmentSession, + lastModified: testDate, + }); + }); + + it('should throw a NotFoundException if the recruitment session does not exist', async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Update, 'RecruitmentSession'); + }); + jest.spyOn(service, 'findRecruitmentSessionById').mockResolvedValue(null); + const result = controller.updateRecruitmentSession( + mockRecruitmentSession.id, + mockUpdateRecruitmentSessionDto, + mockAbility, + ); + await expect(result).rejects.toThrow(NotFoundException); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + }); + + it("should throw a ForbiddenException if the user can't update the recruitment session", async () => { + const mockAbility = createMockAbility(({ cannot }) => { + cannot(Action.Update, 'RecruitmentSession'); + }); + jest + .spyOn(service, 'findRecruitmentSessionById') + .mockResolvedValue(mockRecruitmentSession); + const result = controller.updateRecruitmentSession( + mockRecruitmentSession.id, + mockUpdateRecruitmentSessionDto, + mockAbility, + ); + await expect(result).rejects.toThrow(ForbiddenException); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + }); + + it("should throw a ConflictException when updating a RecruitmentSection state to 'Active' and there is already an active recruitment session", async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Update, 'RecruitmentSession'); + }); + const mockRecruitmentSessionToUpdate = { + ...mockRecruitmentSession, + state: RecruitmentSessionState.Concluded, + id: 1, + } as RecruitmentSession; + const activeRecruitmentSession = { + ...mockRecruitmentSession, + id: 2, + state: RecruitmentSessionState.Active, + } as RecruitmentSession; + const updateRecruitmentSessionDto = { + state: RecruitmentSessionState.Active, + } as UpdateRecruitmentSessionDto; + jest + .spyOn(service, 'findRecruitmentSessionById') + .mockResolvedValue(mockRecruitmentSessionToUpdate); + jest + .spyOn(service, 'findActiveRecruitmentSession') + .mockResolvedValue(activeRecruitmentSession); + const result = controller.updateRecruitmentSession( + mockRecruitmentSessionToUpdate.id, + updateRecruitmentSessionDto, + mockAbility, + ); + await expect(result).rejects.toThrow(ConflictException); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + expect(service.findActiveRecruitmentSession).toHaveBeenCalledTimes(1); + }); + + it("shouldn't throw a ConflictException when updating the currentyl active RecruitmentSection state to 'Active'", async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Update, 'RecruitmentSession'); + }); + const mockRecruitmentSessionToUpdate = { + ...mockRecruitmentSession, + state: RecruitmentSessionState.Concluded, + id: 1, + } as RecruitmentSession; + const activeRecruitmentSession = { + ...mockRecruitmentSession, + id: 1, + state: RecruitmentSessionState.Active, + } as RecruitmentSession; + const updateRecruitmentSessionDto = { + state: RecruitmentSessionState.Active, + } as UpdateRecruitmentSessionDto; + jest + .spyOn(service, 'findRecruitmentSessionById') + .mockResolvedValue(mockRecruitmentSessionToUpdate); + jest + .spyOn(service, 'findActiveRecruitmentSession') + .mockResolvedValue(activeRecruitmentSession); + const result = controller.updateRecruitmentSession( + mockRecruitmentSessionToUpdate.id, + updateRecruitmentSessionDto, + mockAbility, + ); + await expect(result).resolves.not.toThrow(ConflictException); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + }); + + it("should throw a ConflictException when updating a RecruitmentSection state to 'Concluded' and there are pending interviews", async () => { + const mockAbility = createMockAbility(({ can }) => { + can(Action.Update, 'RecruitmentSession'); + }); + const mockRecruitmentSessionToUpdate = { + ...mockRecruitmentSession, + state: RecruitmentSessionState.Active, + id: 1, + } as RecruitmentSession; + const updateRecruitmentSessionDto = { + state: RecruitmentSessionState.Concluded, + } as UpdateRecruitmentSessionDto; + jest + .spyOn(service, 'findRecruitmentSessionById') + .mockResolvedValue(mockRecruitmentSessionToUpdate); + jest + .spyOn(service, 'sessionHasPendingInterviews') + .mockResolvedValue(true); + const result = controller.updateRecruitmentSession( + mockRecruitmentSessionToUpdate.id, + updateRecruitmentSessionDto, + mockAbility, + ); + await expect(result).rejects.toThrow(ConflictException); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + expect(service.sessionHasPendingInterviews).toHaveBeenCalledTimes(1); + expect(service.sessionHasPendingInterviews).toHaveBeenCalledWith( + mockRecruitmentSessionToUpdate, + ); + }); + }); + + describe('deleteRecruitmentSession', () => { + it('should delete a recruitment session', async () => { + const mockDeletedRecruitmentSession = { + ...mockRecruitmentSession, + } as RecruitmentSession; + const expectedRecruitmentSession = { + id: mockDeletedRecruitmentSession.id, + state: mockDeletedRecruitmentSession.state, + createdAt: mockDeletedRecruitmentSession.createdAt, + } as RecruitmentSessionResponseDto; + jest + .spyOn(service, 'findRecruitmentSessionById') + .mockResolvedValue(mockRecruitmentSession); + jest + .spyOn(service, 'deletRecruitmentSession') + .mockResolvedValue(mockDeletedRecruitmentSession); + const result = await controller.deleteRecruitmentSession( + mockRecruitmentSession.id, + ); + expect(result).toEqual(expectedRecruitmentSession); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + expect(service.deletRecruitmentSession).toHaveBeenCalledTimes(1); + expect(service.deletRecruitmentSession).toHaveBeenCalledWith( + mockRecruitmentSession, + ); + }); + + it('should throw a NotFoundException if the recruitment session does not exist', async () => { + jest.spyOn(service, 'findRecruitmentSessionById').mockResolvedValue(null); + const result = controller.deleteRecruitmentSession( + mockRecruitmentSession.id, + ); + await expect(result).rejects.toThrow(NotFoundException); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + }); + + it('should throw a ConflictException when deleting a RecruitmentSection that has pending interviews', async () => { + const mockRecruitmentSessionToDelete = { + ...mockRecruitmentSession, + state: RecruitmentSessionState.Active, + id: 1, + } as RecruitmentSession; + jest + .spyOn(service, 'findRecruitmentSessionById') + .mockResolvedValue(mockRecruitmentSessionToDelete); + jest + .spyOn(service, 'sessionHasPendingInterviews') + .mockResolvedValue(true); + const result = controller.deleteRecruitmentSession( + mockRecruitmentSessionToDelete.id, + ); + await expect(result).rejects.toThrow(ConflictException); + expect(service.findRecruitmentSessionById).toHaveBeenCalledTimes(1); + expect(service.findRecruitmentSessionById).toHaveBeenCalledWith( + mockRecruitmentSession.id, + ); + expect(service.sessionHasPendingInterviews).toHaveBeenCalledTimes(1); + expect(service.sessionHasPendingInterviews).toHaveBeenCalledWith( + mockRecruitmentSessionToDelete, + ); + }); + }); +}); diff --git a/api/src/recruitment-session/recruitment-session.controller.ts b/api/src/recruitment-session/recruitment-session.controller.ts new file mode 100644 index 0000000..9e65523 --- /dev/null +++ b/api/src/recruitment-session/recruitment-session.controller.ts @@ -0,0 +1,231 @@ +import { + Body, + Controller, + NotFoundException, + ConflictException, + Param, + Post, + Delete, + Patch, + ForbiddenException, + Get, +} from '@nestjs/common'; +import { RecruitmentSessionService } from './recruitment-session.service'; +import { + createRecruitmentSessionSchema, + RecruitmentSession, + RecruitmentSessionState, + updateRecruitmentSessionSchema, +} from '@hkrecruitment/shared/recruitment-session'; +import { Action, AppAbility, checkAbility } from '@hkrecruitment/shared'; +import { JoiValidate } from '../joi-validation/joi-validate.decorator'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiCreatedResponse, + ApiOkResponse, + ApiTags, + ApiConflictResponse, + ApiNoContentResponse, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { CheckPolicies } from 'src/authorization/check-policies.decorator'; +import { CreateRecruitmentSessionDto } from './create-recruitment-session.dto'; +import { UpdateRecruitmentSessionDto } from './update-recruitment-session.dto'; +import * as Joi from 'joi'; +import { Ability } from 'src/authorization/ability.decorator'; +import { plainToClass } from 'class-transformer'; +import { RecruitmentSessionResponseDto } from './recruitment-session-response.dto'; + +@ApiBearerAuth() +@ApiTags('recruitment-session') +@Controller('recruitment-session') +export class RecruitmentSessionController { + constructor( + private readonly recruitmentSessionService: RecruitmentSessionService, + ) {} + + // FIND ACTIVE RECRUITMENT SESSION + @ApiNotFoundResponse() + @ApiUnauthorizedResponse() + @Get() + @CheckPolicies((ability) => ability.can(Action.Read, 'RecruitmentSession')) + async findActive( + @Ability() ability: AppAbility, + ): Promise { + const recruitmentSession = + await this.recruitmentSessionService.findActiveRecruitmentSession(); + if (recruitmentSession === null) { + throw new NotFoundException(); + } + + if ( + !checkAbility( + ability, + Action.Read, + recruitmentSession, + 'RecruitmentSession', + ) + ) { + throw new ForbiddenException(); + } + + return recruitmentSession; + } + + // CREATE NEW RECRUITMENT SESSION + @ApiBadRequestResponse() + @ApiForbiddenResponse() + @ApiConflictResponse({ + description: 'The recruitment session cannot be created', // + }) + @ApiCreatedResponse() + @JoiValidate({ + body: createRecruitmentSessionSchema, + }) + @CheckPolicies((ability) => ability.can(Action.Create, 'RecruitmentSession')) + @Post() + async createRecruitmentSession( + @Body() recruitmentSession: CreateRecruitmentSessionDto, + ): Promise { + // there should be only one active recruitment session at a time + const hasActiveRecruitmentSession = + await this.recruitmentSessionService.findActiveRecruitmentSession(); + if (hasActiveRecruitmentSession) + throw new ConflictException( + 'There is already an active recruitment session', + ); + + return this.recruitmentSessionService.createRecruitmentSession({ + ...recruitmentSession, + }); + } + + // UPDATE A RECRUITMENT SESSION + @Patch(':session_id') + @ApiBadRequestResponse() + @ApiForbiddenResponse() + @ApiConflictResponse() + @ApiNotFoundResponse() + @ApiOkResponse() + @JoiValidate({ + param: Joi.number().positive().integer().required().label('session_id'), + body: updateRecruitmentSessionSchema, + }) + @CheckPolicies((ability) => ability.can(Action.Update, 'RecruitmentSession')) + async updateRecruitmentSession( + @Param('session_id') sessionId: number, + @Body() updateRecruitmentSession: UpdateRecruitmentSessionDto, + @Ability() ability: AppAbility, + ): Promise { + const recruitmentSession = + await this.recruitmentSessionService.findRecruitmentSessionById( + sessionId, + ); + + if (recruitmentSession === null) throw new NotFoundException(); + + const sessionToCheck = { + ...updateRecruitmentSession, + sessionId: recruitmentSession.id, + }; + if ( + !checkAbility( + ability, + Action.Update, + sessionToCheck, + 'RecruitmentSession', + ) + ) + throw new ForbiddenException(); + + if (updateRecruitmentSession.hasOwnProperty('state')) { + // There should be only one active recruitment session at a time + if (updateRecruitmentSession.state === RecruitmentSessionState.Active) { + const currentlyActiveRecruitmentSession = + await this.recruitmentSessionService.findActiveRecruitmentSession(); + if ( + currentlyActiveRecruitmentSession && + currentlyActiveRecruitmentSession.id !== recruitmentSession.id // It's ok to set 'Active' to the (already) active recruitment session + ) + throw new ConflictException( + 'There is already an active recruitment session', + ); + } else if ( + updateRecruitmentSession.state === RecruitmentSessionState.Concluded + ) { + // Recruitment session can't be set to concluded if it has pending interviews + const hasPendingInterviews = + await this.recruitmentSessionService.sessionHasPendingInterviews( + recruitmentSession, + ); + if (hasPendingInterviews) + throw new ConflictException( + "Recruitment session can't be set to inactive because it has pending interviews", + ); + } + } + + const updatedRecruitmentSession = + await this.recruitmentSessionService.updateRecruitmentSession({ + ...recruitmentSession, + ...updateRecruitmentSession, + lastModified: new Date(), + }); + + return plainToClass( + RecruitmentSessionResponseDto, + updatedRecruitmentSession, + ); + } + + // DELETE A RECRUITMENT SESSION + @ApiBadRequestResponse() + @ApiForbiddenResponse() + @ApiNotFoundResponse() + @ApiOkResponse() + @ApiConflictResponse() + @ApiNoContentResponse() + @CheckPolicies((ability) => ability.can(Action.Delete, 'RecruitmentSession')) + @Delete('/:recruitment_session_id') + @JoiValidate({ + param: Joi.number() + .positive() + .integer() + .required() + .label('recruitment_session_id'), + }) + async deleteRecruitmentSession( + @Param('recruitment_session_id') recruitmentSessionId: number, + ): Promise { + // Check if recruitment session exists + const toRemove = + await this.recruitmentSessionService.findRecruitmentSessionById( + recruitmentSessionId, + ); + if (!toRemove) throw new NotFoundException('Recruitment session not found'); + + // Check if recruitment session has pending interviews + if (toRemove.state !== RecruitmentSessionState.Concluded) { + const hasPendingInterviews = + await this.recruitmentSessionService.sessionHasPendingInterviews( + toRemove, + ); + if (hasPendingInterviews) + throw new ConflictException( + "Recruitment session can't be deleted because it has pending interviews", + ); + } + + // Delete recruitment session + const deletedRecruitmentSession = + await this.recruitmentSessionService.deletRecruitmentSession(toRemove); + + return plainToClass( + RecruitmentSessionResponseDto, + deletedRecruitmentSession, + ); + } +} diff --git a/api/src/recruitment-session/recruitment-session.entity.ts b/api/src/recruitment-session/recruitment-session.entity.ts new file mode 100644 index 0000000..df58326 --- /dev/null +++ b/api/src/recruitment-session/recruitment-session.entity.ts @@ -0,0 +1,32 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { + RecruitmentSession as RecruitmentSessionInterface, + RecruitmentSessionState, +} from '@hkrecruitment/shared'; + +@Entity() +export class RecruitmentSession implements RecruitmentSessionInterface { + @PrimaryGeneratedColumn('increment') + id: number; + + @Column() + state: RecruitmentSessionState; + + @Column('int', { name: 'slot_duration' }) + slotDuration: number; + + @Column('date', { name: 'interview_start' }) + interviewStart: Date; + + @Column('date', { name: 'interview_end' }) + interviewEnd: Date; + + @Column('date', { array: true }) + days: Date[]; + + @Column('date', { name: 'created_at' }) + createdAt: Date; + + @Column('date', { name: 'last_modified' }) + lastModified: Date; +} diff --git a/api/src/recruitment-session/recruitment-session.service.spec.ts b/api/src/recruitment-session/recruitment-session.service.spec.ts new file mode 100644 index 0000000..d6dfc9e --- /dev/null +++ b/api/src/recruitment-session/recruitment-session.service.spec.ts @@ -0,0 +1,51 @@ +import { mockRecruitmentSession, testDate } from '@mocks/data'; +import { mockedRepository } from '@mocks/repositories'; +import { TestingModule, Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { RecruitmentSession } from './recruitment-session.entity'; +import { RecruitmentSessionService } from './recruitment-session.service'; + +describe('Recruitment Session Service', () => { + let recruitmentSessionService: RecruitmentSessionService; + + beforeAll(() => { + jest + .spyOn(global, 'Date') + .mockImplementation(() => testDate as unknown as string); + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RecruitmentSessionService, + { + provide: getRepositoryToken(RecruitmentSession), + useValue: mockedRepository, + }, + ], + }).compile(); + + recruitmentSessionService = module.get( + RecruitmentSessionService, + ); + }); + + afterEach(() => jest.clearAllMocks()); + + it('should be defined', () => { + expect(recruitmentSessionService).toBeDefined(); + }); + + describe('deleteRecruitmentSession', () => { + it('should remove the specified recruitment session from the database', async () => { + jest + .spyOn(mockedRepository, 'remove') + .mockResolvedValue(mockRecruitmentSession); + const result = await recruitmentSessionService.deletRecruitmentSession( + mockRecruitmentSession, + ); + expect(result).toEqual(mockRecruitmentSession); + expect(mockedRepository.remove).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/api/src/recruitment-session/recruitment-session.service.ts b/api/src/recruitment-session/recruitment-session.service.ts new file mode 100644 index 0000000..3d9ee94 --- /dev/null +++ b/api/src/recruitment-session/recruitment-session.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { RecruitmentSession } from './recruitment-session.entity'; +import { CreateRecruitmentSessionDto } from './create-recruitment-session.dto'; +import { RecruitmentSessionState } from '@hkrecruitment/shared/recruitment-session'; + +@Injectable() +export class RecruitmentSessionService { + constructor( + @InjectRepository(RecruitmentSession) + private readonly recruitmentSessionRepository: Repository, + ) {} + + async createRecruitmentSession( + recruitmentSession: CreateRecruitmentSessionDto, + ): Promise { + const now = new Date(); + const rs = { + ...recruitmentSession, + state: RecruitmentSessionState.Active, + createdAt: now, + lastModified: now, + } as unknown as RecruitmentSession; + await this.recruitmentSessionRepository.save(rs); + return rs; + } + + async findAllRecruitmentSessions(): Promise { + return await this.recruitmentSessionRepository.find(); + } + + async findRecruitmentSessionById(id: number): Promise { + return await this.recruitmentSessionRepository.findOne({ where: { id } }); + } + + async findActiveRecruitmentSession(): Promise { + return await this.recruitmentSessionRepository.findOne({ + where: { state: RecruitmentSessionState.Active }, + }); + } + + async deletRecruitmentSession( + recruitmentSession: RecruitmentSession, + ): Promise { + return await this.recruitmentSessionRepository.remove(recruitmentSession); + } + + async updateRecruitmentSession( + recruitmentSession: RecruitmentSession, + ): Promise { + return await this.recruitmentSessionRepository.save(recruitmentSession); + } + + async sessionHasPendingInterviews( + recruitmentSession: RecruitmentSession, + ): Promise { + throw new Error('Method not implemented.'); + // TODO: Return true if recruitmentSession.interviews > 0 where interviw date is in the future + } +} diff --git a/api/src/recruitment-session/update-recruitment-session.dto.ts b/api/src/recruitment-session/update-recruitment-session.dto.ts new file mode 100644 index 0000000..a1595ae --- /dev/null +++ b/api/src/recruitment-session/update-recruitment-session.dto.ts @@ -0,0 +1,24 @@ +import { + RecruitmentSession, + RecruitmentSessionState, +} from '@hkrecruitment/shared/recruitment-session'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateRecruitmentSessionDto + implements Partial +{ + @ApiProperty({ required: false }) + state?: RecruitmentSessionState; + + @ApiProperty({ required: false }) + slotDuration?: number; + + @ApiProperty({ required: false }) + interviewStart?: Date; + + @ApiProperty({ required: false }) + interviewEnd?: Date; + + @ApiProperty({ required: false, isArray: true }) + days?: Date[]; +} diff --git a/shared/src/abilities.ts b/shared/src/abilities.ts index 56a3e5d..23fd5bf 100644 --- a/shared/src/abilities.ts +++ b/shared/src/abilities.ts @@ -9,6 +9,8 @@ import { applyAbilitiesForPerson, Person, Role } from "./person"; import { Application, applyAbilitiesOnApplication } from "./application"; import { applyAbilitiesOnAvailability, Availability } from "./availability"; import { TimeSlot } from "./timeslot"; +import { RecruitmentSession } from "./recruitment-session"; +import { applyAbilitiesOnRecruitmentSession } from "./recruitment-session"; export interface UserAuth { sub: string; @@ -26,8 +28,14 @@ type SubjectsTypes = | Partial | Partial | Partial - | Partial; -type SubjectNames = "Person" | "Application" | "Availability" | "TimeSlot"; + | Partial + | Partial; +type SubjectNames = + | "Person" + | "Application" + | "Availability" + | "TimeSlot" + | "RecruitmentSession"; export type Subjects = SubjectsTypes | SubjectNames; export type AppAbility = PureAbility<[Action, Subjects]>; @@ -44,6 +52,7 @@ export const abilityForUser = (user: UserAuth): AppAbility => { applyAbilitiesForPerson(user, builder); applyAbilitiesOnApplication(user, builder); applyAbilitiesOnAvailability(user, builder); + applyAbilitiesOnRecruitmentSession(user, builder); const { build } = builder; return build(); diff --git a/shared/src/index.ts b/shared/src/index.ts index 3402b34..17e316f 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -4,3 +4,4 @@ export * from "./application"; export * from "./availability"; export * from "./timeslot"; export * from "./slot"; +export * from "./recruitment-session"; diff --git a/shared/src/recruitment-session.spec.ts b/shared/src/recruitment-session.spec.ts new file mode 100644 index 0000000..e86cf48 --- /dev/null +++ b/shared/src/recruitment-session.spec.ts @@ -0,0 +1,83 @@ +import { + RecruitmentSession, + RecruitmentSessionState, + createRecruitmentSessionSchema, + applyAbilitiesOnRecruitmentSession, +} from "./recruitment-session"; +import { createMockAbility } from "./abilities.spec"; +import { Action, UserAuth, checkAbility } from "./abilities"; +import { expression } from "joi"; + +describe("Recruitment Session", () => { + describe("createRecruitmentSessionSchema", () => { + const mockRecSess: Partial = { + state: RecruitmentSessionState.Active, + slotDuration: 5, + interviewStart: "11:55" as unknown as Date, + interviewEnd: "16:30" as unknown as Date, + days: [new Date("2024-12-23"), new Date("2024-12-23")], + lastModified: new Date("2023-10-20 15:10"), + }; + + it("should allow a valid recruitment session", () => { + expect( + createRecruitmentSessionSchema.validate(mockRecSess) + ).not.toHaveProperty("error"); + }); + + it("should allow to not set optional fields", () => { + const session: Partial = { + ...mockRecSess, + days: undefined, + slotDuration: undefined, + }; + expect( + createRecruitmentSessionSchema.validate(session) + ).not.toHaveProperty("error"); + }); + + it("should require state", () => { + const session: Partial = { + ...mockRecSess, + state: undefined, + }; + const { error } = createRecruitmentSessionSchema.validate(session); + expect(error).toBeDefined(); + expect(error.message).toMatch(/.+state.+ is required/); + }); + + it("should require interview start", () => { + const session: Partial = { + ...mockRecSess, + interviewStart: undefined, + }; + const { error } = createRecruitmentSessionSchema.validate(session); + expect(error).toBeDefined(); + expect(error.message).toMatch(/.+interviewStart.+ is required/); + }); + + it("should require interview end", () => { + const session: Partial = { + ...mockRecSess, + interviewEnd: undefined, + }; + const { error } = createRecruitmentSessionSchema.validate(session); + expect(error).toBeDefined(); + expect(error.message).toMatch(/.+interviewEnd.+ is required/); + }); + + it("should require last modified", () => { + const session: Partial = { + ...mockRecSess, + lastModified: undefined, + }; + const { error } = createRecruitmentSessionSchema.validate(session); + expect(error).toBeDefined(); + expect(error.message).toMatch(/.+lastModified.+ is required/); + }); + + it("check interview start type: should be 11:55", () => { + expect(mockRecSess.interviewStart).toMatch("11:55"); + }); + }); +}); diff --git a/shared/src/recruitment-session.ts b/shared/src/recruitment-session.ts new file mode 100644 index 0000000..a232e18 --- /dev/null +++ b/shared/src/recruitment-session.ts @@ -0,0 +1,72 @@ +import { Action, ApplyAbilities } from "./abilities"; +import { Role } from "./person"; +import DateExtension from "@joi/date"; +import * as Joi from "joi"; + +const JoiDate = Joi.extend(DateExtension); + +export enum RecruitmentSessionState { + Active = "active", + Concluded = "concluded", +} + +// import BaseJoi from "joi"; + +export interface RecruitmentSession { + state: RecruitmentSessionState; + slotDuration: number; + interviewStart: Date; + interviewEnd: Date; + days: Date[]; + createdAt: Date; + lastModified: Date; +} + +/* Validation schemas */ + +export const createRecruitmentSessionSchema = Joi.object({ + state: Joi.string().valid("active", "concluded").required(), + slotDuration: Joi.number().integer().optional(), + interviewStart: JoiDate.date().format("HH:mm").required(), + interviewEnd: JoiDate.date().format("HH:mm").required(), + days: Joi.array().items(JoiDate.date().format("YYYY-MM-DD")).optional(), + lastModified: JoiDate.date().format("YYYY-MM-DD HH:mm").required(), +}).options({ + stripUnknown: true, + abortEarly: false, + presence: "required", +}); + +export const updateRecruitmentSessionSchema = Joi.object({ + state: Joi.string().valid("active", "concluded").optional(), + slotDuration: Joi.number().integer().optional(), + interviewStart: JoiDate.date().format("YYYY-MM-DD HH:mm").optional(), + interviewEnd: JoiDate.date().format("YYYY-MM-DD HH:mm").optional(), + days: Joi.array().items(JoiDate.date().format("YYYY-MM-DD")).optional(), + createdAt: JoiDate.date().format("YYYY-MM-DD HH:mm").optional(), + lastModified: JoiDate.date().format("YYYY-MM-DD HH:mm").optional(), +}); + +/* Abilities */ + +export const applyAbilitiesOnRecruitmentSession: ApplyAbilities = ( + user, + { can, cannot } +) => { + can(Action.Manage, "RecruitmentSession"); + switch (user.role) { + case Role.Admin: + case Role.Supervisor: + can(Action.Manage, "RecruitmentSession"); + break; + case Role.Clerk: + case Role.Member: + can(Action.Read, "RecruitmentSession"); + break; + case Role.Applicant: + cannot(Action.Manage, "RecruitmentSession"); + break; + default: + cannot(Action.Manage, "RecruitmentSession"); + } +};