From 0a4de9400f023ee0f06cb3172987ef9c7bf26dbd Mon Sep 17 00:00:00 2001 From: Alberto Baroso <35893959+AlbertoBaroso@users.noreply.github.com> Date: Thu, 23 Nov 2023 15:26:08 +0100 Subject: [PATCH 01/37] Fix missing dependencies and imports (#9) * fix: added @joi/date library * fix: added missing useState import fix: removed loading screen when auth token is empty --- api/package.json | 7 ++++--- frontend/src/App.jsx | 6 +----- pnpm-lock.yaml | 3 +++ 3 files changed, 8 insertions(+), 8 deletions(-) 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/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 ( Date: Thu, 7 Dec 2023 18:27:32 +0100 Subject: [PATCH 02/37] docs: updated project description, useful links, and contributors in README.md (#10) --- README.md | 40 +++++++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 11 deletions(-) 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. From 9f52fdfa11319330aec5bd61d4eefe940194d4a8 Mon Sep 17 00:00:00 2001 From: white Date: Mon, 11 Dec 2023 19:42:35 +0100 Subject: [PATCH 03/37] feat session: service, controller, entity --- .../create-recruitment-session.dto.ts | 25 +++++ .../recruitment-session-response.dto.ts | 9 ++ .../recruitment-session.controller.ts | 102 ++++++++++++++++++ .../recruitment-session.entity.ts | 30 ++++++ .../recruitment-session.service.ts | 34 ++++++ .../update-recruitment-session.dto.ts | 23 ++++ shared/src/abilities.ts | 6 +- shared/src/recruitment-session.ts | 82 ++++++++++++++ 8 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 api/src/recruitment-session/create-recruitment-session.dto.ts create mode 100644 api/src/recruitment-session/recruitment-session-response.dto.ts create mode 100644 api/src/recruitment-session/recruitment-session.controller.ts create mode 100644 api/src/recruitment-session/recruitment-session.entity.ts create mode 100644 api/src/recruitment-session/recruitment-session.service.ts create mode 100644 api/src/recruitment-session/update-recruitment-session.dto.ts create mode 100644 shared/src/recruitment-session.ts 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..614ef24 --- /dev/null +++ b/api/src/recruitment-session/create-recruitment-session.dto.ts @@ -0,0 +1,25 @@ +import { RecruitmentSession, RecruitmentSessionState } from "@hkrecruitment/shared/recruitment-session"; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateRecruitmentSessionDto implements RecruitmentSession { + @ApiProperty() + state: RecruitmentSessionState; + + @ApiProperty() + slotDuration: number; + + @ApiProperty() + interviewStart: Date; + + @ApiProperty() + interviewEnd: Date; + + @ApiProperty() + days: [Date]; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + lastModified: Date; +} \ No newline at end of file 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..d63a3fd --- /dev/null +++ b/api/src/recruitment-session/recruitment-session-response.dto.ts @@ -0,0 +1,9 @@ +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; +} + \ No newline at end of file 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..22d46fd --- /dev/null +++ b/api/src/recruitment-session/recruitment-session.controller.ts @@ -0,0 +1,102 @@ +import { + Body, + Controller, + BadRequestException, + NotFoundException, + ConflictException, + Param, + Post, + Delete, + Req, + Patch, + ForbiddenException, + } 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, +} 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 { AuthenticatedRequest } from 'src/authorization/authenticated-request.types'; +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) {} + + // 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() rSess: CreateRecruitmentSessionDto): Promise { + return this.recruitmentSessionService.createRecruitmentSession({...rSess}); + } + + // UPDATE A RECRUITMENT SESSION + @Patch(':session_id') + @ApiBadRequestResponse() + @ApiForbiddenResponse() + @ApiOkResponse() + @JoiValidate({ + param: Joi.number().positive().integer().required().label('session_id'), + body: updateRecruitmentSessionSchema, + }) + async updateRecruitmentSession( + @Param('session_id') sessionId: number, + @Body() updateRecruitmentSession: UpdateRecruitmentSessionDto, + @Ability() ability: AppAbility, + @Req() req: AuthenticatedRequest, + ): Promise { + const session = await this.recruitmentSessionService.findRecruitmentSessionById(sessionId); + + if (session === null) throw new NotFoundException(); + + const sessionToCheck = { + ...updateRecruitmentSession, + sessionId: session.id, + }; + if ( + !checkAbility(ability, Action.Update, sessionToCheck, 'RecruitmentSession', [ + 'applicantId', + ]) + ) + throw new ForbiddenException(); + + const updatedRecruitmentSession = await this.recruitmentSessionService.updateRecruitmentSession( + { + ...session, + ...updateRecruitmentSession, + lastModified: new Date(), + }, + ); + + return plainToClass(RecruitmentSessionResponseDto, updatedRecruitmentSession); + } + + +} \ No newline at end of file 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..73f6d04 --- /dev/null +++ b/api/src/recruitment-session/recruitment-session.entity.ts @@ -0,0 +1,30 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { RecruitmentSession as RecruitmentSessionInterface, RecruitmentSessionState } from '@hkrecruitment/shared/src/recruitment-session' + + +@Entity() +export class RecruitmentSession implements RecruitmentSessionInterface { + @PrimaryGeneratedColumn('increment') + id: number; + + @Column() + state: RecruitmentSessionState; + + @Column() + slotDuration: number; + + @Column() + interviewStart: Date; + + @Column() + interviewEnd: Date; + + @Column() + days: [Date]; + + @Column() + createdAt: Date; + + @Column() + lastModified: Date; +} 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..ec46aca --- /dev/null +++ b/api/src/recruitment-session/recruitment-session.service.ts @@ -0,0 +1,34 @@ +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"; + + +@Injectable() +export class RecruitmentSessionService { + constructor( + @InjectRepository(RecruitmentSession) + private readonly recruitmentSessionRepository: Repository + ) {} + + async createRecruitmentSession(rSess: CreateRecruitmentSessionDto): Promise { + return await this.recruitmentSessionRepository.save(rSess); + } + + async findAllRecruitmentSessions(): Promise { + return await this.recruitmentSessionRepository.find(); + } + + async findRecruitmentSessionById(id: number): Promise { + return await this.recruitmentSessionRepository.findOne({where: {id} }); + } + + async deletRecruitmentSession(rSess: RecruitmentSession): Promise { + return await this.recruitmentSessionRepository.remove(rSess); + } + + async updateRecruitmentSession(rSess: RecruitmentSession): Promise { + return await this.recruitmentSessionRepository.save(rSess); + } +} \ No newline at end of file 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..7d2d222 --- /dev/null +++ b/api/src/recruitment-session/update-recruitment-session.dto.ts @@ -0,0 +1,23 @@ +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 }) + days?: [Date]; + + @ApiProperty({ required: false }) + lastModified?: Date; +} diff --git a/shared/src/abilities.ts b/shared/src/abilities.ts index 56a3e5d..8fa7e5b 100644 --- a/shared/src/abilities.ts +++ b/shared/src/abilities.ts @@ -9,6 +9,7 @@ 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"; export interface UserAuth { sub: string; @@ -26,8 +27,9 @@ 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]>; diff --git a/shared/src/recruitment-session.ts b/shared/src/recruitment-session.ts new file mode 100644 index 0000000..6ca8728 --- /dev/null +++ b/shared/src/recruitment-session.ts @@ -0,0 +1,82 @@ +import { AvailabilityState } from "availability"; +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(), + interviewStart: JoiDate.date().format("YYYY-MM-DD HH:mm").required(), + interviewEnd: JoiDate.date().format("YYYY-MM-DD HH:mm").required(), + // days: + 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(), + interviewStart: JoiDate.date().format("YYYY-MM-DD HH:mm").optional(), + interviewEnd: JoiDate.date().format("YYYY-MM-DD HH:mm").optional(), + // days: + // .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: // puo o non puo ?????? + case Role.Member: + case Role.Applicant: + can(Action.Read, "RecruitmentSession"); + break; + default: + cannot(Action.Manage, "RecruitmentSession"); + } +}; \ No newline at end of file From 97aa948220caa4c5340ba152ae914ba9ac4d47bb Mon Sep 17 00:00:00 2001 From: white Date: Thu, 28 Dec 2023 13:57:23 +0100 Subject: [PATCH 04/37] feat upd recruitment-session: service, controller, entity --- .../create-recruitment-session.dto.ts | 51 ++-- .../recruitment-session-response.dto.ts | 14 +- .../recruitment-session.controller.ts | 237 ++++++++++++------ .../recruitment-session.entity.ts | 16 +- .../recruitment-session.service.ts | 104 +++++--- .../update-recruitment-session.dto.ts | 10 +- shared/src/abilities.ts | 9 +- shared/src/recruitment-session.ts | 41 ++- 8 files changed, 308 insertions(+), 174 deletions(-) diff --git a/api/src/recruitment-session/create-recruitment-session.dto.ts b/api/src/recruitment-session/create-recruitment-session.dto.ts index 614ef24..09dedd1 100644 --- a/api/src/recruitment-session/create-recruitment-session.dto.ts +++ b/api/src/recruitment-session/create-recruitment-session.dto.ts @@ -1,25 +1,30 @@ -import { RecruitmentSession, RecruitmentSessionState } from "@hkrecruitment/shared/recruitment-session"; +import { + RecruitmentSession, + RecruitmentSessionState, +} from '@hkrecruitment/shared/recruitment-session'; import { ApiProperty } from '@nestjs/swagger'; -export class CreateRecruitmentSessionDto implements RecruitmentSession { - @ApiProperty() - state: RecruitmentSessionState; - - @ApiProperty() - slotDuration: number; - - @ApiProperty() - interviewStart: Date; - - @ApiProperty() - interviewEnd: Date; - - @ApiProperty() - days: [Date]; - - @ApiProperty() - createdAt: Date; - - @ApiProperty() - lastModified: Date; -} \ No newline at end of file +export class CreateRecruitmentSessionDto + implements Partial +{ + //@ApiProperty() + //state: RecruitmentSessionState; + + @ApiProperty() + slotDuration: number; + + @ApiProperty() + interviewStart: Date; + + @ApiProperty() + interviewEnd: Date; + + @ApiProperty() + days: [Date]; + + // @ApiProperty() + // createdAt: Date; + + // @ApiProperty() + // lastModified: Date; +} diff --git a/api/src/recruitment-session/recruitment-session-response.dto.ts b/api/src/recruitment-session/recruitment-session-response.dto.ts index d63a3fd..3ebee42 100644 --- a/api/src/recruitment-session/recruitment-session-response.dto.ts +++ b/api/src/recruitment-session/recruitment-session-response.dto.ts @@ -1,9 +1,13 @@ -import { RecruitmentSession, RecruitmentSessionState } from '@hkrecruitment/shared/recruitment-session'; +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; +export class RecruitmentSessionResponseDto + implements Partial +{ + @Expose() id: number; + @Expose() createdAt: Date; } - \ No newline at end of file diff --git a/api/src/recruitment-session/recruitment-session.controller.ts b/api/src/recruitment-session/recruitment-session.controller.ts index 22d46fd..d67693b 100644 --- a/api/src/recruitment-session/recruitment-session.controller.ts +++ b/api/src/recruitment-session/recruitment-session.controller.ts @@ -1,30 +1,35 @@ import { - Body, - Controller, - BadRequestException, - NotFoundException, - ConflictException, - Param, - Post, - Delete, - Req, - Patch, - ForbiddenException, - } from '@nestjs/common'; + Body, + Controller, + BadRequestException, + NotFoundException, + ConflictException, + Param, + Post, + Delete, + Req, + Patch, + ForbiddenException, +} 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 { + 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, + ApiBadRequestResponse, + ApiBearerAuth, + ApiForbiddenResponse, + ApiNotFoundResponse, + ApiCreatedResponse, + ApiOkResponse, + ApiTags, + ApiConflictResponse, + ApiNoContentResponse, } from '@nestjs/swagger'; import { CheckPolicies } from 'src/authorization/check-policies.decorator'; import { CreateRecruitmentSessionDto } from './create-recruitment-session.dto'; @@ -39,64 +44,138 @@ import { RecruitmentSessionResponseDto } from './recruitment-session-response.dt @ApiTags('recruitment-session') @Controller('recruitment-session') export class RecruitmentSessionController { - constructor(private readonly recruitmentSessionService: RecruitmentSessionService) {} - - // 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() rSess: CreateRecruitmentSessionDto): Promise { - return this.recruitmentSessionService.createRecruitmentSession({...rSess}); - } + constructor( + private readonly recruitmentSessionService: RecruitmentSessionService, + ) {} - // UPDATE A RECRUITMENT SESSION - @Patch(':session_id') - @ApiBadRequestResponse() - @ApiForbiddenResponse() - @ApiOkResponse() - @JoiValidate({ - param: Joi.number().positive().integer().required().label('session_id'), - body: updateRecruitmentSessionSchema, - }) - async updateRecruitmentSession( - @Param('session_id') sessionId: number, - @Body() updateRecruitmentSession: UpdateRecruitmentSessionDto, - @Ability() ability: AppAbility, - @Req() req: AuthenticatedRequest, - ): Promise { - const session = await this.recruitmentSessionService.findRecruitmentSessionById(sessionId); - - if (session === null) throw new NotFoundException(); - - const sessionToCheck = { - ...updateRecruitmentSession, - sessionId: session.id, - }; - if ( - !checkAbility(ability, Action.Update, sessionToCheck, 'RecruitmentSession', [ - 'applicantId', - ]) + // 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() + @ApiOkResponse() + @JoiValidate({ + param: Joi.number().positive().integer().required().label('session_id'), + body: updateRecruitmentSessionSchema, + }) + async updateRecruitmentSession( + @Param('session_id') sessionId: number, + @Body() updateRecruitmentSession: UpdateRecruitmentSessionDto, + @Ability() ability: AppAbility, + @Req() req: AuthenticatedRequest, + ): 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', + ['applicantId'], ) - throw new ForbiddenException(); - - const updatedRecruitmentSession = await this.recruitmentSessionService.updateRecruitmentSession( - { - ...session, - ...updateRecruitmentSession, - lastModified: new Date(), - }, + ) + throw new ForbiddenException(); + + const updatedRecruitmentSession = + await this.recruitmentSessionService.updateRecruitmentSession({ + ...recruitmentSession, + ...updateRecruitmentSession, + lastModified: new Date(), + }); + + return plainToClass( + RecruitmentSessionResponseDto, + updatedRecruitmentSession, + ); + } + + // DELETE A RECRUITMENT SESSION BY START & END DATE + @ApiBadRequestResponse() + @ApiForbiddenResponse() + @ApiNotFoundResponse() + @ApiOkResponse() + @ApiNoContentResponse() + @CheckPolicies((ability) => ability.can(Action.Delete, 'RecruitmentSession')) + // @Delete('/:time_slot_id') + // @JoiValidate({ + // param: Joi.number().positive().integer().required().label('time_slot_id'), + // }) + async deleteTimeSlotByStartEnd( + @Param('start') startDate: Date, + @Param('end') endDate: Date, + ): Promise { + const toRemove = + await this.recruitmentSessionService.findRecruitmentSessionByStartEndDate( + startDate, + endDate, ); - - return plainToClass(RecruitmentSessionResponseDto, updatedRecruitmentSession); - } + if (!toRemove) throw new NotFoundException('Recruitment session not found'); + return await this.recruitmentSessionService.deletRecruitmentSession( + toRemove, + ); + } + // DELETE A RECRUITMENT SESSION + @ApiBadRequestResponse() + @ApiForbiddenResponse() + @ApiNotFoundResponse() + @ApiOkResponse() + @ApiNoContentResponse() + @CheckPolicies((ability) => ability.can(Action.Delete, 'RecruitmentSession')) + @Delete('/:recruitment_session_id') + @JoiValidate({ + param: Joi.number().positive().integer().required().label('time_slot_id'), + }) + async deleteTimeSlotById( + @Param('recruitment_session_id') recruitmentSessionId: number, + ): Promise { + // check if it exists + const toRemove = + await this.recruitmentSessionService.findRecruitmentSessionById( + recruitmentSessionId, + ); + if (!toRemove) throw new NotFoundException('Recruitment session not found'); -} \ No newline at end of file + // delete it + return await this.recruitmentSessionService.deletRecruitmentSession( + toRemove, + ); + } +} diff --git a/api/src/recruitment-session/recruitment-session.entity.ts b/api/src/recruitment-session/recruitment-session.entity.ts index 73f6d04..26f9d75 100644 --- a/api/src/recruitment-session/recruitment-session.entity.ts +++ b/api/src/recruitment-session/recruitment-session.entity.ts @@ -1,6 +1,8 @@ import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; -import { RecruitmentSession as RecruitmentSessionInterface, RecruitmentSessionState } from '@hkrecruitment/shared/src/recruitment-session' - +import { + RecruitmentSession as RecruitmentSessionInterface, + RecruitmentSessionState, +} from '@hkrecruitment/shared/src/recruitment-session'; @Entity() export class RecruitmentSession implements RecruitmentSessionInterface { @@ -10,21 +12,21 @@ export class RecruitmentSession implements RecruitmentSessionInterface { @Column() state: RecruitmentSessionState; - @Column() + @Column({ name: 'slot_duration' }) slotDuration: number; - @Column() + @Column({ name: 'interview_start' }) interviewStart: Date; - @Column() + @Column({ name: 'interview_end' }) interviewEnd: Date; @Column() days: [Date]; - @Column() + @Column({ name: 'created_at' }) createdAt: Date; - @Column() + @Column({ name: 'last_modified' }) lastModified: Date; } diff --git a/api/src/recruitment-session/recruitment-session.service.ts b/api/src/recruitment-session/recruitment-session.service.ts index ec46aca..87021b0 100644 --- a/api/src/recruitment-session/recruitment-session.service.ts +++ b/api/src/recruitment-session/recruitment-session.service.ts @@ -1,34 +1,76 @@ -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 { 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(rSess: CreateRecruitmentSessionDto): Promise { - return await this.recruitmentSessionRepository.save(rSess); - } - - async findAllRecruitmentSessions(): Promise { - return await this.recruitmentSessionRepository.find(); - } - - async findRecruitmentSessionById(id: number): Promise { - return await this.recruitmentSessionRepository.findOne({where: {id} }); - } - - async deletRecruitmentSession(rSess: RecruitmentSession): Promise { - return await this.recruitmentSessionRepository.remove(rSess); - } - - async updateRecruitmentSession(rSess: RecruitmentSession): Promise { - return await this.recruitmentSessionRepository.save(rSess); - } -} \ No newline at end of file + constructor( + @InjectRepository(RecruitmentSession) + private readonly recruitmentSessionRepository: Repository, + ) {} + + async createRecruitmentSession( + recruitmentSession: CreateRecruitmentSessionDto, + ): Promise { + let rs = await this.recruitmentSessionRepository.save(recruitmentSession); + rs.state = RecruitmentSessionState.Active; + let now = new Date(); + rs.createdAt = now; + rs.lastModified = now; + 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 findRecruitmentSessionByStartEndDate( + start: Date, + end: Date, + ): Promise { + return await this.recruitmentSessionRepository.findOne({ + where: { interviewStart: start, interviewEnd: end }, + }); + } + + async deletRecruitmentSession( + recruitmentSession: RecruitmentSession, + ): Promise { + // let toRemove = await this.findRecruitmentSessionByStartEndDate(start, end); + return await this.recruitmentSessionRepository.remove(recruitmentSession); + } + + async updateRecruitmentSession( + recruitmentSession: RecruitmentSession, + ): Promise { + return await this.recruitmentSessionRepository.save(recruitmentSession); + } + + // udpate state active with respect to current date + async updateAllStates() { + let now: number = new Date().getTime(); + let list: RecruitmentSession[] = + await this.recruitmentSessionRepository.find({ + where: { state: RecruitmentSessionState.Active }, + }); + list.forEach((recruitmentSession) => { + if (now >= recruitmentSession.interviewEnd.getTime()) { + recruitmentSession.state = RecruitmentSessionState.Concluded; + this.updateRecruitmentSession(recruitmentSession); + } + }); + } +} diff --git a/api/src/recruitment-session/update-recruitment-session.dto.ts b/api/src/recruitment-session/update-recruitment-session.dto.ts index 7d2d222..d0ea4a5 100644 --- a/api/src/recruitment-session/update-recruitment-session.dto.ts +++ b/api/src/recruitment-session/update-recruitment-session.dto.ts @@ -1,8 +1,12 @@ -import { RecruitmentSession, RecruitmentSessionState } from '@hkrecruitment/shared/recruitment-session'; +import { + RecruitmentSession, + RecruitmentSessionState, +} from '@hkrecruitment/shared/recruitment-session'; import { ApiProperty } from '@nestjs/swagger'; - -export class UpdateRecruitmentSessionDto implements Partial { +export class UpdateRecruitmentSessionDto + implements Partial +{ @ApiProperty({ required: false }) state?: RecruitmentSessionState; diff --git a/shared/src/abilities.ts b/shared/src/abilities.ts index 8fa7e5b..f12e327 100644 --- a/shared/src/abilities.ts +++ b/shared/src/abilities.ts @@ -10,6 +10,7 @@ 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; @@ -29,7 +30,12 @@ type SubjectsTypes = | Partial | Partial | Partial; -type SubjectNames = "Person" | "Application" | "Availability" | "TimeSlot" | "RecruitmentSession"; +type SubjectNames = + | "Person" + | "Application" + | "Availability" + | "TimeSlot" + | "RecruitmentSession"; export type Subjects = SubjectsTypes | SubjectNames; export type AppAbility = PureAbility<[Action, Subjects]>; @@ -46,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/recruitment-session.ts b/shared/src/recruitment-session.ts index 6ca8728..7b0fccb 100644 --- a/shared/src/recruitment-session.ts +++ b/shared/src/recruitment-session.ts @@ -1,4 +1,3 @@ -import { AvailabilityState } from "availability"; import { Action, ApplyAbilities } from "./abilities"; import { Role } from "./person"; import DateExtension from "@joi/date"; @@ -6,13 +5,11 @@ import * as Joi from "joi"; const JoiDate = Joi.extend(DateExtension); - export enum RecruitmentSessionState { Active = "active", Concluded = "concluded", } - // import BaseJoi from "joi"; export interface RecruitmentSession { @@ -27,38 +24,30 @@ export interface RecruitmentSession { /* Validation schemas */ -export const createRecruitmentSessionSchema = Joi.object ({ - state: Joi.string() - .valid("active", "concluded") - .required(), - slotDuration: Joi.number() - .integer(), - interviewStart: JoiDate.date().format("YYYY-MM-DD HH:mm").required(), - interviewEnd: JoiDate.date().format("YYYY-MM-DD HH:mm").required(), - // days: - lastModified: JoiDate.date().format("YYYY-MM-DD HH:mm").required() +export const createRecruitmentSessionSchema = Joi.object({ + state: Joi.string().valid("active", "concluded").required(), + slotDuration: Joi.number().integer(), + interviewStart: JoiDate.date().format("HH:mm").required(), + interviewEnd: JoiDate.date().format("HH:mm").required(), + // days: + 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(), +export const updateRecruitmentSessionSchema = Joi.object({ + state: Joi.string().valid("active", "concluded").optional(), + slotDuration: Joi.number().integer(), interviewStart: JoiDate.date().format("YYYY-MM-DD HH:mm").optional(), interviewEnd: JoiDate.date().format("YYYY-MM-DD HH:mm").optional(), // days: // .optional(), createdAt: JoiDate.date().format("YYYY-MM-DD HH:mm").optional(), - lastModified: JoiDate.date().format("YYYY-MM-DD HH:mm").optional() + lastModified: JoiDate.date().format("YYYY-MM-DD HH:mm").optional(), }); - - /* Abilities */ export const applyAbilitiesOnRecruitmentSession: ApplyAbilities = ( @@ -71,12 +60,14 @@ export const applyAbilitiesOnRecruitmentSession: ApplyAbilities = ( case Role.Supervisor: can(Action.Manage, "RecruitmentSession"); break; - case Role.Clerk: // puo o non puo ?????? + case Role.Clerk: case Role.Member: - case Role.Applicant: can(Action.Read, "RecruitmentSession"); break; + case Role.Applicant: + cannot(Action.Manage, "RecruitmentSession"); + break; default: cannot(Action.Manage, "RecruitmentSession"); } -}; \ No newline at end of file +}; From 7f1e3d8a20dc6a4fc03155ab0c202cd3863f7a9f Mon Sep 17 00:00:00 2001 From: white Date: Thu, 28 Dec 2023 19:49:14 +0100 Subject: [PATCH 05/37] feat upd recruitment-session: service, controller, entity --- .../recruitment-session.controller.ts | 26 -------------- .../recruitment-session.service.ts | 36 ++++--------------- 2 files changed, 7 insertions(+), 55 deletions(-) diff --git a/api/src/recruitment-session/recruitment-session.controller.ts b/api/src/recruitment-session/recruitment-session.controller.ts index d67693b..ab1f54f 100644 --- a/api/src/recruitment-session/recruitment-session.controller.ts +++ b/api/src/recruitment-session/recruitment-session.controller.ts @@ -126,32 +126,6 @@ export class RecruitmentSessionController { ); } - // DELETE A RECRUITMENT SESSION BY START & END DATE - @ApiBadRequestResponse() - @ApiForbiddenResponse() - @ApiNotFoundResponse() - @ApiOkResponse() - @ApiNoContentResponse() - @CheckPolicies((ability) => ability.can(Action.Delete, 'RecruitmentSession')) - // @Delete('/:time_slot_id') - // @JoiValidate({ - // param: Joi.number().positive().integer().required().label('time_slot_id'), - // }) - async deleteTimeSlotByStartEnd( - @Param('start') startDate: Date, - @Param('end') endDate: Date, - ): Promise { - const toRemove = - await this.recruitmentSessionService.findRecruitmentSessionByStartEndDate( - startDate, - endDate, - ); - if (!toRemove) throw new NotFoundException('Recruitment session not found'); - return await this.recruitmentSessionService.deletRecruitmentSession( - toRemove, - ); - } - // DELETE A RECRUITMENT SESSION @ApiBadRequestResponse() @ApiForbiddenResponse() diff --git a/api/src/recruitment-session/recruitment-session.service.ts b/api/src/recruitment-session/recruitment-session.service.ts index 87021b0..4a8be9a 100644 --- a/api/src/recruitment-session/recruitment-session.service.ts +++ b/api/src/recruitment-session/recruitment-session.service.ts @@ -15,11 +15,14 @@ export class RecruitmentSessionService { async createRecruitmentSession( recruitmentSession: CreateRecruitmentSessionDto, ): Promise { - let rs = await this.recruitmentSessionRepository.save(recruitmentSession); - rs.state = RecruitmentSessionState.Active; let now = new Date(); - rs.createdAt = now; - rs.lastModified = now; + const rs = { + ...recruitmentSession, + state: RecruitmentSessionState.Active, + createdAt: now, + lastModified: now, + } as RecruitmentSession; + await this.recruitmentSessionRepository.save(rs); return rs; } @@ -37,19 +40,9 @@ export class RecruitmentSessionService { }); } - async findRecruitmentSessionByStartEndDate( - start: Date, - end: Date, - ): Promise { - return await this.recruitmentSessionRepository.findOne({ - where: { interviewStart: start, interviewEnd: end }, - }); - } - async deletRecruitmentSession( recruitmentSession: RecruitmentSession, ): Promise { - // let toRemove = await this.findRecruitmentSessionByStartEndDate(start, end); return await this.recruitmentSessionRepository.remove(recruitmentSession); } @@ -58,19 +51,4 @@ export class RecruitmentSessionService { ): Promise { return await this.recruitmentSessionRepository.save(recruitmentSession); } - - // udpate state active with respect to current date - async updateAllStates() { - let now: number = new Date().getTime(); - let list: RecruitmentSession[] = - await this.recruitmentSessionRepository.find({ - where: { state: RecruitmentSessionState.Active }, - }); - list.forEach((recruitmentSession) => { - if (now >= recruitmentSession.interviewEnd.getTime()) { - recruitmentSession.state = RecruitmentSessionState.Concluded; - this.updateRecruitmentSession(recruitmentSession); - } - }); - } } From f3aedbf048c5a9460b2735b2ac94893e669d10c0 Mon Sep 17 00:00:00 2001 From: white Date: Sun, 7 Jan 2024 01:01:16 +0100 Subject: [PATCH 06/37] upd dependencies in shared/abilities --- shared/src/abilities.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/src/abilities.ts b/shared/src/abilities.ts index f12e327..23fd5bf 100644 --- a/shared/src/abilities.ts +++ b/shared/src/abilities.ts @@ -9,8 +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"; +import { RecruitmentSession } from "./recruitment-session"; +import { applyAbilitiesOnRecruitmentSession } from "./recruitment-session"; export interface UserAuth { sub: string; From 70964e7afcc5bed14bea0cf992398c2eb4660fc6 Mon Sep 17 00:00:00 2001 From: white Date: Sun, 7 Jan 2024 01:32:17 +0100 Subject: [PATCH 07/37] mock --- shared/src/recruitment-session.spec.ts | 28 ++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 shared/src/recruitment-session.spec.ts diff --git a/shared/src/recruitment-session.spec.ts b/shared/src/recruitment-session.spec.ts new file mode 100644 index 0000000..3aad3d1 --- /dev/null +++ b/shared/src/recruitment-session.spec.ts @@ -0,0 +1,28 @@ +import { + RecruitmentSession, + RecruitmentSessionState, + createRecruitmentSessionSchema, + applyAbilitiesOnRecruitmentSession +} from "./recruitment-session"; +import { createMockAbility } from "./abilities.spec"; +import { Action, UserAuth, checkAbility } from "./abilities"; + +describe("RecruitmentSession", () => { + describe("createRecruitmentSessionSchema", () => { + const mockRecSess: Partial = { + state: RecruitmentSessionState.Active, + slotDuration: 5, + interviewStart: new Date("15:20"), + interviewEnd: new Date("16:30"), + lastModified: new Date("2000-10-20 15:10") + }; + + it("prova", () => { + expect(createRecruitmentSessionSchema.validate(mockRecSess)).not.toHaveProperty("erroe"); + }) + + + }) + + +}) \ No newline at end of file From 0f046a7901aa853f04e510165d9ef8eb7d8b53ed Mon Sep 17 00:00:00 2001 From: white Date: Sun, 7 Jan 2024 01:34:32 +0100 Subject: [PATCH 08/37] mock formatted --- shared/src/recruitment-session.spec.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/shared/src/recruitment-session.spec.ts b/shared/src/recruitment-session.spec.ts index 3aad3d1..892de5a 100644 --- a/shared/src/recruitment-session.spec.ts +++ b/shared/src/recruitment-session.spec.ts @@ -1,8 +1,8 @@ import { - RecruitmentSession, - RecruitmentSessionState, - createRecruitmentSessionSchema, - applyAbilitiesOnRecruitmentSession + RecruitmentSession, + RecruitmentSessionState, + createRecruitmentSessionSchema, + applyAbilitiesOnRecruitmentSession, } from "./recruitment-session"; import { createMockAbility } from "./abilities.spec"; import { Action, UserAuth, checkAbility } from "./abilities"; @@ -14,15 +14,13 @@ describe("RecruitmentSession", () => { slotDuration: 5, interviewStart: new Date("15:20"), interviewEnd: new Date("16:30"), - lastModified: new Date("2000-10-20 15:10") + lastModified: new Date("2000-10-20 15:10"), }; it("prova", () => { - expect(createRecruitmentSessionSchema.validate(mockRecSess)).not.toHaveProperty("erroe"); - }) - - - }) - - -}) \ No newline at end of file + expect( + createRecruitmentSessionSchema.validate(mockRecSess) + ).not.toHaveProperty("erroe"); + }); + }); +}); From 37c060e61233892450fc1d90dd631d1df5b3353f Mon Sep 17 00:00:00 2001 From: white Date: Fri, 12 Jan 2024 23:06:03 +0100 Subject: [PATCH 09/37] upd date array --- api/src/recruitment-session/recruitment-session.entity.ts | 4 ++-- api/src/recruitment-session/recruitment-session.service.ts | 2 +- shared/src/recruitment-session.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/api/src/recruitment-session/recruitment-session.entity.ts b/api/src/recruitment-session/recruitment-session.entity.ts index 26f9d75..3e10a39 100644 --- a/api/src/recruitment-session/recruitment-session.entity.ts +++ b/api/src/recruitment-session/recruitment-session.entity.ts @@ -21,8 +21,8 @@ export class RecruitmentSession implements RecruitmentSessionInterface { @Column({ name: 'interview_end' }) interviewEnd: Date; - @Column() - days: [Date]; + @Column({array:true}) + days: Date[]; @Column({ name: 'created_at' }) createdAt: Date; diff --git a/api/src/recruitment-session/recruitment-session.service.ts b/api/src/recruitment-session/recruitment-session.service.ts index 4a8be9a..4d5210b 100644 --- a/api/src/recruitment-session/recruitment-session.service.ts +++ b/api/src/recruitment-session/recruitment-session.service.ts @@ -21,7 +21,7 @@ export class RecruitmentSessionService { state: RecruitmentSessionState.Active, createdAt: now, lastModified: now, - } as RecruitmentSession; + } as unknown as RecruitmentSession; await this.recruitmentSessionRepository.save(rs); return rs; } diff --git a/shared/src/recruitment-session.ts b/shared/src/recruitment-session.ts index 7b0fccb..7a6fef9 100644 --- a/shared/src/recruitment-session.ts +++ b/shared/src/recruitment-session.ts @@ -17,7 +17,7 @@ export interface RecruitmentSession { slotDuration: number; interviewStart: Date; interviewEnd: Date; - days: [Date]; + days: Date[]; createdAt: Date; lastModified: Date; } From 0e21b0e3a0084a6cf1c77f886ec5a6cf9e6672ee Mon Sep 17 00:00:00 2001 From: white Date: Fri, 12 Jan 2024 23:08:02 +0100 Subject: [PATCH 10/37] upd date array, format --- api/src/recruitment-session/recruitment-session.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/recruitment-session/recruitment-session.entity.ts b/api/src/recruitment-session/recruitment-session.entity.ts index 3e10a39..fec8ed5 100644 --- a/api/src/recruitment-session/recruitment-session.entity.ts +++ b/api/src/recruitment-session/recruitment-session.entity.ts @@ -21,7 +21,7 @@ export class RecruitmentSession implements RecruitmentSessionInterface { @Column({ name: 'interview_end' }) interviewEnd: Date; - @Column({array:true}) + @Column({ array: true }) days: Date[]; @Column({ name: 'created_at' }) From e7bf426798aec9647ca2c4b75825960ea4f81f40 Mon Sep 17 00:00:00 2001 From: white Date: Fri, 12 Jan 2024 23:14:13 +0100 Subject: [PATCH 11/37] upd date array2 --- api/src/recruitment-session/recruitment-session.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/recruitment-session/recruitment-session.entity.ts b/api/src/recruitment-session/recruitment-session.entity.ts index fec8ed5..7a4a953 100644 --- a/api/src/recruitment-session/recruitment-session.entity.ts +++ b/api/src/recruitment-session/recruitment-session.entity.ts @@ -21,7 +21,7 @@ export class RecruitmentSession implements RecruitmentSessionInterface { @Column({ name: 'interview_end' }) interviewEnd: Date; - @Column({ array: true }) + @Column('date', { array: true }) days: Date[]; @Column({ name: 'created_at' }) From 0fddaee3f0af499c5171a330d15799f507fbe048 Mon Sep 17 00:00:00 2001 From: white Date: Fri, 12 Jan 2024 23:20:21 +0100 Subject: [PATCH 12/37] upd date array3 --- .../recruitment-session/recruitment-session.entity.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/api/src/recruitment-session/recruitment-session.entity.ts b/api/src/recruitment-session/recruitment-session.entity.ts index 7a4a953..ed755d4 100644 --- a/api/src/recruitment-session/recruitment-session.entity.ts +++ b/api/src/recruitment-session/recruitment-session.entity.ts @@ -12,21 +12,21 @@ export class RecruitmentSession implements RecruitmentSessionInterface { @Column() state: RecruitmentSessionState; - @Column({ name: 'slot_duration' }) + @Column('int', { name: 'slot_duration' }) slotDuration: number; - @Column({ name: 'interview_start' }) + @Column('date', { name: 'interview_start' }) interviewStart: Date; - @Column({ name: 'interview_end' }) + @Column('date', { name: 'interview_end' }) interviewEnd: Date; @Column('date', { array: true }) days: Date[]; - @Column({ name: 'created_at' }) + @Column('date', { name: 'created_at' }) createdAt: Date; - @Column({ name: 'last_modified' }) + @Column('date', { name: 'last_modified' }) lastModified: Date; } From d8daaccad68ec9419ac46dbc6a43ac1003c64332 Mon Sep 17 00:00:00 2001 From: white Date: Fri, 12 Jan 2024 23:36:58 +0100 Subject: [PATCH 13/37] upd date array4 --- shared/src/recruitment-session.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/shared/src/recruitment-session.ts b/shared/src/recruitment-session.ts index 7a6fef9..23c080b 100644 --- a/shared/src/recruitment-session.ts +++ b/shared/src/recruitment-session.ts @@ -29,7 +29,7 @@ export const createRecruitmentSessionSchema = Joi.object({ slotDuration: Joi.number().integer(), interviewStart: JoiDate.date().format("HH:mm").required(), interviewEnd: JoiDate.date().format("HH:mm").required(), - // days: + days: Joi.array().items(JoiDate.format("YYYY-MM-DD")), lastModified: JoiDate.date().format("YYYY-MM-DD HH:mm").required(), }).options({ stripUnknown: true, @@ -42,8 +42,7 @@ export const updateRecruitmentSessionSchema = Joi.object({ slotDuration: Joi.number().integer(), interviewStart: JoiDate.date().format("YYYY-MM-DD HH:mm").optional(), interviewEnd: JoiDate.date().format("YYYY-MM-DD HH:mm").optional(), - // days: - // .optional(), + days: Joi.array().items(JoiDate.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(), }); From 39b010c14c7dd5694fb97464161ebb9d7c7c6463 Mon Sep 17 00:00:00 2001 From: white Date: Fri, 12 Jan 2024 23:41:03 +0100 Subject: [PATCH 14/37] upd date array5 --- shared/src/recruitment-session.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/src/recruitment-session.ts b/shared/src/recruitment-session.ts index 23c080b..06ee473 100644 --- a/shared/src/recruitment-session.ts +++ b/shared/src/recruitment-session.ts @@ -29,7 +29,7 @@ export const createRecruitmentSessionSchema = Joi.object({ slotDuration: Joi.number().integer(), interviewStart: JoiDate.date().format("HH:mm").required(), interviewEnd: JoiDate.date().format("HH:mm").required(), - days: Joi.array().items(JoiDate.format("YYYY-MM-DD")), + days: Joi.array().items(JoiDate.date().format("YYYY-MM-DD")), lastModified: JoiDate.date().format("YYYY-MM-DD HH:mm").required(), }).options({ stripUnknown: true, @@ -42,7 +42,7 @@ export const updateRecruitmentSessionSchema = Joi.object({ slotDuration: Joi.number().integer(), 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.format("YYYY-MM-DD")).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(), }); From 3600b0b7b594b0fd8db404da43a9acf27a91b686 Mon Sep 17 00:00:00 2001 From: white Date: Sat, 13 Jan 2024 01:29:55 +0100 Subject: [PATCH 15/37] mock shared -> required/optional fields --- shared/src/recruitment-session.spec.ts | 56 +++++++++++++++++++++++++- shared/src/recruitment-session.ts | 6 +-- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/shared/src/recruitment-session.spec.ts b/shared/src/recruitment-session.spec.ts index 892de5a..4c551b9 100644 --- a/shared/src/recruitment-session.spec.ts +++ b/shared/src/recruitment-session.spec.ts @@ -14,13 +14,65 @@ describe("RecruitmentSession", () => { slotDuration: 5, interviewStart: new Date("15:20"), interviewEnd: new Date("16:30"), - lastModified: new Date("2000-10-20 15:10"), + days: [new Date("2024-12-23"), new Date("2024-12-23")], + lastModified: new Date("2023-10-20 15:10"), }; - it("prova", () => { + it("should allow a valid recruitment session", () => { expect( createRecruitmentSessionSchema.validate(mockRecSess) ).not.toHaveProperty("erroe"); }); + + 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("CONTROLLA", () => { + const session: Partial = { + ...mockRecSess, + lastModified: undefined, + }; + const { error } = createRecruitmentSessionSchema.validate(session); + expect(error).toBeDefined(); + expect(error.message).toMatch(/.+lastModified.+ is required/); + }); }); }); diff --git a/shared/src/recruitment-session.ts b/shared/src/recruitment-session.ts index 06ee473..a232e18 100644 --- a/shared/src/recruitment-session.ts +++ b/shared/src/recruitment-session.ts @@ -26,10 +26,10 @@ export interface RecruitmentSession { export const createRecruitmentSessionSchema = Joi.object({ state: Joi.string().valid("active", "concluded").required(), - slotDuration: Joi.number().integer(), + 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")), + days: Joi.array().items(JoiDate.date().format("YYYY-MM-DD")).optional(), lastModified: JoiDate.date().format("YYYY-MM-DD HH:mm").required(), }).options({ stripUnknown: true, @@ -39,7 +39,7 @@ export const createRecruitmentSessionSchema = Joi.object({ export const updateRecruitmentSessionSchema = Joi.object({ state: Joi.string().valid("active", "concluded").optional(), - slotDuration: Joi.number().integer(), + 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(), From 4470ea87878e315b41aea1a438af5522254a36d5 Mon Sep 17 00:00:00 2001 From: white Date: Sat, 13 Jan 2024 01:46:39 +0100 Subject: [PATCH 16/37] mock shared -> required/optional fields 2 --- shared/src/recruitment-session.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shared/src/recruitment-session.spec.ts b/shared/src/recruitment-session.spec.ts index 4c551b9..dfb0555 100644 --- a/shared/src/recruitment-session.spec.ts +++ b/shared/src/recruitment-session.spec.ts @@ -21,7 +21,7 @@ describe("RecruitmentSession", () => { it("should allow a valid recruitment session", () => { expect( createRecruitmentSessionSchema.validate(mockRecSess) - ).not.toHaveProperty("erroe"); + ).not.toHaveProperty("error"); }); it("should allow to not set optional fields", () => { @@ -65,7 +65,7 @@ describe("RecruitmentSession", () => { expect(error.message).toMatch(/.+interviewEnd.+ is required/); }); - it("CONTROLLA", () => { + it("should require last modified", () => { const session: Partial = { ...mockRecSess, lastModified: undefined, From dffa94f97c969cef4980417c169006c0b0a4fdc3 Mon Sep 17 00:00:00 2001 From: white Date: Sun, 14 Jan 2024 00:04:41 +0100 Subject: [PATCH 17/37] upd date string --- shared/src/recruitment-session.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/src/recruitment-session.spec.ts b/shared/src/recruitment-session.spec.ts index dfb0555..f97d129 100644 --- a/shared/src/recruitment-session.spec.ts +++ b/shared/src/recruitment-session.spec.ts @@ -12,7 +12,7 @@ describe("RecruitmentSession", () => { const mockRecSess: Partial = { state: RecruitmentSessionState.Active, slotDuration: 5, - interviewStart: new Date("15:20"), + interviewStart: "15:20" as unknown as Date, interviewEnd: new Date("16:30"), days: [new Date("2024-12-23"), new Date("2024-12-23")], lastModified: new Date("2023-10-20 15:10"), From c314bd97e0092cdc96360c7094eb5392da7b2770 Mon Sep 17 00:00:00 2001 From: white Date: Sun, 14 Jan 2024 00:07:51 +0100 Subject: [PATCH 18/37] upd date string2 --- shared/src/recruitment-session.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/src/recruitment-session.spec.ts b/shared/src/recruitment-session.spec.ts index f97d129..1c8fcea 100644 --- a/shared/src/recruitment-session.spec.ts +++ b/shared/src/recruitment-session.spec.ts @@ -13,7 +13,7 @@ describe("RecruitmentSession", () => { state: RecruitmentSessionState.Active, slotDuration: 5, interviewStart: "15:20" as unknown as Date, - interviewEnd: new Date("16:30"), + 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"), }; From dfe14332c76ead59bb669476f2b683aa422ce98e Mon Sep 17 00:00:00 2001 From: white Date: Sun, 14 Jan 2024 00:12:59 +0100 Subject: [PATCH 19/37] upd date string3 --- api/src/recruitment-session/recruitment-session.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/recruitment-session/recruitment-session.controller.ts b/api/src/recruitment-session/recruitment-session.controller.ts index ab1f54f..a967ad3 100644 --- a/api/src/recruitment-session/recruitment-session.controller.ts +++ b/api/src/recruitment-session/recruitment-session.controller.ts @@ -137,7 +137,7 @@ export class RecruitmentSessionController { @JoiValidate({ param: Joi.number().positive().integer().required().label('time_slot_id'), }) - async deleteTimeSlotById( + async deleteRecruitmentSessionById( @Param('recruitment_session_id') recruitmentSessionId: number, ): Promise { // check if it exists From d4bf3b9285ced83c297948430af0e3e278c3dde6 Mon Sep 17 00:00:00 2001 From: white Date: Sun, 14 Jan 2024 00:23:56 +0100 Subject: [PATCH 20/37] upd date string check --- shared/src/recruitment-session.spec.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/shared/src/recruitment-session.spec.ts b/shared/src/recruitment-session.spec.ts index 1c8fcea..e86cf48 100644 --- a/shared/src/recruitment-session.spec.ts +++ b/shared/src/recruitment-session.spec.ts @@ -6,13 +6,14 @@ import { } from "./recruitment-session"; import { createMockAbility } from "./abilities.spec"; import { Action, UserAuth, checkAbility } from "./abilities"; +import { expression } from "joi"; -describe("RecruitmentSession", () => { +describe("Recruitment Session", () => { describe("createRecruitmentSessionSchema", () => { const mockRecSess: Partial = { state: RecruitmentSessionState.Active, slotDuration: 5, - interviewStart: "15:20" as unknown as Date, + 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"), @@ -74,5 +75,9 @@ describe("RecruitmentSession", () => { 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"); + }); }); }); From c2d69bdec325d1ee018e98aba0205f31b13dad8f Mon Sep 17 00:00:00 2001 From: white Date: Sun, 14 Jan 2024 01:35:24 +0100 Subject: [PATCH 21/37] mock recruitment session service, insert data mock --- api/src/mocks/data.ts | 20 ++++++++++++++++ .../recruitment-session.controller.ts | 24 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/api/src/mocks/data.ts b/api/src/mocks/data.ts index f31aa3e..61b8b02 100644 --- a/api/src/mocks/data.ts +++ b/api/src/mocks/data.ts @@ -11,6 +11,7 @@ import { PhdApplication, } from 'src/application/application.entity'; import { UpdateApplicationDto } from 'src/application/update-application.dto'; +import { RecruitmentSessionState } from '@hkrecruitment/shared/recruitment-session'; export const testDate = new Date(2023, 0, 1); export const testDateTimeStart = new Date(2023, 0, 1, 10, 30, 0); @@ -24,6 +25,25 @@ 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 baseFile = { encoding: '7bit', mimetype: 'application/pdf', diff --git a/api/src/recruitment-session/recruitment-session.controller.ts b/api/src/recruitment-session/recruitment-session.controller.ts index a967ad3..0edc0f2 100644 --- a/api/src/recruitment-session/recruitment-session.controller.ts +++ b/api/src/recruitment-session/recruitment-session.controller.ts @@ -10,6 +10,7 @@ import { Req, Patch, ForbiddenException, + Get, } from '@nestjs/common'; import { RecruitmentSessionService } from './recruitment-session.service'; import { @@ -30,6 +31,7 @@ import { ApiTags, ApiConflictResponse, ApiNoContentResponse, + ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { CheckPolicies } from 'src/authorization/check-policies.decorator'; import { CreateRecruitmentSessionDto } from './create-recruitment-session.dto'; @@ -39,6 +41,7 @@ import { Ability } from 'src/authorization/ability.decorator'; import { AuthenticatedRequest } from 'src/authorization/authenticated-request.types'; import { plainToClass } from 'class-transformer'; import { RecruitmentSessionResponseDto } from './recruitment-session-response.dto'; +import { User } from 'src/users/user.entity'; @ApiBearerAuth() @ApiTags('recruitment-session') @@ -48,6 +51,27 @@ export class RecruitmentSessionController { 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, 'Person')) { + throw new ForbiddenException(); + } + + return recruitmentSession; + } + // CREATE NEW RECRUITMENT SESSION @ApiBadRequestResponse() @ApiForbiddenResponse() From 2c7771470662ca89f69274fd951ebf004860f945 Mon Sep 17 00:00:00 2001 From: white Date: Sun, 14 Jan 2024 01:53:03 +0100 Subject: [PATCH 22/37] mock service --- .../recruitment-session.service.spec.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 api/src/recruitment-session/recruitment-session.service.spec.ts 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..f6657f3 --- /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', () => { + jest + .spyOn(mockedRepository, 'remove') + .mockResolvedValue(mockRecruitmentSession); + const result = recruitmentSessionService.deletRecruitmentSession( + mockRecruitmentSession, + ); + expect(result).toEqual([mockRecruitmentSession]); + expect(mockedRepository.find).toHaveBeenCalledTimes(1); + }); + }); +}); From 09e737b2f07c959a816144dcb70f1c8c96d6546b Mon Sep 17 00:00:00 2001 From: white Date: Sun, 14 Jan 2024 02:03:17 +0100 Subject: [PATCH 23/37] mock update --- api/src/recruitment-session/recruitment-session.entity.ts | 2 +- .../recruitment-session/recruitment-session.service.spec.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/api/src/recruitment-session/recruitment-session.entity.ts b/api/src/recruitment-session/recruitment-session.entity.ts index ed755d4..2f1048d 100644 --- a/api/src/recruitment-session/recruitment-session.entity.ts +++ b/api/src/recruitment-session/recruitment-session.entity.ts @@ -9,7 +9,7 @@ export class RecruitmentSession implements RecruitmentSessionInterface { @PrimaryGeneratedColumn('increment') id: number; - @Column() + @Column('enum') state: RecruitmentSessionState; @Column('int', { name: 'slot_duration' }) diff --git a/api/src/recruitment-session/recruitment-session.service.spec.ts b/api/src/recruitment-session/recruitment-session.service.spec.ts index f6657f3..da1707c 100644 --- a/api/src/recruitment-session/recruitment-session.service.spec.ts +++ b/api/src/recruitment-session/recruitment-session.service.spec.ts @@ -42,7 +42,8 @@ describe('Recruitment Session Service', () => { .spyOn(mockedRepository, 'remove') .mockResolvedValue(mockRecruitmentSession); const result = recruitmentSessionService.deletRecruitmentSession( - mockRecruitmentSession, + // perché non prende "await" + mockRecruitmentSession, // nonostante la funzione nel service sia async ?? ); expect(result).toEqual([mockRecruitmentSession]); expect(mockedRepository.find).toHaveBeenCalledTimes(1); From 5b51f0c1d21b42c604dfaaf2aef3888ddfda3100 Mon Sep 17 00:00:00 2001 From: white Date: Sun, 14 Jan 2024 02:16:39 +0100 Subject: [PATCH 24/37] upd shared mock --- api/src/recruitment-session/recruitment-session.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/recruitment-session/recruitment-session.entity.ts b/api/src/recruitment-session/recruitment-session.entity.ts index 2f1048d..ed755d4 100644 --- a/api/src/recruitment-session/recruitment-session.entity.ts +++ b/api/src/recruitment-session/recruitment-session.entity.ts @@ -9,7 +9,7 @@ export class RecruitmentSession implements RecruitmentSessionInterface { @PrimaryGeneratedColumn('increment') id: number; - @Column('enum') + @Column() state: RecruitmentSessionState; @Column('int', { name: 'slot_duration' }) From 8971cf12bde906754fc7332dfb9ca9c619f2dce1 Mon Sep 17 00:00:00 2001 From: Alberto Baroso Date: Fri, 19 Jan 2024 20:33:25 +0100 Subject: [PATCH 25/37] fix: relative import of recruitment-session from shared folder --- api/src/recruitment-session/recruitment-session.entity.ts | 2 +- shared/src/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/api/src/recruitment-session/recruitment-session.entity.ts b/api/src/recruitment-session/recruitment-session.entity.ts index ed755d4..df58326 100644 --- a/api/src/recruitment-session/recruitment-session.entity.ts +++ b/api/src/recruitment-session/recruitment-session.entity.ts @@ -2,7 +2,7 @@ import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; import { RecruitmentSession as RecruitmentSessionInterface, RecruitmentSessionState, -} from '@hkrecruitment/shared/src/recruitment-session'; +} from '@hkrecruitment/shared'; @Entity() export class RecruitmentSession implements RecruitmentSessionInterface { 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"; From f95f5db6abdc35209558d66c459459a6efd4fdc8 Mon Sep 17 00:00:00 2001 From: Alberto Baroso Date: Fri, 19 Jan 2024 20:40:14 +0100 Subject: [PATCH 26/37] fix: recruitment-session service Delete test --- .../recruitment-session.service.spec.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/api/src/recruitment-session/recruitment-session.service.spec.ts b/api/src/recruitment-session/recruitment-session.service.spec.ts index da1707c..d6dfc9e 100644 --- a/api/src/recruitment-session/recruitment-session.service.spec.ts +++ b/api/src/recruitment-session/recruitment-session.service.spec.ts @@ -37,16 +37,15 @@ describe('Recruitment Session Service', () => { }); describe('deleteRecruitmentSession', () => { - it('should remove the specified recruitment session from the database', () => { + it('should remove the specified recruitment session from the database', async () => { jest .spyOn(mockedRepository, 'remove') .mockResolvedValue(mockRecruitmentSession); - const result = recruitmentSessionService.deletRecruitmentSession( - // perché non prende "await" - mockRecruitmentSession, // nonostante la funzione nel service sia async ?? + const result = await recruitmentSessionService.deletRecruitmentSession( + mockRecruitmentSession, ); - expect(result).toEqual([mockRecruitmentSession]); - expect(mockedRepository.find).toHaveBeenCalledTimes(1); + expect(result).toEqual(mockRecruitmentSession); + expect(mockedRepository.remove).toHaveBeenCalledTimes(1); }); }); }); From 70e4e5a1760088659fd845fbc18ef341be0679ce Mon Sep 17 00:00:00 2001 From: Alberto Baroso Date: Fri, 19 Jan 2024 20:48:19 +0100 Subject: [PATCH 27/37] fix: removed lastModified from UpdateRecruitmentSessionDto --- api/src/recruitment-session/update-recruitment-session.dto.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/src/recruitment-session/update-recruitment-session.dto.ts b/api/src/recruitment-session/update-recruitment-session.dto.ts index d0ea4a5..4c642ba 100644 --- a/api/src/recruitment-session/update-recruitment-session.dto.ts +++ b/api/src/recruitment-session/update-recruitment-session.dto.ts @@ -21,7 +21,4 @@ export class UpdateRecruitmentSessionDto @ApiProperty({ required: false }) days?: [Date]; - - @ApiProperty({ required: false }) - lastModified?: Date; } From 5fc8fed1db661ae7593d296239bd741a329304f1 Mon Sep 17 00:00:00 2001 From: Alberto Baroso Date: Fri, 19 Jan 2024 20:51:32 +0100 Subject: [PATCH 28/37] fix: ability check on recruitment session creation --- .../recruitment-session.controller.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/api/src/recruitment-session/recruitment-session.controller.ts b/api/src/recruitment-session/recruitment-session.controller.ts index 0edc0f2..f030592 100644 --- a/api/src/recruitment-session/recruitment-session.controller.ts +++ b/api/src/recruitment-session/recruitment-session.controller.ts @@ -65,7 +65,14 @@ export class RecruitmentSessionController { throw new NotFoundException(); } - if (!checkAbility(ability, Action.Read, recruitmentSession, 'Person')) { + if ( + !checkAbility( + ability, + Action.Read, + recruitmentSession, + 'RecruitmentSession', + ) + ) { throw new ForbiddenException(); } From 9190905d46025a10661466b65abf74c362624549 Mon Sep 17 00:00:00 2001 From: Alberto Baroso Date: Sun, 21 Jan 2024 15:24:01 +0100 Subject: [PATCH 29/37] feat: check if recruitment session has pending interviews before deleting it --- .../recruitment-session.controller.ts | 30 ++++++++++++++++--- .../recruitment-session.service.ts | 7 +++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/api/src/recruitment-session/recruitment-session.controller.ts b/api/src/recruitment-session/recruitment-session.controller.ts index f030592..2cc7398 100644 --- a/api/src/recruitment-session/recruitment-session.controller.ts +++ b/api/src/recruitment-session/recruitment-session.controller.ts @@ -120,7 +120,7 @@ export class RecruitmentSessionController { @Param('session_id') sessionId: number, @Body() updateRecruitmentSession: UpdateRecruitmentSessionDto, @Ability() ability: AppAbility, - @Req() req: AuthenticatedRequest, + @Req() req: AuthenticatedRequest, // # TODO: USE THIS param? ): Promise { const recruitmentSession = await this.recruitmentSessionService.findRecruitmentSessionById( @@ -144,6 +144,10 @@ export class RecruitmentSessionController { ) throw new ForbiddenException(); + // TODO: CAN'T SET A RECRUITMENT SESSION TO ACTIVE IF THERE IS ALREADY AN ACTIVE ONE + // TODO: CAN'T SET A RECRUITMENT SESSION TO INACTIVE IF THERE ISN'T AN ACTIVE ONE + // TODO: CAN'T SET A RECRUITMENT SESSION TO INACTIVE IF THERE ARE INTERVIEWS SCHEDULED FOR IT + const updatedRecruitmentSession = await this.recruitmentSessionService.updateRecruitmentSession({ ...recruitmentSession, @@ -151,6 +155,8 @@ export class RecruitmentSessionController { lastModified: new Date(), }); + // #TODO: CAN'T EDIT A REC SESSION IF IT'S NOT ACTIVE + return plainToClass( RecruitmentSessionResponseDto, updatedRecruitmentSession, @@ -166,19 +172,35 @@ export class RecruitmentSessionController { @CheckPolicies((ability) => ability.can(Action.Delete, 'RecruitmentSession')) @Delete('/:recruitment_session_id') @JoiValidate({ - param: Joi.number().positive().integer().required().label('time_slot_id'), + param: Joi.number() + .positive() + .integer() + .required() + .label('recruitment_session_id'), }) async deleteRecruitmentSessionById( @Param('recruitment_session_id') recruitmentSessionId: number, ): Promise { - // check if it exists + // Check if recruitment session exists const toRemove = await this.recruitmentSessionService.findRecruitmentSessionById( recruitmentSessionId, ); if (!toRemove) throw new NotFoundException('Recruitment session not found'); - // delete it + // Check if recruitment session has pending interviews + if (toRemove.state !== RecruitmentSessionState.Concluded) { + const hasPendingInterviews = + await this.recruitmentSessionService.sessionHasPendingInterviews( + toRemove, + ); + if (hasPendingInterviews) + throw new BadRequestException( + "Recruitment session can't be deleted because it has pending interviews", + ); + } + + // Delete recruitment session return await this.recruitmentSessionService.deletRecruitmentSession( toRemove, ); diff --git a/api/src/recruitment-session/recruitment-session.service.ts b/api/src/recruitment-session/recruitment-session.service.ts index 4d5210b..8dfaccd 100644 --- a/api/src/recruitment-session/recruitment-session.service.ts +++ b/api/src/recruitment-session/recruitment-session.service.ts @@ -51,4 +51,11 @@ export class RecruitmentSessionService { ): 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 + } } From d0e4524cf99f92111a37647c3af30d6bdfcade17 Mon Sep 17 00:00:00 2001 From: Alberto Baroso Date: Sun, 21 Jan 2024 15:30:34 +0100 Subject: [PATCH 30/37] feat: check for conflicts and consistency when updating a recruitment session state --- .../recruitment-session.controller.ts | 28 +++++++++++++++++-- .../recruitment-session.service.ts | 2 +- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/api/src/recruitment-session/recruitment-session.controller.ts b/api/src/recruitment-session/recruitment-session.controller.ts index 2cc7398..0cbbc05 100644 --- a/api/src/recruitment-session/recruitment-session.controller.ts +++ b/api/src/recruitment-session/recruitment-session.controller.ts @@ -111,6 +111,8 @@ export class RecruitmentSessionController { @Patch(':session_id') @ApiBadRequestResponse() @ApiForbiddenResponse() + @ApiConflictResponse() + @ApiNotFoundResponse() @ApiOkResponse() @JoiValidate({ param: Joi.number().positive().integer().required().label('session_id'), @@ -144,9 +146,29 @@ export class RecruitmentSessionController { ) throw new ForbiddenException(); - // TODO: CAN'T SET A RECRUITMENT SESSION TO ACTIVE IF THERE IS ALREADY AN ACTIVE ONE - // TODO: CAN'T SET A RECRUITMENT SESSION TO INACTIVE IF THERE ISN'T AN ACTIVE ONE - // TODO: CAN'T SET A RECRUITMENT SESSION TO INACTIVE IF THERE ARE INTERVIEWS SCHEDULED FOR IT + 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) + 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({ diff --git a/api/src/recruitment-session/recruitment-session.service.ts b/api/src/recruitment-session/recruitment-session.service.ts index 8dfaccd..55c1bcd 100644 --- a/api/src/recruitment-session/recruitment-session.service.ts +++ b/api/src/recruitment-session/recruitment-session.service.ts @@ -56,6 +56,6 @@ export class RecruitmentSessionService { recruitmentSession: RecruitmentSession, ): Promise { throw new Error('Method not implemented.'); - // TODO: Return true if recruitmentSession.interviews > 0 + // TODO: Return true if recruitmentSession.interviews > 0 where interviw date is in the future } } From 2d8bfea71df0990bfbb33dec29a826a1d5fc573f Mon Sep 17 00:00:00 2001 From: Alberto Baroso Date: Sun, 21 Jan 2024 15:34:40 +0100 Subject: [PATCH 31/37] fix: check ability for update recruitment session --- api/src/recruitment-session/recruitment-session.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/recruitment-session/recruitment-session.controller.ts b/api/src/recruitment-session/recruitment-session.controller.ts index 0cbbc05..96d56fb 100644 --- a/api/src/recruitment-session/recruitment-session.controller.ts +++ b/api/src/recruitment-session/recruitment-session.controller.ts @@ -118,11 +118,11 @@ export class RecruitmentSessionController { 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, - @Req() req: AuthenticatedRequest, // # TODO: USE THIS param? ): Promise { const recruitmentSession = await this.recruitmentSessionService.findRecruitmentSessionById( From f40d72724d971ad8bcbb165995b13a6fbce55af3 Mon Sep 17 00:00:00 2001 From: Alberto Baroso Date: Sun, 21 Jan 2024 15:35:57 +0100 Subject: [PATCH 32/37] refactor: removed unused imports in recruitment-session.controller.ts --- api/src/recruitment-session/recruitment-session.controller.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/src/recruitment-session/recruitment-session.controller.ts b/api/src/recruitment-session/recruitment-session.controller.ts index 96d56fb..7271f11 100644 --- a/api/src/recruitment-session/recruitment-session.controller.ts +++ b/api/src/recruitment-session/recruitment-session.controller.ts @@ -7,7 +7,6 @@ import { Param, Post, Delete, - Req, Patch, ForbiddenException, Get, @@ -38,10 +37,8 @@ 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 { AuthenticatedRequest } from 'src/authorization/authenticated-request.types'; import { plainToClass } from 'class-transformer'; import { RecruitmentSessionResponseDto } from './recruitment-session-response.dto'; -import { User } from 'src/users/user.entity'; @ApiBearerAuth() @ApiTags('recruitment-session') From 3bcac194b41215407b3d267e18bc94890ad51c4b Mon Sep 17 00:00:00 2001 From: Alberto Baroso Date: Sun, 21 Jan 2024 15:37:01 +0100 Subject: [PATCH 33/37] fix: use const for unchanged variable in createRecruitmentSession service method --- api/src/recruitment-session/recruitment-session.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/recruitment-session/recruitment-session.service.ts b/api/src/recruitment-session/recruitment-session.service.ts index 55c1bcd..3d9ee94 100644 --- a/api/src/recruitment-session/recruitment-session.service.ts +++ b/api/src/recruitment-session/recruitment-session.service.ts @@ -15,7 +15,7 @@ export class RecruitmentSessionService { async createRecruitmentSession( recruitmentSession: CreateRecruitmentSessionDto, ): Promise { - let now = new Date(); + const now = new Date(); const rs = { ...recruitmentSession, state: RecruitmentSessionState.Active, From dc45a8e49d63e85c92bfb3ce7da515128e2bf9d4 Mon Sep 17 00:00:00 2001 From: Alberto Baroso Date: Sun, 21 Jan 2024 15:56:35 +0100 Subject: [PATCH 34/37] refactor: removed unused code in creatre-recruitment-session.dto.ts --- .../create-recruitment-session.dto.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/api/src/recruitment-session/create-recruitment-session.dto.ts b/api/src/recruitment-session/create-recruitment-session.dto.ts index 09dedd1..d749e09 100644 --- a/api/src/recruitment-session/create-recruitment-session.dto.ts +++ b/api/src/recruitment-session/create-recruitment-session.dto.ts @@ -1,15 +1,9 @@ -import { - RecruitmentSession, - RecruitmentSessionState, -} from '@hkrecruitment/shared/recruitment-session'; +import { RecruitmentSession } from '@hkrecruitment/shared/recruitment-session'; import { ApiProperty } from '@nestjs/swagger'; export class CreateRecruitmentSessionDto implements Partial { - //@ApiProperty() - //state: RecruitmentSessionState; - @ApiProperty() slotDuration: number; @@ -21,10 +15,4 @@ export class CreateRecruitmentSessionDto @ApiProperty() days: [Date]; - - // @ApiProperty() - // createdAt: Date; - - // @ApiProperty() - // lastModified: Date; } From ce623853f1bf84576f8cf458d59a2e44dcfc9676 Mon Sep 17 00:00:00 2001 From: Alberto Baroso Date: Sun, 21 Jan 2024 17:53:05 +0100 Subject: [PATCH 35/37] fix: updated Date[] in create and update Recruitment session DTOs added 'state' to recruitmentSession response DTO --- api/src/recruitment-session/create-recruitment-session.dto.ts | 4 ++-- .../recruitment-session/recruitment-session-response.dto.ts | 1 + api/src/recruitment-session/update-recruitment-session.dto.ts | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/api/src/recruitment-session/create-recruitment-session.dto.ts b/api/src/recruitment-session/create-recruitment-session.dto.ts index d749e09..81b6399 100644 --- a/api/src/recruitment-session/create-recruitment-session.dto.ts +++ b/api/src/recruitment-session/create-recruitment-session.dto.ts @@ -13,6 +13,6 @@ export class CreateRecruitmentSessionDto @ApiProperty() interviewEnd: Date; - @ApiProperty() - days: [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 index 3ebee42..f13d57f 100644 --- a/api/src/recruitment-session/recruitment-session-response.dto.ts +++ b/api/src/recruitment-session/recruitment-session-response.dto.ts @@ -10,4 +10,5 @@ export class RecruitmentSessionResponseDto { @Expose() id: number; @Expose() createdAt: Date; + @Expose() state: RecruitmentSessionState; } diff --git a/api/src/recruitment-session/update-recruitment-session.dto.ts b/api/src/recruitment-session/update-recruitment-session.dto.ts index 4c642ba..a1595ae 100644 --- a/api/src/recruitment-session/update-recruitment-session.dto.ts +++ b/api/src/recruitment-session/update-recruitment-session.dto.ts @@ -19,6 +19,6 @@ export class UpdateRecruitmentSessionDto @ApiProperty({ required: false }) interviewEnd?: Date; - @ApiProperty({ required: false }) - days?: [Date]; + @ApiProperty({ required: false, isArray: true }) + days?: Date[]; } From 74d1a9cb3b773839918255f753a6d1e0372fb037 Mon Sep 17 00:00:00 2001 From: Alberto Baroso Date: Sun, 21 Jan 2024 17:53:42 +0100 Subject: [PATCH 36/37] test: Recruitment Session Controller tests --- api/src/mocks/data.ts | 16 + .../recruitment-session.controller.spec.ts | 361 ++++++++++++++++++ .../recruitment-session.controller.ts | 23 +- 3 files changed, 391 insertions(+), 9 deletions(-) create mode 100644 api/src/recruitment-session/recruitment-session.controller.spec.ts diff --git a/api/src/mocks/data.ts b/api/src/mocks/data.ts index 61b8b02..be63dcf 100644 --- a/api/src/mocks/data.ts +++ b/api/src/mocks/data.ts @@ -12,6 +12,8 @@ import { } 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); @@ -44,6 +46,20 @@ export const mockRecruitmentSession = { 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/recruitment-session.controller.spec.ts b/api/src/recruitment-session/recruitment-session.controller.spec.ts new file mode 100644 index 0000000..9e64d2d --- /dev/null +++ b/api/src/recruitment-session/recruitment-session.controller.spec.ts @@ -0,0 +1,361 @@ +import { createMockAbility } from '@hkrecruitment/shared/abilities.spec'; +import { RecruitmentSessionController } from './recruitment-session.controller'; +import { RecruitmentSessionService } from './recruitment-session.service'; +import { Action, RecruitmentSessionState, Role } 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 { + BadRequestException, + ConflictException, + Delete, + ForbiddenException, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; +import { createMock } from '@golevelup/ts-jest'; +import { AuthenticatedRequest } from 'src/authorization/authenticated-request.types'; +import { CreateRecruitmentSessionDto } from './create-recruitment-session.dto'; +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 index 7271f11..d68eddd 100644 --- a/api/src/recruitment-session/recruitment-session.controller.ts +++ b/api/src/recruitment-session/recruitment-session.controller.ts @@ -138,7 +138,6 @@ export class RecruitmentSessionController { Action.Update, sessionToCheck, 'RecruitmentSession', - ['applicantId'], ) ) throw new ForbiddenException(); @@ -148,7 +147,10 @@ export class RecruitmentSessionController { if (updateRecruitmentSession.state === RecruitmentSessionState.Active) { const currentlyActiveRecruitmentSession = await this.recruitmentSessionService.findActiveRecruitmentSession(); - if (currentlyActiveRecruitmentSession) + 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', ); @@ -174,8 +176,6 @@ export class RecruitmentSessionController { lastModified: new Date(), }); - // #TODO: CAN'T EDIT A REC SESSION IF IT'S NOT ACTIVE - return plainToClass( RecruitmentSessionResponseDto, updatedRecruitmentSession, @@ -187,6 +187,7 @@ export class RecruitmentSessionController { @ApiForbiddenResponse() @ApiNotFoundResponse() @ApiOkResponse() + @ApiConflictResponse() @ApiNoContentResponse() @CheckPolicies((ability) => ability.can(Action.Delete, 'RecruitmentSession')) @Delete('/:recruitment_session_id') @@ -197,9 +198,9 @@ export class RecruitmentSessionController { .required() .label('recruitment_session_id'), }) - async deleteRecruitmentSessionById( + async deleteRecruitmentSession( @Param('recruitment_session_id') recruitmentSessionId: number, - ): Promise { + ): Promise { // Check if recruitment session exists const toRemove = await this.recruitmentSessionService.findRecruitmentSessionById( @@ -214,14 +215,18 @@ export class RecruitmentSessionController { toRemove, ); if (hasPendingInterviews) - throw new BadRequestException( + throw new ConflictException( "Recruitment session can't be deleted because it has pending interviews", ); } // Delete recruitment session - return await this.recruitmentSessionService.deletRecruitmentSession( - toRemove, + const deletedRecruitmentSession = + await this.recruitmentSessionService.deletRecruitmentSession(toRemove); + + return plainToClass( + RecruitmentSessionResponseDto, + deletedRecruitmentSession, ); } } From 598f2dc4d02c05e4797aef39796174c07ce22b69 Mon Sep 17 00:00:00 2001 From: Alberto Baroso Date: Sun, 21 Jan 2024 17:55:53 +0100 Subject: [PATCH 37/37] refactor: removed unused imports --- .../recruitment-session.controller.spec.ts | 8 +------- .../recruitment-session/recruitment-session.controller.ts | 1 - 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/api/src/recruitment-session/recruitment-session.controller.spec.ts b/api/src/recruitment-session/recruitment-session.controller.spec.ts index 9e64d2d..719202d 100644 --- a/api/src/recruitment-session/recruitment-session.controller.spec.ts +++ b/api/src/recruitment-session/recruitment-session.controller.spec.ts @@ -1,7 +1,7 @@ import { createMockAbility } from '@hkrecruitment/shared/abilities.spec'; import { RecruitmentSessionController } from './recruitment-session.controller'; import { RecruitmentSessionService } from './recruitment-session.service'; -import { Action, RecruitmentSessionState, Role } from '@hkrecruitment/shared'; +import { Action, RecruitmentSessionState } from '@hkrecruitment/shared'; import { TestBed } from '@automock/jest'; import { RecruitmentSessionResponseDto } from './recruitment-session-response.dto'; import { RecruitmentSession } from './recruitment-session.entity'; @@ -12,16 +12,10 @@ import { testDate, } from '@mocks/data'; import { - BadRequestException, ConflictException, - Delete, ForbiddenException, NotFoundException, - UnprocessableEntityException, } from '@nestjs/common'; -import { createMock } from '@golevelup/ts-jest'; -import { AuthenticatedRequest } from 'src/authorization/authenticated-request.types'; -import { CreateRecruitmentSessionDto } from './create-recruitment-session.dto'; import { UpdateRecruitmentSessionDto } from './update-recruitment-session.dto'; describe('RecruitmentSessionController', () => { diff --git a/api/src/recruitment-session/recruitment-session.controller.ts b/api/src/recruitment-session/recruitment-session.controller.ts index d68eddd..9e65523 100644 --- a/api/src/recruitment-session/recruitment-session.controller.ts +++ b/api/src/recruitment-session/recruitment-session.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, - BadRequestException, NotFoundException, ConflictException, Param,