diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 86e5b5b..cab0441 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule, TypeOrmModuleOptions } from '@nestjs/typeorm'; import { UsersModule } from './users/users.module'; import { ApplicationsModule } from './application/applications.module'; +import { TimeSlotsModule } from './timeslots/timeslots.module'; import { AuthenticationModule } from './authentication/authentication.module'; import { APP_GUARD } from '@nestjs/core'; import { JwtGuard } from './authentication/jwt-guard.guard'; @@ -31,6 +32,7 @@ import { AuthorizationGuard } from './authorization/authorization.guard'; }), UsersModule, ApplicationsModule, + TimeSlotsModule, AuthenticationModule, AuthorizationModule, ], diff --git a/api/src/application/applications.service.ts b/api/src/application/applications.service.ts index d56db1a..079b473 100644 --- a/api/src/application/applications.service.ts +++ b/api/src/application/applications.service.ts @@ -66,7 +66,6 @@ export class ApplicationsService { // Add state condition when "state" is specified if (state) conditions['state'] = state; - // Retrieve applications return await this.applicationRepository.findBy(conditions); } diff --git a/api/src/application/create-application.dto.ts b/api/src/application/create-application.dto.ts index aabe24d..ab6a210 100644 --- a/api/src/application/create-application.dto.ts +++ b/api/src/application/create-application.dto.ts @@ -98,7 +98,7 @@ export class CreateApplicationDto implements Partial { export function flattenApplication( application: CreateApplicationDto, ): Application { - let newApplication: Application = { + const newApplication: Application = { ...application, ...application.bscApplication, ...application.mscApplication, diff --git a/api/src/mocks/data.ts b/api/src/mocks/data.ts index fb05e84..f31aa3e 100644 --- a/api/src/mocks/data.ts +++ b/api/src/mocks/data.ts @@ -13,6 +13,16 @@ import { import { UpdateApplicationDto } from 'src/application/update-application.dto'; export const testDate = new Date(2023, 0, 1); +export const testDateTimeStart = new Date(2023, 0, 1, 10, 30, 0); +export const testDateTime10Minutes = new Date(2023, 0, 1, 10, 40, 0); +export const testDateTime3Hours = new Date(2023, 0, 1, 13, 30, 0); +export const testDateTimeEnd = new Date(2023, 0, 1, 11, 30, 0); + +export const mockTimeSlot = { + start: testDateTimeStart, + end: testDateTimeEnd, + id: 1, +}; export const baseFile = { encoding: '7bit', diff --git a/api/src/timeslots/create-timeslot.dto.ts b/api/src/timeslots/create-timeslot.dto.ts new file mode 100644 index 0000000..2b9aaea --- /dev/null +++ b/api/src/timeslots/create-timeslot.dto.ts @@ -0,0 +1,10 @@ +import { TimeSlot } from '@hkrecruitment/shared'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateTimeSlotDto implements TimeSlot { + @ApiProperty() + start: Date; + + @ApiProperty() + end: Date; +} diff --git a/api/src/timeslots/timeslot.entity.ts b/api/src/timeslots/timeslot.entity.ts new file mode 100644 index 0000000..959f85f --- /dev/null +++ b/api/src/timeslots/timeslot.entity.ts @@ -0,0 +1,14 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { TimeSlot as TimeSlotInterface } from '@hkrecruitment/shared'; + +@Entity() +export class TimeSlot implements TimeSlotInterface { + @PrimaryGeneratedColumn('increment') + id: number; + + @Column() + start: Date; + + @Column() + end: Date; +} diff --git a/api/src/timeslots/timeslots.controller.spec.ts b/api/src/timeslots/timeslots.controller.spec.ts new file mode 100644 index 0000000..0c2d34b --- /dev/null +++ b/api/src/timeslots/timeslots.controller.spec.ts @@ -0,0 +1,104 @@ +import { TestBed } from '@automock/jest'; +import { + mockTimeSlot, + testDate, + testDateTimeEnd, + testDateTimeStart, +} from '@mocks/data'; +import { TimeSlotsController } from './timeslots.controller'; +import { TimeSlotsService } from './timeslots.service'; +import { testDateTime10Minutes } from '@mocks/data'; +import { testDateTime3Hours } from '@mocks/data'; + +describe('TimeSlotController', () => { + let controller: TimeSlotsController; + let service: TimeSlotsService; + + /************* Test setup ************/ + + beforeEach(async () => { + const { unit, unitRef } = TestBed.create(TimeSlotsController).compile(); + controller = unit; + service = unitRef.get(TimeSlotsService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + expect(service).toBeDefined(); + }); + + // Create a time slot + describe('createTimeSlot', () => { + it('should allow creating a valid time slot', async () => { + const timeSlot = { + start: mockTimeSlot.start, + end: mockTimeSlot.end, + }; + + jest.spyOn(service, 'countOverlappingTimeSlots').mockResolvedValue(0); + jest.spyOn(service, 'createTimeSlot').mockResolvedValue(mockTimeSlot); + + const result = await controller.createTimeSlot(timeSlot); + + expect(result).toEqual(mockTimeSlot); + }); + + it('should throw an error if the duration is less than 30 minutes', async () => { + const timeSlot = { + start: testDateTimeStart, + end: testDateTime10Minutes, + }; + + await expect(controller.createTimeSlot(timeSlot)).rejects.toThrow( + 'The duration of the time slot must be at least 30 minutes', + ); + }); + + it('should throw an error if the duration is more than 60 minutes', async () => { + const timeSlot = { + start: testDateTimeStart, + end: testDateTime3Hours, + }; + + await expect(controller.createTimeSlot(timeSlot)).rejects.toThrow( + 'The duration of the time slot must be at most 60 minutes', + ); + }); + + it('should throw an error if the time slot overlaps with an existing time slot', async () => { + const timeSlot = { + start: testDateTimeStart, + end: testDateTimeEnd, + }; + + jest.spyOn(service, 'countOverlappingTimeSlots').mockResolvedValue(1); + + await expect(controller.createTimeSlot(timeSlot)).rejects.toThrow( + 'The time slot overlaps with existing time slots', + ); + }); + }); + + describe('deleteTimeSlot', () => { + it('should allow deleting an existing time slot', async () => { + jest.spyOn(service, 'findById').mockResolvedValue(mockTimeSlot); + jest.spyOn(service, 'deleteTimeSlot').mockResolvedValue(mockTimeSlot); + + await expect(controller.deleteTimeSlot(mockTimeSlot.id)).resolves.toEqual( + mockTimeSlot, + ); + expect(service.deleteTimeSlot).toHaveBeenCalledWith(mockTimeSlot); + expect(service.deleteTimeSlot).toHaveBeenCalledTimes(1); + }); + + it('should throw an error if the time slot does not exist', async () => { + jest.spyOn(service, 'findById').mockResolvedValue(null); + jest.spyOn(service, 'deleteTimeSlot').mockResolvedValue(mockTimeSlot); + + await expect( + controller.deleteTimeSlot(mockTimeSlot.id), + ).rejects.toThrowError('Time slot not found'); + expect(service.deleteTimeSlot).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/api/src/timeslots/timeslots.controller.ts b/api/src/timeslots/timeslots.controller.ts new file mode 100644 index 0000000..3202071 --- /dev/null +++ b/api/src/timeslots/timeslots.controller.ts @@ -0,0 +1,91 @@ +import { + Body, + Controller, + BadRequestException, + NotFoundException, + ConflictException, + Param, + Post, + Delete, +} from '@nestjs/common'; +import { TimeSlotsService } from './timeslots.service'; +import { Action, createTimeSlotSchema, TimeSlot } 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 { CreateTimeSlotDto } from './create-timeslot.dto'; +import * as Joi from 'joi'; + +@ApiBearerAuth() +@ApiTags('timeslots') +@Controller('timeslots') +export class TimeSlotsController { + constructor(private readonly timeSlotsService: TimeSlotsService) {} + + @ApiBadRequestResponse() + @ApiForbiddenResponse() + @ApiConflictResponse({ + description: 'The time slot overlaps with existing time slots', + }) + @ApiCreatedResponse() + @JoiValidate({ + body: createTimeSlotSchema, + }) + @CheckPolicies((ability) => ability.can(Action.Create, 'TimeSlot')) + @Post() + async createTimeSlot(@Body() timeSlot: CreateTimeSlotDto): Promise { + const startDate = new Date(timeSlot.start); + const endDate = new Date(timeSlot.end); + + // Check duration + const durationInMinutes = + (endDate.getTime() - startDate.getTime()) / (1000 * 60); + if (durationInMinutes < 30) { + throw new BadRequestException( + 'The duration of the time slot must be at least 30 minutes', + ); + } else if (durationInMinutes > 60) { + throw new BadRequestException( + 'The duration of the time slot must be at most 60 minutes', + ); + } + + // Check overlapping timeslots + const overlappingTimeSlots = + await this.timeSlotsService.countOverlappingTimeSlots(startDate, endDate); + if (overlappingTimeSlots > 0) + throw new ConflictException( + 'The time slot overlaps with existing time slots', + ); + + return await this.timeSlotsService.createTimeSlot(timeSlot); + } + + @ApiBadRequestResponse() + @ApiForbiddenResponse() + @ApiNotFoundResponse() + @ApiOkResponse() + @ApiNoContentResponse() + @CheckPolicies((ability) => ability.can(Action.Delete, 'TimeSlot')) + @Delete('/:time_slot_id') + @JoiValidate({ + param: Joi.number().positive().integer().required().label('time_slot_id'), + }) + async deleteTimeSlot( + @Param('time_slot_id') timeSlotId: number, + ): Promise { + const timeSlot = await this.timeSlotsService.findById(timeSlotId); + if (!timeSlot) throw new NotFoundException('Time slot not found'); + return await this.timeSlotsService.deleteTimeSlot(timeSlot); + } +} diff --git a/api/src/timeslots/timeslots.module.ts b/api/src/timeslots/timeslots.module.ts new file mode 100644 index 0000000..15299da --- /dev/null +++ b/api/src/timeslots/timeslots.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TimeSlotsService } from './timeslots.service'; +import { TimeSlotsController } from './timeslots.controller'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TimeSlot } from './timeslot.entity'; +import { UsersModule } from 'src/users/users.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([TimeSlot]), UsersModule], + providers: [TimeSlotsService], + controllers: [TimeSlotsController], + exports: [TimeSlotsService], +}) +export class TimeSlotsModule {} diff --git a/api/src/timeslots/timeslots.service.spec.ts b/api/src/timeslots/timeslots.service.spec.ts new file mode 100644 index 0000000..fde00f1 --- /dev/null +++ b/api/src/timeslots/timeslots.service.spec.ts @@ -0,0 +1,76 @@ +import { mockTimeSlot, testDate } from '@mocks/data'; +import { mockedRepository } from '@mocks/repositories'; +import { TestingModule, Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { TimeSlot } from './timeslot.entity'; +import { TimeSlotsService } from './timeslots.service'; + +describe('TimeSlotsService', () => { + let timeSlotService: TimeSlotsService; + + /************* Test setup ************/ + + beforeAll(() => { + jest + .spyOn(global, 'Date') + .mockImplementation(() => testDate as unknown as string); + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TimeSlotsService, + { + provide: getRepositoryToken(TimeSlot), + useValue: mockedRepository, + }, + ], + }).compile(); + + timeSlotService = module.get(TimeSlotsService); + }); + + afterEach(() => jest.clearAllMocks()); + + /*************** Tests ***************/ + + it('should be defined', () => { + expect(timeSlotService).toBeDefined(); + }); + + describe('deleteTimeSlot', () => { + it('should remove the specified timeslot from the database', async () => { + jest.spyOn(mockedRepository, 'remove').mockResolvedValue(mockTimeSlot); + const result = await timeSlotService.deleteTimeSlot(mockTimeSlot); + expect(result).toEqual(mockTimeSlot); + expect(mockedRepository.remove).toHaveBeenCalledTimes(1); + }); + }); + + describe('listTimeSlots', () => { + it('should return all timeslots', async () => { + jest.spyOn(mockedRepository, 'find').mockResolvedValue([mockTimeSlot]); + const result = await timeSlotService.listTimeSlots(); + expect(result).toEqual([mockTimeSlot]); + expect(mockedRepository.find).toHaveBeenCalledTimes(1); + }); + }); + + describe('findById', () => { + it('should return the timeslot with the specified id', async () => { + jest.spyOn(mockedRepository, 'findBy').mockResolvedValue([mockTimeSlot]); + const result = await timeSlotService.findById(mockTimeSlot.id); + expect(result).toEqual(mockTimeSlot); + expect(mockedRepository.findBy).toHaveBeenCalledTimes(1); + }); + }); + + describe('createTimeSlot', () => { + it('should create a new timeslot', async () => { + jest.spyOn(mockedRepository, 'save').mockResolvedValue(mockTimeSlot); + const result = await timeSlotService.createTimeSlot(mockTimeSlot); + expect(result).toEqual(mockTimeSlot); + expect(mockedRepository.save).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/api/src/timeslots/timeslots.service.ts b/api/src/timeslots/timeslots.service.ts new file mode 100644 index 0000000..ec5e1b9 --- /dev/null +++ b/api/src/timeslots/timeslots.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThan, MoreThan } from 'typeorm'; +import { TimeSlot } from './timeslot.entity'; +import { CreateTimeSlotDto } from './create-timeslot.dto'; + +@Injectable() +export class TimeSlotsService { + constructor( + @InjectRepository(TimeSlot) + private readonly timeSlotRepository: Repository, + ) {} + + async countOverlappingTimeSlots( + startDate: Date, + endDate: Date, + ): Promise { + const count = await this.timeSlotRepository.count({ + where: [ + { + // start < startDate && end > startDate + start: LessThan(startDate), + end: MoreThan(startDate), + }, + // OR + { + // start < endDate || end > endDate + start: LessThan(endDate), + end: MoreThan(endDate), + }, + ], + }); + return count; + } + + async listTimeSlots(): Promise { + return await this.timeSlotRepository.find(); + } + + async deleteTimeSlot(timeSlot: TimeSlot): Promise { + return await this.timeSlotRepository.remove(timeSlot); + } + + async findById(timeSlotId: number): Promise { + const matches = await this.timeSlotRepository.findBy({ + id: timeSlotId, + }); + return matches.length > 0 ? matches[0] : null; + } + + async createTimeSlot(timeSlot: CreateTimeSlotDto): Promise { + return await this.timeSlotRepository.save(timeSlot); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e85631..c25b86d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + importers: .: {} @@ -96,7 +100,7 @@ importers: version: 9.1.5(@swc/core@1.3.56) '@nestjs/schematics': specifier: ^9.0.0 - version: 9.0.3(chokidar@3.5.3)(typescript@4.8.4) + version: 9.0.3(typescript@4.5.2) '@nestjs/testing': specifier: ^9.0.0 version: 9.2.1(@nestjs/common@9.2.1)(@nestjs/core@9.2.1)(@nestjs/platform-express@9.2.1) @@ -242,6 +246,9 @@ importers: '@casl/ability': specifier: ^6.3.3 version: 6.3.3 + '@joi/date': + specifier: ^2.1.0 + version: 2.1.0 joi: specifier: ^17.7.0 version: 17.7.0 @@ -1124,6 +1131,7 @@ packages: /@gar/promisify@1.1.3: resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + requiresBuild: true optional: true /@golevelup/ts-jest@0.3.6: @@ -1408,6 +1416,12 @@ packages: chalk: 4.1.2 dev: true + /@joi/date@2.1.0: + resolution: {integrity: sha512-2zN5m0LgxZp/cynHGbzEImVmFIa+n+IOb/Nlw5LX/PLJneeCwG1NbiGw7MvPjsAKUGQK8z31Nn6V6lEN+4fZhg==} + dependencies: + moment: 2.29.4 + dev: false + /@jridgewell/gen-mapping@0.1.1: resolution: {integrity: sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==} engines: {node: '>=6.0.0'} @@ -1629,6 +1643,21 @@ packages: - chokidar dev: true + /@nestjs/schematics@9.0.3(typescript@4.5.2): + resolution: {integrity: sha512-kZrU/lrpVd2cnK8I3ibDb3Wi1ppl3wX3U3lVWoL+DzRRoezWKkh8upEL4q0koKmuXnsmLiu3UPxFeMOrJV7TSA==} + peerDependencies: + typescript: ^4.3.5 + dependencies: + '@angular-devkit/core': 14.2.1(chokidar@3.5.3) + '@angular-devkit/schematics': 14.2.1(chokidar@3.5.3) + fs-extra: 10.1.0 + jsonc-parser: 3.2.0 + pluralize: 8.0.0 + typescript: 4.5.2 + transitivePeerDependencies: + - chokidar + dev: true + /@nestjs/swagger@6.1.3(@fastify/static@6.6.0)(@nestjs/common@9.2.1)(@nestjs/core@9.2.1)(class-transformer@0.5.1)(reflect-metadata@0.1.13): resolution: {integrity: sha512-H9C/yRgLFb5QrAt6iGrYmIX9X7Q0zXkgZaTNUATljUBra+RCWrEUbLHBcGjTAOtcIyGV/vmyCLv68YSVcZoE0Q==} peerDependencies: @@ -1713,6 +1742,7 @@ packages: /@npmcli/fs@1.1.1: resolution: {integrity: sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==} + requiresBuild: true dependencies: '@gar/promisify': 1.1.3 semver: 7.3.8 @@ -1722,6 +1752,7 @@ packages: resolution: {integrity: sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==} engines: {node: '>=10'} deprecated: This functionality has been moved to @npmcli/fs + requiresBuild: true dependencies: mkdirp: 1.0.4 rimraf: 3.0.2 @@ -1947,6 +1978,7 @@ packages: /@tootallnate/once@1.1.2: resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} engines: {node: '>= 6'} + requiresBuild: true optional: true /@tsconfig/node10@1.0.9: @@ -2515,6 +2547,7 @@ packages: /agentkeepalive@4.3.0: resolution: {integrity: sha512-7Epl1Blf4Sy37j4v9f9FjICCh4+KAQOyXgHEwlyBiAQLbhKdq/i2QQU3amQalS/wPhdPzDXPL5DMR5bkn+YeWg==} engines: {node: '>= 8.0.0'} + requiresBuild: true dependencies: debug: 4.3.4 depd: 2.0.0 @@ -2526,6 +2559,7 @@ packages: /aggregate-error@3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} + requiresBuild: true dependencies: clean-stack: 2.2.0 indent-string: 4.0.0 @@ -2633,6 +2667,7 @@ packages: /are-we-there-yet@3.0.1: resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + requiresBuild: true dependencies: delegates: 1.0.0 readable-stream: 3.6.0 @@ -2899,6 +2934,7 @@ packages: /cacache@15.3.0: resolution: {integrity: sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==} engines: {node: '>= 10'} + requiresBuild: true dependencies: '@npmcli/fs': 1.1.1 '@npmcli/move-file': 1.1.2 @@ -3039,6 +3075,7 @@ packages: /clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} + requiresBuild: true optional: true /cli-cursor@3.1.0: @@ -3496,10 +3533,12 @@ packages: /env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + requiresBuild: true optional: true /err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + requiresBuild: true optional: true /error-ex@1.3.2: @@ -4051,6 +4090,7 @@ packages: /gauge@4.0.4: resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + requiresBuild: true dependencies: aproba: 2.0.0 color-support: 1.1.3 @@ -4308,6 +4348,7 @@ packages: /http-cache-semantics@4.1.1: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + requiresBuild: true optional: true /http-errors@2.0.0: @@ -4323,6 +4364,7 @@ packages: /http-proxy-agent@4.0.1: resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} engines: {node: '>= 6'} + requiresBuild: true dependencies: '@tootallnate/once': 1.1.2 agent-base: 6.0.2 @@ -4352,6 +4394,7 @@ packages: /humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + requiresBuild: true dependencies: ms: 2.1.3 optional: true @@ -4365,6 +4408,7 @@ packages: /iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + requiresBuild: true dependencies: safer-buffer: 2.1.2 optional: true @@ -4405,10 +4449,12 @@ packages: /indent-string@4.0.0: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + requiresBuild: true optional: true /infer-owner@1.0.4: resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} + requiresBuild: true optional: true /inflight@1.0.6: @@ -4473,6 +4519,7 @@ packages: /ip@2.0.0: resolution: {integrity: sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==} + requiresBuild: true optional: true /ipaddr.js@1.9.1: @@ -4524,6 +4571,7 @@ packages: /is-lambda@1.0.1: resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + requiresBuild: true optional: true /is-number@7.0.0: @@ -4550,6 +4598,7 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + requiresBuild: true /istanbul-lib-coverage@3.2.0: resolution: {integrity: sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==} @@ -5351,6 +5400,7 @@ packages: /make-fetch-happen@9.1.0: resolution: {integrity: sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==} engines: {node: '>= 10'} + requiresBuild: true dependencies: agentkeepalive: 4.3.0 cacache: 15.3.0 @@ -5456,6 +5506,7 @@ packages: /minipass-collect@1.0.2: resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} engines: {node: '>= 8'} + requiresBuild: true dependencies: minipass: 3.3.6 optional: true @@ -5463,6 +5514,7 @@ packages: /minipass-fetch@1.4.1: resolution: {integrity: sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==} engines: {node: '>=8'} + requiresBuild: true dependencies: minipass: 3.3.6 minipass-sized: 1.0.3 @@ -5474,6 +5526,7 @@ packages: /minipass-flush@1.0.5: resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} engines: {node: '>= 8'} + requiresBuild: true dependencies: minipass: 3.3.6 optional: true @@ -5481,6 +5534,7 @@ packages: /minipass-pipeline@1.2.4: resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} engines: {node: '>=8'} + requiresBuild: true dependencies: minipass: 3.3.6 optional: true @@ -5488,6 +5542,7 @@ packages: /minipass-sized@1.0.3: resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} engines: {node: '>=8'} + requiresBuild: true dependencies: minipass: 3.3.6 optional: true @@ -5713,6 +5768,7 @@ packages: /npmlog@6.0.2: resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + requiresBuild: true dependencies: are-we-there-yet: 3.0.1 console-control-strings: 1.1.0 @@ -5825,6 +5881,7 @@ packages: /p-map@4.0.0: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} + requiresBuild: true dependencies: aggregate-error: 3.1.0 optional: true @@ -6095,6 +6152,7 @@ packages: /promise-inflight@1.0.1: resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} + requiresBuild: true peerDependencies: bluebird: '*' peerDependenciesMeta: @@ -6109,6 +6167,7 @@ packages: /promise-retry@2.0.1: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} + requiresBuild: true dependencies: err-code: 2.0.3 retry: 0.12.0 @@ -6414,6 +6473,7 @@ packages: /retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + requiresBuild: true optional: true /reusify@1.0.4: @@ -6606,11 +6666,13 @@ packages: /smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + requiresBuild: true optional: true /socks-proxy-agent@6.2.1: resolution: {integrity: sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==} engines: {node: '>= 10'} + requiresBuild: true dependencies: agent-base: 6.0.2 debug: 4.3.4 @@ -6622,6 +6684,7 @@ packages: /socks@2.7.1: resolution: {integrity: sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==} engines: {node: '>= 10.13.0', npm: '>= 3.0.0'} + requiresBuild: true dependencies: ip: 2.0.0 smart-buffer: 4.2.0 @@ -6687,6 +6750,7 @@ packages: /ssri@8.0.1: resolution: {integrity: sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==} engines: {node: '>= 8'} + requiresBuild: true dependencies: minipass: 3.3.6 optional: true @@ -7300,12 +7364,14 @@ packages: /unique-filename@1.1.1: resolution: {integrity: sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==} + requiresBuild: true dependencies: unique-slug: 2.0.2 optional: true /unique-slug@2.0.2: resolution: {integrity: sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==} + requiresBuild: true dependencies: imurmurhash: 0.1.4 optional: true diff --git a/shared/package.json b/shared/package.json index 21b7b06..9688ff6 100644 --- a/shared/package.json +++ b/shared/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@casl/ability": "^6.3.3", + "@joi/date": "^2.1.0", "joi": "^17.7.0" }, "jest": { diff --git a/shared/src/abilities.ts b/shared/src/abilities.ts index f76ff00..56a3e5d 100644 --- a/shared/src/abilities.ts +++ b/shared/src/abilities.ts @@ -7,6 +7,8 @@ import { } from "@casl/ability"; import { applyAbilitiesForPerson, Person, Role } from "./person"; import { Application, applyAbilitiesOnApplication } from "./application"; +import { applyAbilitiesOnAvailability, Availability } from "./availability"; +import { TimeSlot } from "./timeslot"; export interface UserAuth { sub: string; @@ -20,8 +22,12 @@ export enum Action { Update = "update", Delete = "delete", } -type SubjectsTypes = Partial | Partial; -type SubjectNames = "Person" | "Application"; +type SubjectsTypes = + | Partial + | Partial + | Partial + | Partial; +type SubjectNames = "Person" | "Application" | "Availability" | "TimeSlot"; export type Subjects = SubjectsTypes | SubjectNames; export type AppAbility = PureAbility<[Action, Subjects]>; @@ -37,6 +43,7 @@ export const abilityForUser = (user: UserAuth): AppAbility => { applyAbilitiesForPerson(user, builder); applyAbilitiesOnApplication(user, builder); + applyAbilitiesOnAvailability(user, builder); const { build } = builder; return build(); diff --git a/shared/src/application.spec.ts b/shared/src/application.spec.ts index 0e81fed..5ec6783 100644 --- a/shared/src/application.spec.ts +++ b/shared/src/application.spec.ts @@ -11,7 +11,6 @@ import { import { createMockAbility } from "./abilities.spec"; import { Action, UserAuth, checkAbility } from "./abilities"; import { Role } from "./person"; -import { subject } from "@casl/ability"; describe("Application", () => { const mockApplication: Partial = { diff --git a/shared/src/application.ts b/shared/src/application.ts index 5bbc0b5..c5683cf 100644 --- a/shared/src/application.ts +++ b/shared/src/application.ts @@ -1,7 +1,6 @@ import { Action, ApplyAbilities } from "./abilities"; import { Role } from "./person"; import * as Joi from "joi"; -// import { TimeSlot } from "./slot"; export const applicationsConfig = { BSC: { @@ -55,7 +54,7 @@ export interface Application { cv: any; // CV file grades?: any; // Grades file itaLevel: LangLevel; - + // TODO: Add slot bscApplication?: BscApplication; mscApplication?: MscApplication; phdApplication?: PhdApplication; @@ -177,6 +176,8 @@ export const updateApplicationSchema = Joi.object({ presence: "required", }); +/* Abilities */ + export const applyAbilitiesOnApplication: ApplyAbilities = ( user, { can, cannot } diff --git a/shared/src/availability.spec.ts b/shared/src/availability.spec.ts new file mode 100644 index 0000000..212a245 --- /dev/null +++ b/shared/src/availability.spec.ts @@ -0,0 +1,40 @@ +import { + AvailabilityState, + AvailabilityType, + Availability, + updateAvailabilitySchema, +} from "./availability"; +import { createMockAbility } from "./abilities.spec"; +import { Action, UserAuth, checkAbility } from "./abilities"; +import { Role } from "./person"; + +describe("Availability", () => { + describe("updateAvailabilitySchema", () => { + it("should allow a valid update", () => { + const updateAvailability = { + state: AvailabilityState.Confirmed, + timeSlotId: 123, + }; + const { error } = updateAvailabilitySchema.validate(updateAvailability); + expect(error).toBeDefined(); + }); + + it("should not allow updating with an invalid state", () => { + const updateAvailability = { + state: "Non_Existent_State", + timeSlotId: 123, + }; + const { error } = updateAvailabilitySchema.validate(updateAvailability); + expect(error).toBeDefined(); + }); + + it("should not allow updating with an invalid timeSlotId", () => { + const updateAvailability = { + state: AvailabilityState.Confirmed, + timeSlotId: -321, + }; + const { error } = updateAvailabilitySchema.validate(updateAvailability); + expect(error).toBeDefined(); + }); + }); +}); diff --git a/shared/src/availability.ts b/shared/src/availability.ts new file mode 100644 index 0000000..1cd0e38 --- /dev/null +++ b/shared/src/availability.ts @@ -0,0 +1,61 @@ +import { Action, ApplyAbilities } from "./abilities"; +import { Person, Role } from "./person"; +import * as Joi from "joi"; + +export enum AvailabilityState { + Subscribed = "subscribed", + Confirmed = "confirmed", + Cancelled = "cancelled", +} + +export enum AvailabilityType { + Available = "available", + Unavailable = "unavailable", +} + +export interface Availability { + state: AvailabilityState; + timeSlotId: number; + member: Person; + // assignedAt?: Date; + // confirmedAt?: Date; + // cancelledAt?: Date; +} + +/* Validation schemas */ + +export const updateAvailabilitySchema = Joi.object({ + state: Joi.string() + .valid(...Object.values(AvailabilityType)) + .required(), + timeSlotId: Joi.number().positive().required(), +}).options({ + stripUnknown: true, + abortEarly: false, + presence: "required", +}); + +/* Abilities */ + +export const applyAbilitiesOnAvailability: ApplyAbilities = ( + user, + { can, cannot } +) => { + switch (user.role) { + case Role.Admin: + case Role.Supervisor: + can(Action.Manage, "Availability"); + break; + case Role.Member: + case Role.Clerk: + can(Action.Read, "Availability"); + can(Action.Update, "Availability", { userId: user.sub }); + break; + case Role.Applicant: + can(Action.Read, "Availability", { userId: user.sub }); + can(Action.Update, "Availability", { userId: user.sub }); + break; + default: + cannot(Action.Manage, "Availability"); + } +}; diff --git a/shared/src/index.ts b/shared/src/index.ts index 81bf996..3402b34 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,3 +1,6 @@ export * from "./person"; export * from "./abilities"; export * from "./application"; +export * from "./availability"; +export * from "./timeslot"; +export * from "./slot"; diff --git a/shared/src/slot.ts b/shared/src/slot.ts new file mode 100644 index 0000000..5b31ebd --- /dev/null +++ b/shared/src/slot.ts @@ -0,0 +1,16 @@ +import { TimeSlot } from "./timeslot"; + +export enum SlotState { + Free = "free", + Assigned = "assigned", + Rejected = "rejected", + Reserved = "reserved", +} + +export interface Slot { + state: SlotState; + timeSlot: TimeSlot; + calendarId?: string; +} + +/* Validation schemas */ diff --git a/shared/src/timeslot.ts b/shared/src/timeslot.ts new file mode 100644 index 0000000..fea1951 --- /dev/null +++ b/shared/src/timeslot.ts @@ -0,0 +1,47 @@ +import { Action, ApplyAbilities } from "./abilities"; +import { Role } from "./person"; +import DateExtension from "@joi/date"; +import * as Joi from "joi"; +const JoiDate = Joi.extend(DateExtension); + +// import BaseJoi from "joi"; +// const Joi = BaseJoi.extend(JoiDate); + +export interface TimeSlot { + start: Date; + end: Date; +} + +/* Validation schemas */ + +export const createTimeSlotSchema = Joi.object({ + start: JoiDate.date().format("YYYY-MM-DD HH:mm").required(), + end: JoiDate.date().format("YYYY-MM-DD HH:mm").required(), +}).options({ + stripUnknown: true, + abortEarly: false, + presence: "required", +}); + +/* Abilities */ + +export const applyAbilitiesOnTimeSlot: ApplyAbilities = ( + user, + { can, cannot } +) => { + can(Action.Manage, "TimeSlot"); + // switch (user.role) { + // case Role.Admin: + // case Role.Supervisor: + // case Role.Clerk: + // // TODO: Decide who can create/delete timeslots + // can(Action.Manage, "TimeSlot"); + // break; + // case Role.Member: + // case Role.Applicant: + // can(Action.Read, "TimeSlot"); + // break; + // default: + // cannot(Action.Manage, "TimeSlot"); + // } +};