diff --git a/README.md b/README.md
index 7b0628a..285a4fb 100644
--- a/README.md
+++ b/README.md
@@ -1,20 +1,38 @@
-
+
# HKrecruitment
-HKrecruitment is the platform used by HKN Polito to handle
-the recruitment process. This repository contains three branches:
-* **apiserver** - tracks the development of the API server
-* **reactapp** - tracks the development of the front-end React application
-* **documentation** - track the documentation for both the API server and the React application
-Trello for project state https://trello.com/invite/b/T6YYVYW3/ATTI0e7257a02d61a8b4fe54ac88affe746346110847/hkrecruitment
+HKrecruitment is the platform used by HKN Polito to handle the recruitment process.
-## Authors
+This repository follows the strcuture:
-* **Riccardo Zaccone** - *API server* - [HKN Polito](https://hknpolito.org/)
-* **Arianna Ravera** - *API server* - [HKN Polito](https://hknpolito.org/)
-* **Marco Pappalardo** - *React application* - [HKN Polito](https://hknpolito.org/)
+- **api** - API endpoints, back-end logic, and data storage
+- **frontend** - React Application UI
+- **shared** - Models, interfaces, and validation logic common to front-end and back-end
+## Useful Links
+
+[Reports](https://drive.google.com/drive/folders/1RqGVtzU4TV6RJPmtjZQPpHVybDpU6DZk?usp=sharing)
+
+[Trello](https://trello.com/b/vnLyKH85/hkrecruitment)
+
+[UI Mockups](https://miro.com/app/board/uXjVOdvzKAk=/)
+
+[Database Schema](https://app.diagrams.net/#G19QUWxP5BBB3tWXnATnHP8wFE4wW7NsXw)
+
+## Contributors
+
+- **Riccardo Zaccone** - _API server_ - [HKN Polito](https://hknpolito.org/)
+- **Arianna Ravera** - _API server_ - [HKN Polito](https://hknpolito.org/)
+- **Vincenzo Pellegrini** - _API server_ - [HKN Polito](https://hknpolito.org/)
+- **Alberto Baroso** - _API server_ - [HKN Polito](https://hknpolito.org/)
+- **Marco De Luca** - _API server_ - [HKN Polito](https://hknpolito.org/)
+- **Matteo Mugnai** - _API server_ - [HKN Polito](https://hknpolito.org/)
+- **Pasquale Bianco** - _API server_ - [HKN Polito](https://hknpolito.org/)
+
+- **Marco Pappalardo** - _React application_ - [HKN Polito](https://hknpolito.org/)
+- **Damiano Bonaccorsi** - _React application_ - [HKN Polito](https://hknpolito.org/)
## License
+
HKRecruitment is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See the [COPYING](COPYING) file for details.
diff --git a/api/package.json b/api/package.json
index ba92e4e..4bb5e3c 100644
--- a/api/package.json
+++ b/api/package.json
@@ -24,6 +24,7 @@
"@casl/ability": "^6.3.3",
"@fastify/static": "^6.6.0",
"@hkrecruitment/shared": "workspace:*",
+ "@joi/date": "^2.1.0",
"@nestjs/common": "^9.0.0",
"@nestjs/config": "^2.2.0",
"@nestjs/core": "^9.0.0",
@@ -34,9 +35,9 @@
"@types/js-yaml": "^4.0.5",
"@types/passport-jwt": "^3.0.7",
"class-transformer": "^0.5.1",
+ "dotenv": "^16.0.3",
"google-auth-library": "^8.7.0",
"googleapis": "^118.0.0",
- "dotenv": "^16.0.3",
"joi": "^17.7.0",
"js-yaml": "^4.1.0",
"jwks-rsa": "^3.0.0",
@@ -49,7 +50,6 @@
"webpack": "^5.75.0"
},
"devDependencies": {
- "@types/multer": "^1.4.7",
"@automock/jest": "^1.0.1",
"@golevelup/ts-jest": "^0.3.6",
"@nestjs/cli": "^9.0.0",
@@ -59,6 +59,7 @@
"@swc/jest": "^0.2.26",
"@types/express": "^4.17.14",
"@types/jest": "28.1.8",
+ "@types/multer": "^1.4.7",
"@types/node": "^16.11.10",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
@@ -99,7 +100,7 @@
],
"moduleNameMapper": {
"^@mocks/(.*)$": "/src/mocks/$1"
- },
+ },
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
diff --git a/api/src/mocks/data.ts b/api/src/mocks/data.ts
index f31aa3e..be63dcf 100644
--- a/api/src/mocks/data.ts
+++ b/api/src/mocks/data.ts
@@ -11,6 +11,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);
export const testDateTimeStart = new Date(2023, 0, 1, 10, 30, 0);
@@ -24,6 +27,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/frontend/src/App.jsx b/frontend/src/App.jsx
index 709f43b..7c17f28 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,6 +1,6 @@
import "./App.css";
import "bootstrap/dist/css/bootstrap.min.css";
-import React, { useEffect } from "react";
+import React, { useEffect, useState } from "react";
import MyNavbar from "./MyNavbar";
import SignupForm from "./SignupForm";
import { Route } from "react-router-dom";
@@ -37,10 +37,6 @@ function App() {
}
}, [isAuthenticated]);
- if (accessToken === "") {
- return Loading...
;
- }
-
return (
| 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");
+ }
+};