diff --git a/api/src/interview/create-interview.dto.ts b/api/src/interview/create-interview.dto.ts new file mode 100644 index 0000000..d5fd379 --- /dev/null +++ b/api/src/interview/create-interview.dto.ts @@ -0,0 +1,24 @@ +import { Interview } from '@hkrecruitment/shared'; +import { User } from '../users/user.entity' +import { ApiProperty } from '@nestjs/swagger'; + + +export class CreateInterviewDto implements Partial { + @ApiProperty() + notes: string; + + @ApiProperty() + created_at: Date; + + @ApiProperty() + id_timeslot: number; + + @ApiProperty() + interviewer_1: User; + + @ApiProperty() + interviewer_2: User; + + @ApiProperty({ required: false }) + optional_interviewer?: User; +} diff --git a/api/src/interview/interview.controller.ts b/api/src/interview/interview.controller.ts new file mode 100644 index 0000000..df3e784 --- /dev/null +++ b/api/src/interview/interview.controller.ts @@ -0,0 +1,124 @@ +import { + Get, + Param, + ForbiddenException, + NotFoundException, + Controller, + Patch, + Body, + Post, + Req, + Delete +} from '@nestjs/common'; +import { Interview } from './interview.entity'; +import { InterviewService } from './interview.Service'; +import { CreateInterviewDto } from './create-interview.dto'; +import { UpdateInterviewDto } from './update-interview.dto'; +import { + Action, + AppAbility, + checkAbility, + createInterviewSchema, + updateInterviewSchema, +} from '@hkrecruitment/shared'; +import { JoiValidate } from 'src/joi-validation/joi-validate.decorator'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiNotFoundResponse, + ApiTags, +} from '@nestjs/swagger'; +import { AuthenticatedRequest } from 'src/authorization/authenticated-request.types'; +import * as Joi from 'joi'; +import { CheckPolicies } from 'src/authorization/check-policies.decorator'; +import { Ability } from 'src/authorization/ability.decorator'; + +@ApiBearerAuth() +@ApiTags('interview') +@Controller('interview') + +export class InterviewController { + constructor(private readonly interviewService: InterviewService) {} + + @ApiNotFoundResponse() + @ApiBadRequestResponse() + @Get(':Id') + @JoiValidate({ + param: Joi.number().required(), + }) + @CheckPolicies((ability) => ability.can(Action.Read, 'Interview')) + async findById( + @Param('Id') Id: number, + @Ability() ability: AppAbility, + ): Promise { + const interview = await this.interviewService.findById(Id); + if (interview === null) { + throw new NotFoundException(); + } + if (!checkAbility(ability, Action.Read, interview, 'Interview')) { + throw new ForbiddenException(); + } + return interview; + } + + @ApiNotFoundResponse() + @ApiBadRequestResponse() + @Delete(':Id') + @JoiValidate({ + param: Joi.number().required(), + }) + @CheckPolicies((ability) => ability.can(Action.Delete, 'Interview')) + async delete( + @Param('Id') Id: number, + @Ability() ability: AppAbility, + ): Promise { + const interview = await this.interviewService.findById(Id); + if (interview === null) { + throw new NotFoundException(); + } + return this.interviewService.delete(interview); + } + + @ApiNotFoundResponse() + @ApiBadRequestResponse() + @Patch(':Id') + @JoiValidate({ + body: updateInterviewSchema, + param: Joi.number().required(), + }) + @CheckPolicies((ability) => ability.can(Action.Update, 'Interview')) + async update( + @Param('Id') Id: number, + @Body() updateInterview: UpdateInterviewDto, + @Ability() ability: AppAbility, + @Req() req: AuthenticatedRequest, + ): Promise { + const interview = await this.interviewService.findById(Id); + if (interview === null) { + throw new NotFoundException(); + } + return this.interviewService.update({ + ...interview, + ...updateInterview, + }); + } + + @ApiNotFoundResponse() + @ApiBadRequestResponse() + @Post() + @JoiValidate({ + body: createInterviewSchema, + }) + async create( + @Body() interview: CreateInterviewDto, + @Ability() ability: AppAbility, + @Req() req: AuthenticatedRequest, + ): Promise { + if (!checkAbility(ability, Action.Create, interview, 'Interview')){ + throw new ForbiddenException()}; + return this.interviewService.create({ + + }); + } +} + \ No newline at end of file diff --git a/api/src/interview/interview.entity.ts b/api/src/interview/interview.entity.ts new file mode 100644 index 0000000..58c7784 --- /dev/null +++ b/api/src/interview/interview.entity.ts @@ -0,0 +1,32 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import {Interview as InterviewSlot} from '@hkrecruitment/shared'; +import {User} from '../users/user.entity'; +import { TimeSlot } from '../timeslots/timeslot.entity'; +import { Application } from '../application/application.entity'; + +@Entity() +export class Interview implements InterviewSlot { + @PrimaryGeneratedColumn('increment') + id: number; + + @Column() + notes: string; + + @Column() + created_at: Date; + + @Column() + timeslot: TimeSlot; + + @Column() + application: Application; + + @Column() + interviewer_1: User; + + @Column() + interviewer_2: User; + + @Column({nullable: true}) + optional_interviewer?: User; +} diff --git a/api/src/interview/interview.module.ts b/api/src/interview/interview.module.ts new file mode 100644 index 0000000..b07b9fc --- /dev/null +++ b/api/src/interview/interview.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { InterviewService } from './interview.service'; +import { InterviewController } from './interview.controller'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Interview } from './interview.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Interview])], + providers: [InterviewService], + controllers: [InterviewController], + exports: [InterviewService], +}) +export class InterviewModule {} diff --git a/api/src/interview/interview.service.ts b/api/src/interview/interview.service.ts new file mode 100644 index 0000000..333285d --- /dev/null +++ b/api/src/interview/interview.service.ts @@ -0,0 +1,32 @@ +import { Interview } from "./interview.entity" +import { Application } from "../application/application.entity"; +import { TimeSlot } from "../timeslots/timeslot.entity"; +import { InjectRepository } from '@nestjs/typeorm'; +import { Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { CreateInterviewDto } from "./create-interview.dto"; + +@Injectable() +export class InterviewService { + constructor( + @InjectRepository(Interview) + private readonly interviewRepository: Repository, + ) {} + + async findById(id: number): Promise { + return this.interviewRepository.findOne({ where: { id } }); + } + + async delete(interview: Interview): Promise { + return await this.interviewRepository.remove(interview); + } + + async create(interview: CreateInterviewDto, application: Application, timeslot: TimeSlot): Promise { + return await this.interviewRepository.save(interview); + } + + async update(interview: Interview): Promise { + return await this.interviewRepository.save(interview); + } +} + \ No newline at end of file diff --git a/api/src/interview/update-interview.dto.ts b/api/src/interview/update-interview.dto.ts new file mode 100644 index 0000000..5152d68 --- /dev/null +++ b/api/src/interview/update-interview.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, PartialType } from '@nestjs/swagger'; +import { Interview } from '@hkrecruitment/shared'; +import { User } from '../users/user.entity' +import { TimeSlot } from '../timeslots/timeslot.entity'; +import { Application } from '../application/application.entity'; + +export class UpdateInterviewDto implements Partial{ + @ApiProperty({required: false}) + notes: string; + + @ApiProperty({required: false}) + created_at: Date; + + @ApiProperty({required: false}) + timeslot: TimeSlot; + + @ApiProperty({required: false}) + application: Application; + + @ApiProperty({required: false}) + interviewer_1: User; + + @ApiProperty({required: false}) + interviewer_2: User; + + @ApiProperty({ required: false }) + optional_interviewer?: User; +} \ No newline at end of file diff --git a/shared/src/abilities.ts b/shared/src/abilities.ts index 56a3e5d..a3daa46 100644 --- a/shared/src/abilities.ts +++ b/shared/src/abilities.ts @@ -8,7 +8,8 @@ import { import { applyAbilitiesForPerson, Person, Role } from "./person"; import { Application, applyAbilitiesOnApplication } from "./application"; import { applyAbilitiesOnAvailability, Availability } from "./availability"; -import { TimeSlot } from "./timeslot"; +import { TimeSlot } from "./timeslot" +import { applyAbilitiesOnInterview, Interview } from "./interview"; 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" | "Interview"; export type Subjects = SubjectsTypes | SubjectNames; export type AppAbility = PureAbility<[Action, Subjects]>; @@ -44,6 +46,7 @@ export const abilityForUser = (user: UserAuth): AppAbility => { applyAbilitiesForPerson(user, builder); applyAbilitiesOnApplication(user, builder); applyAbilitiesOnAvailability(user, builder); + applyAbilitiesOnInterview(user, builder); const { build } = builder; return build(); diff --git a/shared/src/index.ts b/shared/src/index.ts index 3402b34..c5eeeca 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 "./interview" diff --git a/shared/src/interview.ts b/shared/src/interview.ts new file mode 100644 index 0000000..d475814 --- /dev/null +++ b/shared/src/interview.ts @@ -0,0 +1,47 @@ +import { Person, createUserSchema, updateUserSchema, Role } from "./person"; +import { TimeSlot, createTimeSlotSchema } from "timeslot"; +import { Application, createApplicationSchema, updateApplicationSchema } from "application"; +import { Action, ApplyAbilities } from "./abilities"; +import * as Joi from "joi"; + +export interface Interview { + id: number; + notes: string; + created_at: Date; + timeslot: TimeSlot; + application: Application; + interviewer_1: Person; + interviewer_2: Person; + optional_interviewer?: Person; +}; + +export const createInterviewSchema = Joi.object({ + notes: Joi.string().required(), + created_at: Joi.date().required(), + interviewer_1: createUserSchema.required(), + interviewer_2: createUserSchema.required(), + optional_interviewer: createUserSchema.optional(), +}); + +export const updateInterviewSchema = Joi.object({ + notes: Joi.string().optional(), + created_at: Joi.date().optional(), + timeslot: createTimeSlotSchema.optional(), + application: updateApplicationSchema.optional(), + interviewer_1: updateUserSchema.optional(), + interviewer_2: updateUserSchema.optional(), + optional_interviewer: updateUserSchema.optional() +}); + +export const applyAbilitiesOnInterview: ApplyAbilities = ( + user, + { can, cannot } + ) => { + if (user.role === Role.Admin || user.role === Role.Supervisor) { + can(Action.Manage, "Interview"); + } + else{ + cannot(Action.Manage, "Interview") + } + }; + \ No newline at end of file