diff --git a/.gitlab/issue_templates/bug.md b/.gitlab/issue_templates/bug.md new file mode 100644 index 0000000000000000000000000000000000000000..4aabf73667e583c772e1f9ae0c4487a4a31c155a --- /dev/null +++ b/.gitlab/issue_templates/bug.md @@ -0,0 +1,46 @@ +## Description du problème + +_Donnez une briève description du problème_ + +## L'environnement + +#### Utilisez vous l'application sur : + +- [ ] Mobile +- [ ] Ordinateur + +##### En cas de mobile + +###### Quel type de mobile utilisez-vous? + +- [ ] Android +- [ ] Iphone + +###### Quel navigateur utilisez-vous? + +- [ ] Chrome +- [ ] Safari +- [ ] Autre + +##### En cas d'ordinateur + +###### Quel navigateur utilisez-vous? + +- [ ] Chrome +- [ ] Firefox +- [ ] Safari +- [ ] Autre + +## Le bug + +#### Quelles sont les étapes qui ont menées au problème? + +_Donnez une description des étapes, il est fortemment conseillé de l'accompagner par des captures d'écran_ + +#### Quel est le comportement obtenu? + +_Donnez une description du comportement obtenu, il est fortemment conseillé de l'accompagner par des captures d'écran_ + +#### Quel est le comportement attendu? + +_Donnez une description du comportement attendu_ diff --git a/README.md b/README.md index b42a00f565b5af59c7a86633dae94af758db373c..81dfff4ff237e7c40910e2d9d3e1fae3e69179d3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 [circleci-url]: https://circleci.com/gh/nestjs/nest - + <p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p> <p align="center"> <a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a> @@ -47,7 +47,6 @@ $ npm install ### Base de donnée - ```bash $ docker-compose up -d database-ram $ docker-compose up -d mongo-express @@ -70,17 +69,20 @@ $ npm run start:prod ```bash # Lien vers le swagger -$ http://localhost:3000/api +$ http://localhost:3000/doc # Lien vers le mongo-express $ http://localhost:8081 ``` -## Documentation +## Documentation + A documentation is generated with compodoc in addition of the existing documentation on the wiki. + ```sh npm run doc:serve ``` + You can now visualize it at : `localhost:8080` ## Test diff --git a/package.json b/package.json index d04b8b75788e9abce5c79a143d4f5d65e2747092..085845eb99348cc1e4392877ee20051c9282e8a8 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "release": "standard-version", "init-db": "node ./scripts/init-db.js", "test": "jest", - "test:watch": "jest --config ./test/jest.json --watch", + "test:watch": "jest --config ./test/jest.json --watch --coverage", "test:cov": "jest --config ./test/jest.json --coverage --ci --reporters=default --reporters=jest-junit", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", diff --git a/src/admin/admin.controller.spec.ts b/src/admin/admin.controller.spec.ts index 7d859527c4792d72464fabbf2781da7e7ef89eb3..c3e8b200ea5df78c072f272e1bdefa64ca23ad40 100644 --- a/src/admin/admin.controller.spec.ts +++ b/src/admin/admin.controller.spec.ts @@ -1,27 +1,66 @@ import { HttpModule } from '@nestjs/common'; import { getModelToken } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; +import { Types } from 'mongoose'; +import { mockJwtAuthGuard } from '../../test/mock/guards/jwt-auth.mock.guard'; +import { mockRoleGuard } from '../../test/mock/guards/role.mock.guard'; +import { NewsletterServiceMock } from '../../test/mock/services/newsletter.mock.service'; +import { StructuresServiceMock } from '../../test/mock/services/structures.mock.service'; +import { UsersServiceMock } from '../../test/mock/services/user.mock.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { ConfigurationModule } from '../configuration/configuration.module'; import { MailerService } from '../mailer/mailer.service'; import { NewsletterSubscription } from '../newsletter/newsletter-subscription.schema'; import { NewsletterService } from '../newsletter/newsletter.service'; import { SearchModule } from '../search/search.module'; import { Structure } from '../structures/schemas/structure.schema'; -import { StructuresService } from '../structures/services/structures.service'; import { StructuresSearchService } from '../structures/services/structures-search.service'; +import { StructuresService } from '../structures/services/structures.service'; +import { RolesGuard } from '../users/guards/roles.guard'; import { User } from '../users/schemas/user.schema'; +import { EmployerSearchService } from '../users/services/employer-search.service'; +import { EmployerService } from '../users/services/employer.service'; +import { JobsService } from '../users/services/jobs.service'; import { UsersService } from '../users/services/users.service'; import { AdminController } from './admin.controller'; -import { mockJwtAuthGuard } from '../../test/mock/guards/jwt-auth.mock.guard'; -import { mockRoleGuard } from '../../test/mock/guards/role.mock.guard'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { RolesGuard } from '../users/guards/roles.guard'; -import { UsersServiceMock } from '../../test/mock/services/user.mock.service'; -import { StructuresServiceMock } from '../../test/mock/services/structures.mock.service'; -import { NewsletterServiceMock } from '../../test/mock/services/newsletter.mock.service'; describe('AdminController', () => { let controller: AdminController; + let userService: UsersService; + + const mockEmployerSearchService = { + indexEmployer: jest.fn(), + search: jest.fn(), + dropIndex: jest.fn(), + createEmployerIndex: jest.fn(), + deleteIndex: jest.fn(), + }; + + const mockEmployerService = { + findOne: jest.fn(), + deleteOneId: jest.fn(), + findByName: jest.fn(), + findAllValidated: jest.fn(), + findAllUnvalidated: jest.fn(), + createEmployerFromAdmin: jest.fn(), + validate: jest.fn(), + update: jest.fn(), + mergeEmployer: jest.fn(), + deleteInvalidEmployer: jest.fn(), + }; + + const mockJobService = { + findOne: jest.fn(), + findByName: jest.fn(), + deleteOneId: jest.fn(), + findAll: jest.fn(), + findAllUnvalidated: jest.fn(), + createJobFromAdmin: jest.fn(), + validate: jest.fn(), + update: jest.fn(), + mergeJob: jest.fn(), + deleteInvalidJob: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -41,6 +80,18 @@ describe('AdminController', () => { }, StructuresSearchService, MailerService, + { + provide: EmployerService, + useValue: mockEmployerService, + }, + { + provide: JobsService, + useValue: mockJobService, + }, + { + provide: EmployerSearchService, + useValue: mockEmployerSearchService, + }, { provide: getModelToken('User'), useValue: User, @@ -63,6 +114,7 @@ describe('AdminController', () => { .compile(); controller = module.get<AdminController>(AdminController); + userService = module.get<UsersService>(UsersService); }); it('should be defined', () => { @@ -71,7 +123,7 @@ describe('AdminController', () => { it('should get pending attachments', async () => { expect((await controller.getPendingAttachments()).length).toBe(2); - expect(Object.keys((await controller.getPendingAttachments())[0]).length).toBe(3); + expect(Object.keys((await controller.getPendingAttachments())[0]).length).toBe(4); }); describe('Pending structures validation', () => { @@ -80,9 +132,10 @@ describe('AdminController', () => { structureId: '6093ba0e2ab5775cfc01ed3e', structureName: 'test', userEmail: 'jean.paul@mii.com', + updatedAt: '2021-03-02T10:07:48.000Z', }; expect((await controller.validatePendingStructure(pendingStructureTest)).length).toBe(2); - expect(Object.keys((await controller.validatePendingStructure(pendingStructureTest))[0]).length).toBe(3); + expect(Object.keys((await controller.validatePendingStructure(pendingStructureTest))[0]).length).toBe(4); }); it('should get structure does not exist', async () => { @@ -90,6 +143,7 @@ describe('AdminController', () => { structureId: '1093ba0e2ab5775cfc01z2ki', structureName: 'test', userEmail: 'jean.paul@mii.com', + updatedAt: '2021-03-02T10:07:48.000Z', }; try { await controller.validatePendingStructure(pendingStructureTest); @@ -106,9 +160,10 @@ describe('AdminController', () => { structureId: '6093ba0e2ab5775cfc01ed3e', structureName: 'test', userEmail: 'jean.paul@mii.com', + updatedAt: '2021-03-02T10:07:48.000Z', }; expect((await controller.refusePendingStructure(pendingStructureTest)).length).toBe(2); - expect(Object.keys((await controller.refusePendingStructure(pendingStructureTest))[0]).length).toBe(3); + expect(Object.keys((await controller.refusePendingStructure(pendingStructureTest))[0]).length).toBe(4); }); it('should get structure does not exist', async () => { @@ -116,6 +171,7 @@ describe('AdminController', () => { structureId: '1093ba0e2ab5775cfc01z2ki', structureName: 'test', userEmail: 'jean.paul@mii.com', + updatedAt: '2021-03-02T10:07:48.000Z', }; try { await controller.refusePendingStructure(pendingStructureTest); @@ -140,6 +196,134 @@ describe('AdminController', () => { }); }); + describe('setUserEmployer', () => { + it('should set a new employer to the user', async () => { + const spyer = jest.spyOn(userService, 'updateUserEmployer'); + const mockUserId = '6231aefe76598527c8d0b5bc'; + const mockEmployer = { + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Sopra', + validated: true, + }; + mockEmployerService.findOne.mockResolvedValueOnce(mockEmployer); + const reply = await controller.setUserEmployer({ + userId: mockUserId, + employerId: String(mockEmployer._id), + }); + expect(spyer).toBeCalledTimes(1); + expect(spyer).toBeCalledWith(mockUserId, mockEmployer); + expect(reply).toBeTruthy(); + }); + + it('should not set a new employer to the user if the employer does not exist', async () => { + const spyer = jest.spyOn(userService, 'updateUserEmployer'); + const mockUserId = '6231aefe76598527c8d0b5bc'; + const mockEmployer = { + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Sopra', + validated: true, + }; + mockEmployerService.findOne.mockResolvedValueOnce(null); + try { + await controller.setUserEmployer({ + userId: mockUserId, + employerId: String(mockEmployer._id), + }); + expect(true).toBe(false); + } catch (e) { + expect(spyer).toBeCalledTimes(0); + expect(e.message).toBe('Employer does not exist'); + expect(e.status).toBe(400); + } + }); + + it('should not set a new employer to the user if the user does not exist', async () => { + const spyer = jest.spyOn(userService, 'updateUserEmployer'); + const mockUserId = 'thisuserdoesnotexist'; + const mockEmployer = { + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Sopra', + validated: true, + }; + mockEmployerService.findOne.mockResolvedValueOnce(mockEmployer); + try { + await controller.setUserEmployer({ + userId: mockUserId, + employerId: String(mockEmployer._id), + }); + expect(true).toBe(false); + } catch (e) { + expect(spyer).toBeCalledTimes(0); + expect(e.message).toBe('User does not exist'); + expect(e.status).toBe(400); + } + }); + }); + + describe('setUserJob', () => { + it('should set a new job to the user', async () => { + const spyer = jest.spyOn(userService, 'updateUserJob'); + const mockUserId = '6231aefe76598527c8d0b5bc'; + const mockJob = { + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Toto', + validated: true, + }; + mockJobService.findOne.mockResolvedValueOnce(mockJob); + const reply = await controller.setUserJob({ + userId: mockUserId, + jobId: String(mockJob._id), + }); + expect(spyer).toBeCalledTimes(1); + expect(spyer).toBeCalledWith(mockUserId, mockJob); + expect(reply).toBeTruthy(); + }); + + it('should not set a new job to the user if the job does not exist', async () => { + const spyer = jest.spyOn(userService, 'updateUserJob'); + const mockUserId = '6231aefe76598527c8d0b5bc'; + const mockJob = { + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Dev', + validated: true, + }; + mockJobService.findOne.mockResolvedValueOnce(null); + try { + await controller.setUserJob({ + userId: mockUserId, + jobId: String(mockJob._id), + }); + expect(true).toBe(false); + } catch (e) { + expect(spyer).toBeCalledTimes(0); + expect(e.message).toBe('Job does not exist'); + expect(e.status).toBe(400); + } + }); + + it('should not set a new job to the user if the user does not exist', async () => { + const spyer = jest.spyOn(userService, 'updateUserJob'); + const mockUserId = 'thisuserdoesntexist'; + const mockJob = { + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Dev', + validated: true, + }; + mockJobService.findOne.mockResolvedValueOnce(mockJob); + try { + await controller.setUserJob({ + userId: mockUserId, + jobId: String(mockJob._id), + }); + expect(true).toBe(false); + } catch (e) { + expect(spyer).toBeCalledTimes(0); + expect(e.message).toBe('User does not exist'); + expect(e.status).toBe(400); + } + }); + }); + describe('Search user', () => { it('should return all users, empty string', async () => { expect((await controller.searchUsers({ searchString: '' })).length).toBe(2); @@ -156,7 +340,7 @@ describe('AdminController', () => { }); }); - describe('Search user newleetter subscription', () => { + describe('Search user newsletter subscription', () => { it('should return all subscribed users, empty string', async () => { expect((await controller.getNewsletterSubscriptions({ searchString: '' })).length).toBe(3); }); diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts index c3de17af253a1a1cd8c396fefd20fbe628bad364..92ff38d7eafa64e23a2fdcb9fa5f4be95cb2ded2 100644 --- a/src/admin/admin.controller.ts +++ b/src/admin/admin.controller.ts @@ -1,27 +1,33 @@ -import { ApiOperation, ApiParam } from '@nestjs/swagger'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { Body, - Delete, - Param, Controller, + Delete, Get, - Post, - UseGuards, - HttpStatus, HttpException, + HttpStatus, Logger, + Param, + Post, + Put, + UseGuards, } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { validate } from 'class-validator'; +import { DateTime, Interval } from 'luxon'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { NewsletterSubscription } from '../newsletter/newsletter-subscription.schema'; import { NewsletterService } from '../newsletter/newsletter.service'; +import { Structure } from '../structures/schemas/structure.schema'; import { StructuresService } from '../structures/services/structures.service'; import { Roles } from '../users/decorators/roles.decorator'; import { RolesGuard } from '../users/guards/roles.guard'; +import { IUser } from '../users/interfaces/user.interface'; +import { EmployerService } from '../users/services/employer.service'; +import { JobsService } from '../users/services/jobs.service'; import { UsersService } from '../users/services/users.service'; import { PendingStructureDto } from './dto/pending-structure.dto'; -import { validate } from 'class-validator'; -import { Structure } from '../structures/schemas/structure.schema'; -import { IUser } from '../users/interfaces/user.interface'; +import { SetUserEmployerDto } from './dto/set-user-employer.dto'; +import { SetUserJobDto } from './dto/set-user-job.dto'; @Controller('admin') export class AdminController { @@ -29,18 +35,22 @@ export class AdminController { constructor( private usersService: UsersService, private structuresService: StructuresService, + private jobsService: JobsService, + private employerService: EmployerService, private newsletterService: NewsletterService ) {} @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin') @Get('pendingStructures') - @ApiOperation({ description: 'Get pending structre for validation' }) + @ApiOperation({ description: 'Get pending structures for validation' }) public async getPendingAttachments(): Promise<PendingStructureDto[]> { const pendingStructure = await this.usersService.getPendingStructures(); return Promise.all( pendingStructure.map(async (structure) => { - structure.structureName = (await this.structuresService.findOne(structure.structureId)).structureName; + const structureDocument = await this.structuresService.findOne(structure.structureId); + structure.structureName = structureDocument.structureName; + structure.updatedAt = structureDocument.updatedAt; return structure; }) ); @@ -49,13 +59,30 @@ export class AdminController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin') @Get('adminStructuresList') - @ApiOperation({ description: 'Get pending structre for validation' }) + @ApiOperation({ description: 'Get pending structures for validation' }) public async getAdminStructuresList(): Promise<any> { const structuresList = { claimed: [], inClaim: [], toClaim: [], incomplete: [] }; - structuresList.inClaim = await this.getPendingAttachments(); - structuresList.toClaim = (await this.structuresService.findAllUnclaimed()).filter( - (demand) => !structuresList.inClaim.find((elem) => elem.structureId == demand.structureId) - ); + const today = DateTime.local().setZone('utc', { keepLocalTime: true }); + const inClaimStructures = await this.getPendingAttachments(); + structuresList.inClaim = inClaimStructures.map((structure) => { + return { + structureId: structure.structureId, + structureName: structure.structureName, + updatedAt: structure.updatedAt, + isOutdated: Interval.fromDateTimes(DateTime.fromISO(structure.updatedAt), today).length('months') > 6, + }; + }); + const toClaimStructures = await this.structuresService.findAllUnclaimed(); + structuresList.toClaim = toClaimStructures + .filter((demand) => !structuresList.inClaim.find((elem) => elem.structureId == demand.structureId)) + .map((structure) => { + return { + structureId: structure.structureId, + structureName: structure.structureName, + updatedAt: structure.updatedAt, + isOutdated: Interval.fromDateTimes(DateTime.fromISO(structure.updatedAt), today).length('months') > 6, + }; + }); const allStructures = await this.structuresService.findAll(); structuresList.claimed = allStructures .filter( @@ -64,14 +91,25 @@ export class AdminController { !structuresList.toClaim.find((elem) => elem.structureId == demand.id) ) .map((structure) => { - return { structureId: structure.id, structureName: structure.structureName }; + return { + structureId: structure.id, + structureName: structure.structureName, + updatedAt: structure.updatedAt, + isOutdated: Interval.fromDateTimes(DateTime.fromISO(structure.updatedAt), today).length('months') > 6, + }; }); structuresList.incomplete = await Promise.all( allStructures.map(async (struct) => { const validity = await validate(new Structure(struct)); if (validity.length > 0) { this.logger.debug(`getAdminStructuresList - validation failed. errors: ${validity.toString()}`); - return { structureId: struct.id, structureName: struct.structureName }; + + return { + structureId: struct.id, + structureName: struct.structureName, + updatedAt: struct.updatedAt, + isOutdated: Interval.fromDateTimes(DateTime.fromISO(struct.updatedAt), today).length('months') > 6, + }; } else { this.logger.debug('getAdminStructuresList - validation succeed'); return null; @@ -141,6 +179,7 @@ export class AdminController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin') @Delete('user/:id') + @ApiBearerAuth('JWT') @ApiParam({ name: 'id', type: String, required: true }) public async deleteUser(@Param() params) { const user = await this.usersService.deleteOneId(params.id); @@ -156,6 +195,7 @@ export class AdminController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin') + @ApiBearerAuth('JWT') @Post('searchUsers') public async searchUsers(@Body() searchString: { searchString: string }) { if (searchString && searchString.searchString && searchString.searchString.length > 0) @@ -165,14 +205,28 @@ export class AdminController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin') - @Get('getUnAttachedUsers') + @ApiBearerAuth('JWT') + @Get('unAttachedUsers') public async findUnattachedUsers() { - return this.usersService.findAllUnattached(); + return this.usersService.findAllUnattached().then((formatUsers) => { + return formatUsers.map((user) => { + return { + id: user._id, + surname: user.surname, + name: user.name, + email: user.email, + phone: user.phone, + job: user.job, + employer: user.employer, + }; + }); + }); } @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin') - @Get('getAttachedUsers') + @ApiBearerAuth('JWT') + @Get('attachedUsers') public async findAttachedUsers() { return this.usersService.findAllAttached().then(async (users: IUser[]) => { return this.structuresService.getAllUserCompletedStructures(users); @@ -180,8 +234,9 @@ export class AdminController { } @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT') @Roles('admin') - @Get('getUnVerifiedUsers') + @Get('unVerifiedUsers') public async findUnVerifiedUsers() { return this.usersService.findAllUnVerified().then(async (users: IUser[]) => { return this.structuresService.getAllUserCompletedStructures(users); @@ -190,6 +245,7 @@ export class AdminController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin') + @ApiBearerAuth('JWT') @Post('searchNewsletterSubscriptions') public async getNewsletterSubscriptions(@Body() searchString: { searchString: string }) { if (searchString && searchString.searchString && searchString.searchString.length > 0) @@ -199,6 +255,7 @@ export class AdminController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin') + @ApiBearerAuth('JWT') @Get('countNewsletterSubscriptions') public async countNewsletterSubscriptions(): Promise<number> { return this.newsletterService.countNewsletterSubscriptions(); @@ -206,9 +263,60 @@ export class AdminController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles('admin') + @ApiBearerAuth('JWT') @Delete('newsletterSubscription/:email') @ApiParam({ name: 'email', type: String, required: true }) public async unsubscribeUserFromNewsletter(@Param() params): Promise<NewsletterSubscription> { return this.newsletterService.newsletterUnsubscribe(params.email); } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin') + @ApiBearerAuth('JWT') + @ApiOperation({ description: 'Set user job' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Return user profile' }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'User does not exist' }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Job does not exist' }) + @Put('setUserJob') + public async setUserJob(@Body() setUserJob: SetUserJobDto): Promise<IUser> { + this.logger.debug(`setUserJob`); + const jobDocument = await this.jobsService.findOne(setUserJob.jobId); + if (!jobDocument) { + this.logger.warn(`Job does not exist: ${setUserJob.jobId}`); + throw new HttpException('Job does not exist', HttpStatus.BAD_REQUEST); + } + + const userDocument = await this.usersService.findById(setUserJob.userId); + if (!userDocument) { + this.logger.warn(`User does not exist: ${setUserJob.userId}`); + throw new HttpException('User does not exist', HttpStatus.BAD_REQUEST); + } + await this.usersService.updateUserJob(userDocument._id, jobDocument); + return this.usersService.findById(setUserJob.userId); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin') + @ApiBearerAuth('JWT') + @ApiOperation({ description: 'Set user employer' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Return user profile' }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'User does not exist' }) + @ApiResponse({ status: HttpStatus.BAD_REQUEST, description: 'Employer does not exist' }) + @Put('setUserEmployer') + public async setUserEmployer(@Body() setUserEmployer: SetUserEmployerDto): Promise<IUser> { + this.logger.debug(`setUserEmployer`); + const employerDocument = await this.employerService.findOne(setUserEmployer.employerId); + if (!employerDocument) { + this.logger.warn(`Employer does not exist: ${setUserEmployer.employerId}`); + throw new HttpException('Employer does not exist', HttpStatus.BAD_REQUEST); + } + + const userDocument = await this.usersService.findById(setUserEmployer.userId); + if (!userDocument) { + this.logger.warn(`User does not exist: ${setUserEmployer.userId}`); + throw new HttpException('User does not exist', HttpStatus.BAD_REQUEST); + } + await this.usersService.updateUserEmployer(userDocument._id, employerDocument); + return this.usersService.findById(setUserEmployer.userId); + } } diff --git a/src/admin/dto/merge-employer.dto.ts b/src/admin/dto/merge-employer.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..53cbcef191aea707c4989290f7da2edd44fe4a8c --- /dev/null +++ b/src/admin/dto/merge-employer.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty } from 'class-validator'; + +export class MergeEmployerDto { + @IsNotEmpty() + @ApiProperty({ type: String }) + sourceEmployerId: string; + + @IsNotEmpty() + @ApiProperty({ type: String }) + targetEmployerId: string; +} diff --git a/src/admin/dto/merge-job.dto.ts b/src/admin/dto/merge-job.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..d661802522cafaf1c1c019fe128f9ed751d5c1ee --- /dev/null +++ b/src/admin/dto/merge-job.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty } from 'class-validator'; + +export class MergeJobDto { + @IsNotEmpty() + @ApiProperty({ type: String }) + sourceJobId: string; + + @IsNotEmpty() + @ApiProperty({ type: String }) + targetJobId: string; +} diff --git a/src/admin/dto/pending-structure.dto.ts b/src/admin/dto/pending-structure.dto.ts index 1f735be0f4256472524dba43286ab53cba836fcc..84d20b18b2490a8b062f931e272f8c5cc12363c0 100644 --- a/src/admin/dto/pending-structure.dto.ts +++ b/src/admin/dto/pending-structure.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsMongoId, IsNotEmpty, IsString } from 'class-validator'; +import { IsDate, IsEmail, IsMongoId, IsNotEmpty, IsString } from 'class-validator'; export class PendingStructureDto { @IsNotEmpty() @@ -16,4 +16,9 @@ export class PendingStructureDto { @IsString() @ApiProperty({ type: String }) structureName: string; + + @IsNotEmpty() + @IsDate() + @ApiProperty({ type: String }) + updatedAt: string; } diff --git a/src/admin/dto/set-user-employer.dto.ts b/src/admin/dto/set-user-employer.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd824100fbaa748d25a46294854ab4f3c913e416 --- /dev/null +++ b/src/admin/dto/set-user-employer.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SetUserEmployerDto { + @IsNotEmpty() + @ApiProperty({ type: String }) + userId: string; + + @IsNotEmpty() + @ApiProperty({ type: String }) + employerId: string; +} diff --git a/src/admin/dto/set-user-job.dto.ts b/src/admin/dto/set-user-job.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..e622e5d21a61ef2f6c9b37da18312e6f954e7268 --- /dev/null +++ b/src/admin/dto/set-user-job.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SetUserJobDto { + @IsNotEmpty() + @ApiProperty({ type: String }) + userId: string; + + @IsNotEmpty() + @ApiProperty({ type: String }) + jobId: string; +} diff --git a/src/admin/dto/unclaimed-structure-dto.ts b/src/admin/dto/unclaimed-structure-dto.ts index c57eedbd30d908debab1d539676731e1a7c79388..b9d7632b917869c637d5e97deeb7abf6f1c7955c 100644 --- a/src/admin/dto/unclaimed-structure-dto.ts +++ b/src/admin/dto/unclaimed-structure-dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsDate, IsNotEmpty, IsString } from 'class-validator'; export class UnclaimedStructureDto { @IsNotEmpty() @@ -11,4 +11,9 @@ export class UnclaimedStructureDto { @IsString() @ApiProperty({ type: String }) structureName: string; + + @IsNotEmpty() + @IsDate() + @ApiProperty({ type: String }) + updatedAt: string; } diff --git a/src/configuration/config.ts b/src/configuration/config.ts index f81023c4e753c40854020b92c241d54d156772f9..67e56cc1eccac899f0a3b2f65ee2596b0cd24911 100644 --- a/src/configuration/config.ts +++ b/src/configuration/config.ts @@ -61,6 +61,14 @@ export const config = { ejs: 'structureDeletionNotification.ejs', json: 'structureDeletionNotification.json', }, + adminJobCreate: { + ejs: 'adminJobCreate.ejs', + json: 'adminJobCreate.json', + }, + adminEmployerCreate: { + ejs: 'adminEmployerCreate.ejs', + json: 'adminEmployerCreate.json', + }, contactMessage: { ejs: 'contactMessage.ejs', json: 'contactMessage.json', diff --git a/src/mailer/mail-templates/adminEmployerCreate.ejs b/src/mailer/mail-templates/adminEmployerCreate.ejs new file mode 100644 index 0000000000000000000000000000000000000000..35ec278fc3ccd874f8550d91cba3f7a3839ea227 --- /dev/null +++ b/src/mailer/mail-templates/adminEmployerCreate.ejs @@ -0,0 +1,6 @@ +Bonjour,<br /> +<br /> +Un nouvel employeur <%= employerName %> vient d'être créé, +<a href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/admin>" + >cliquez ici pour le valider.</a +> diff --git a/src/mailer/mail-templates/adminEmployerCreate.json b/src/mailer/mail-templates/adminEmployerCreate.json new file mode 100644 index 0000000000000000000000000000000000000000..e92f86bef76c521db3275f3ab8908c202d813aa7 --- /dev/null +++ b/src/mailer/mail-templates/adminEmployerCreate.json @@ -0,0 +1,3 @@ +{ + "subject": "Nouvelle création d'employeur" +} diff --git a/src/mailer/mail-templates/adminJobCreate.ejs b/src/mailer/mail-templates/adminJobCreate.ejs new file mode 100644 index 0000000000000000000000000000000000000000..26262721d55b682a0077214a0e586867d2fe0c44 --- /dev/null +++ b/src/mailer/mail-templates/adminJobCreate.ejs @@ -0,0 +1,6 @@ +Bonjour,<br /> +<br /> +Une nouvelle fonction <%= jobName %> vient d'être créée, +<a href="<%= config.protocol %>://<%= config.host %><%= config.port ? ':' + config.port : '' %>/admin>" + >cliquez ici pour la valider.</a +> diff --git a/src/mailer/mail-templates/adminJobCreate.json b/src/mailer/mail-templates/adminJobCreate.json new file mode 100644 index 0000000000000000000000000000000000000000..10e50f6e73f662ae58cb3c90150ba785f4a42614 --- /dev/null +++ b/src/mailer/mail-templates/adminJobCreate.json @@ -0,0 +1,3 @@ +{ + "subject": "Nouvelle création de fonction" +} diff --git a/src/shared/utils.ts b/src/shared/utils.ts index e48d93157982e7fdd7b70139af8bfccc4cbf2b90..0bca461a48a19ee86e63a7ddf490d0555a4b1ed2 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -1,6 +1,8 @@ import { ConfigurationService } from '../configuration/configuration.service'; import { Page } from '../pages/schemas/page.schema'; import { Post } from '../posts/schemas/post.schema'; +import { UserRole } from '../users/enum/user-role.enum'; +import { User } from '../users/schemas/user.schema'; export function rewriteGhostImgUrl(configService: ConfigurationService, itemData: Page | Post): Page | Post { // Handle image display. Rewrite image URL to fit ghost infra issue. @@ -15,3 +17,7 @@ export function rewriteGhostImgUrl(configService: ConfigurationService, itemData } return itemData; } + +export function hasAdminRole(user: User): boolean { + return user.role === UserRole.admin; +} diff --git a/src/structures/services/structures.service.ts b/src/structures/services/structures.service.ts index 21d458e6d8e393d89f853ea047605e32284691cc..fa1bdb55f594faf9accf125df99c85729c083c09 100644 --- a/src/structures/services/structures.service.ts +++ b/src/structures/services/structures.service.ts @@ -240,7 +240,11 @@ export class StructuresService { await Promise.all( structures.map(async (structure: StructureDocument) => { if (!(await this.userService.isStructureClaimed(structure.id))) { - unclaimedStructures.push({ structureId: structure.id, structureName: structure.structureName }); + unclaimedStructures.push({ + structureId: structure.id, + structureName: structure.structureName, + updatedAt: structure.updatedAt, + }); } }) ); @@ -778,6 +782,8 @@ export class StructuresService { name: user.name, email: user.email, phone: user.phone, + job: user.job, + employer: user.employer, structures: await Promise.all( user.structuresLink.map(async (id) => { return this.findOne(id.toHexString()); diff --git a/src/users/controllers/employer.controller.spec.ts b/src/users/controllers/employer.controller.spec.ts index f83962fced079b73a408b3b5b8688c41b09fca27..347edb87cc0f9d03e9e0af25a3fad621b62554da 100644 --- a/src/users/controllers/employer.controller.spec.ts +++ b/src/users/controllers/employer.controller.spec.ts @@ -1,15 +1,17 @@ -import { HttpModule } from '@nestjs/common'; +import { HttpModule, HttpStatus } from '@nestjs/common'; import { getModelToken } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; import { Types } from 'mongoose'; +import { UsersServiceMock } from '../../../test/mock/services/user.mock.service'; import { ConfigurationModule } from '../../configuration/configuration.module'; -import { CreateEmployerDto } from '../dto/create-employer.dto'; import { Employer } from '../schemas/employer.schema'; import { EmployerService } from '../services/employer.service'; +import { UsersService } from '../services/users.service'; import { EmployerController } from './employer.controller'; describe('EmployerController', () => { let controller: EmployerController; + let userService: UsersService; const employerServiceMock = { findAll: jest.fn(), @@ -18,12 +20,24 @@ describe('EmployerController', () => { create: jest.fn(), deleteByName: jest.fn(), initEmployerIndex: jest.fn(), + findOne: jest.fn(), + deleteOneId: jest.fn(), + findAllValidated: jest.fn(), + findAllUnvalidated: jest.fn(), + validate: jest.fn(), + update: jest.fn(), + mergeEmployer: jest.fn(), + deleteInvalidEmployer: jest.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ConfigurationModule, HttpModule], providers: [ + { + provide: UsersService, + useClass: UsersServiceMock, + }, { provide: EmployerService, useValue: employerServiceMock, @@ -37,6 +51,7 @@ describe('EmployerController', () => { }).compile(); controller = module.get<EmployerController>(EmployerController); + userService = module.get<UsersService>(UsersService); }); it('should be defined', () => { @@ -77,89 +92,208 @@ describe('EmployerController', () => { expect(findAllReply.length).toBe(2); }); }); - describe('createEmployer', () => { - it('should create new employer `Metro`', async () => { - const newEmployer: CreateEmployerDto = { - name: 'Metro', - }; - const newCreatedEmployer: Employer = { - name: 'Metro', - validated: false, - }; + + // describe('deleteEmployer', () => { + // it('should delete employer `Metro`', async () => { + // const employer: Employer = { + // name: 'Metro', + // validated: true, + // }; + // const employerToRemove: CreateEmployerDto = { + // name: 'Metro', + // }; + // employerServiceMock.findByName.mockResolvedValueOnce(employer); + // employerServiceMock.deleteByName.mockResolvedValueOnce({ + // ok: 1, + // n: 1, + // deletedCount: 1, + // }); + // const deleteEmployer = await controller.deleteEmployer(employerToRemove); + // expect(deleteEmployer.name).toBe('Metro'); + // expect(deleteEmployer.validated).toBe(true); + // }); + + // it('should throw error on unexisting employer `Metro`', async () => { + // const employerToRemove: CreateEmployerDto = { + // name: 'Metro', + // }; + // employerServiceMock.deleteByName.mockResolvedValueOnce(null); + // try { + // await controller.deleteEmployer(employerToRemove); + // expect(true).toBe(false); + // } catch (e) { + // expect(e.message).toBe('Employer does not exist'); + // expect(e.status).toBe(404); + // } + // }); + // }); + describe('resetES', () => { + it('should reset search index', async () => { + employerServiceMock.initEmployerIndex.mockResolvedValueOnce([ + { + _id: Types.ObjectId('6231aefe76598527c8d0b5a7'), + name: 'CAF', + validated: true, + }, + { + _id: Types.ObjectId('6231aefe76598527c8d0b5a7'), + name: 'CARSAT', + validated: true, + }, + ]); + const index = await controller.resetES(); + expect(index.length).toBe(2); + }); + }); + + describe('Create Employer', () => { + it('should create a employer', async () => { employerServiceMock.findByName.mockResolvedValueOnce(null); - employerServiceMock.create.mockResolvedValueOnce(newCreatedEmployer); - const createReply = await controller.createEmployer(newEmployer); - expect(createReply).toEqual(newCreatedEmployer); + employerServiceMock.create.mockResolvedValueOnce({ + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Sopra', + validated: true, + }); + const req = { user: { _id: '6036721022462b001334c4bb', role: 0 } }; + const reply = await controller.createEmployer({ name: 'Sopra' }, req); + expect(reply).toBeTruthy(); }); - it('should throw error on already existing employer `Metro`', async () => { - const newEmployer: CreateEmployerDto = { - name: 'Metro', - }; + it('should not create if employer already exists', async () => { employerServiceMock.findByName.mockResolvedValueOnce({ - _id: Types.ObjectId('6231aefe76598527c8d0b5a7'), - name: 'Metro', + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Sopra', validated: true, }); + const req = { user: { _id: '6036721022462b001334c4bb' }, role: 0 }; try { - await controller.createEmployer(newEmployer); - expect(true).toBe(false); + await controller.createEmployer({ name: 'Sopra' }, req); + expect; } catch (e) { expect(e.message).toBe('Employer already exist'); - expect(e.status).toBe(422); + expect(e.status).toBe(HttpStatus.UNPROCESSABLE_ENTITY); } }); + it('should call create with send notification to true if admin', async () => { + employerServiceMock.findByName.mockResolvedValueOnce(null); + const employer = { + name: 'Sopra', + }; + employerServiceMock.create.mockResolvedValueOnce({ ...employer, validated: true }); + const req = { user: { _id: '6036721022462b001334c4bb', role: 1 } }; + + const reply = await controller.createEmployer(employer, req); + expect(reply).toBeTruthy(); + expect(employerServiceMock.create).toHaveBeenCalledWith(employer, true, false); + }); }); + describe('Validate Employer', () => { + it('should validate an employer', async () => { + employerServiceMock.validate.mockResolvedValueOnce({ + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Sopra', + validated: true, + }); + expect(await controller.validateEmployer({ id: '6231aefe76598527c8d0b5bc' })).toBeTruthy(); + }); + }); + describe('Get Employers', () => { + it('should call all validated employers and populate them with users attached to it', async () => { + const spyer = jest.spyOn(userService, 'populateEmployerswithUsers'); + employerServiceMock.findAllValidated.mockResolvedValueOnce([ + { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Metro', + validated: true, + }, + { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Sopra', + validated: true, + }, + ]); + await controller.findValidatedEmployers(); + expect(employerServiceMock.findAllValidated.mock.calls.length).toBe(1); + expect(spyer.mock.calls.length).toBe(1); + }); - describe('deleteEmployer', () => { - it('should delete employer `Metro`', async () => { - const employer: Employer = { - name: 'Metro', + it('should call all unvalidated employers and populate them with users attached to it', async () => { + const spyer = jest.spyOn(userService, 'populateEmployerswithUsers'); + employerServiceMock.findAllUnvalidated.mockResolvedValueOnce([ + { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Metro', + validated: false, + }, + { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Sopra', + validated: false, + }, + ]); + await controller.findUnvalidatedEmployers(); + expect(employerServiceMock.findAllUnvalidated.mock.calls.length).toBe(1); + expect(spyer.mock.calls.length).toBe(1); + }); + }); + describe('Edit Employer', () => { + it('should update employer', async () => { + employerServiceMock.update.mockResolvedValueOnce({ + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'SopraMod', validated: true, - }; - const employerToRemove: CreateEmployerDto = { - name: 'Metro', - }; - employerServiceMock.findByName.mockResolvedValueOnce(employer); - employerServiceMock.deleteByName.mockResolvedValueOnce({ + }); + expect(await controller.updateEmployer('6231aefe76598527c8d0b5bc', { name: 'SopraMod' })).toBeTruthy(); + }); + + it('should delete an unvalidated employer and replace all its occurence with a chosen validated employer', async () => { + employerServiceMock.mergeEmployer.mockResolvedValueOnce({ + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Sopra', + validated: true, + }); + employerServiceMock.deleteInvalidEmployer.mockResolvedValueOnce({ + n: 1, ok: 1, + deletedCount: 1, + }); + + const reply = await controller.mergeEmployer({ + sourceEmployerId: '6231aefe76598527c8d0b5ba', + targetEmployerId: '6231aefe76598527c8d0b5bc', + }); + expect(reply).toBeTruthy(); + }); + }); + describe('Delete Employer', () => { + it('should delete employer', async () => { + employerServiceMock.deleteOneId.mockResolvedValueOnce({ n: 1, + ok: 1, deletedCount: 1, }); - const deleteEmployer = await controller.deleteEmployer(employerToRemove); - expect(deleteEmployer.name).toBe('Metro'); - expect(deleteEmployer.validated).toBe(true); + + employerServiceMock.findOne.mockResolvedValueOnce({ + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Sopra', + validated: true, + }); + const reply = await controller.deleteEmployer({ id: '6231aefe76598527c8d0b5ba' }); + expect(reply).toBeTruthy(); }); - it('should throw error on unexisting employer `Metro`', async () => { - const employerToRemove: CreateEmployerDto = { - name: 'Metro', - }; - employerServiceMock.deleteByName.mockResolvedValueOnce(null); + it('should not delete employer if a user is linked', async () => { + employerServiceMock.findOne.mockResolvedValueOnce({ + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Sopra', + validated: true, + }); try { - await controller.deleteEmployer(employerToRemove); + await controller.deleteEmployer({ id: '6231aefe76598527c8d0b5bc' }); expect(true).toBe(false); } catch (e) { - expect(e.message).toBe('Employer does not exist'); - expect(e.status).toBe(404); + expect(e.message).toEqual('Cannot delete employer. It has user(s) attached to it.'); + expect(e.status).toEqual(HttpStatus.FORBIDDEN); } }); }); - describe('resetES', () => { - it('should reset search index', async () => { - employerServiceMock.initEmployerIndex.mockResolvedValueOnce([ - { - _id: Types.ObjectId('6231aefe76598527c8d0b5a7'), - name: 'CAF', - validated: true, - }, - { - _id: Types.ObjectId('6231aefe76598527c8d0b5a7'), - name: 'CARSAT', - validated: true, - }, - ]); - const index = await controller.resetES(); - expect(index.length).toBe(2); - }); - }); }); diff --git a/src/users/controllers/employer.controller.ts b/src/users/controllers/employer.controller.ts index aff9d2e619e91b09ec9ff98ae2468d175d6c1df9..31eb7777f7a4581c704cea4b6ab520b0332900fc 100644 --- a/src/users/controllers/employer.controller.ts +++ b/src/users/controllers/employer.controller.ts @@ -6,23 +6,30 @@ import { HttpException, HttpStatus, Logger, + Param, Post, + Put, Query, + Request, UseGuards, } from '@nestjs/common'; -import { ApiParam } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { MergeEmployerDto } from '../../admin/dto/merge-employer.dto'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { hasAdminRole } from '../../shared/utils'; import { Roles } from '../decorators/roles.decorator'; import { CreateEmployerDto } from '../dto/create-employer.dto'; import { RolesGuard } from '../guards/roles.guard'; +import { IEmployer } from '../interfaces/employer.interface'; import { Employer } from '../schemas/employer.schema'; import { EmployerService } from '../services/employer.service'; +import { UsersService } from '../services/users.service'; @Controller('employer') export class EmployerController { private readonly logger = new Logger(EmployerController.name); - constructor(private employerService: EmployerService) {} + constructor(private employerService: EmployerService, private usersService: UsersService) {} /** * Find all employer. If search is given as param, filter on it. Otherwise return everything @@ -45,35 +52,21 @@ export class EmployerController { * @returns {Employer} */ @Post() + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT') @ApiParam({ name: 'newEmployer', type: CreateEmployerDto, required: true }) - public async createEmployer(@Body() newEmployer: CreateEmployerDto): Promise<Employer> { + public async createEmployer(@Body() newEmployer: CreateEmployerDto, @Request() req): Promise<Employer> { this.logger.debug(`createEmployer: ${newEmployer.name}`); const existingEmployer = await this.employerService.findByName(newEmployer.name); if (existingEmployer) { this.logger.warn(`Employer already exist: ${newEmployer.name}`); throw new HttpException('Employer already exist', HttpStatus.UNPROCESSABLE_ENTITY); } - return this.employerService.create(newEmployer); - } - - /** - * Delete Employer if exist - * @param employer {CreateEmployerDto} - Employer to delete - * @returns {Employer} - */ - @Delete() - @UseGuards(JwtAuthGuard, RolesGuard) - @Roles('admin') - @ApiParam({ name: 'employer', type: CreateEmployerDto, required: true }) - public async deleteEmployer(@Body() employer: CreateEmployerDto): Promise<Employer> { - this.logger.debug(`deleteEmployer: ${employer.name}`); - const existingEmployer = await this.employerService.findByName(employer.name); - if (!existingEmployer) { - this.logger.warn(`Employer does not exist: ${employer.name}`); - throw new HttpException('Employer does not exist', HttpStatus.NOT_FOUND); + // if user is admin, do not send notification + if (hasAdminRole(req.user)) { + return this.employerService.create(newEmployer, true, false); } - await this.employerService.deleteByName(employer.name); - return existingEmployer; + return this.employerService.create(newEmployer); } // SEARCH @@ -88,4 +81,82 @@ export class EmployerController { public async resetES(): Promise<Employer[]> { return this.employerService.initEmployerIndex(); } + + @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT') + @Roles('admin') + @Get('validated') + @ApiOperation({ description: 'Get validated employers populated with users attached to it' }) + public async findValidatedEmployers() { + return this.employerService.findAllValidated().then(async (employers: IEmployer[]) => { + return this.usersService.populateEmployerswithUsers(employers); + }); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT') + @Roles('admin') + @Get('unvalidated') + @ApiOperation({ description: 'Get unvalidated employers for validation or merge' }) + public async findUnvalidatedEmployers() { + return this.employerService.findAllUnvalidated().then(async (employers: IEmployer[]) => { + return this.usersService.populateEmployerswithUsers(employers); + }); + } + + /* + ** All users attached to this employer will be affected to the targeted employer + ** The original unvalidated employer will be deleted + */ + @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT') + @Roles('admin') + @Put('merge') + public async mergeEmployer(@Body() mergeEmployer: MergeEmployerDto) { + return this.employerService.mergeEmployer(mergeEmployer); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT') + @Roles('admin') + @Post('validate/:id') + @ApiParam({ name: 'id', type: String, required: true }) + @ApiOperation({ description: 'Validate employer' }) + public async validateEmployer(@Param() params) { + return this.employerService.validate(params.id); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT') + @Roles('admin') + @Put(':id') + public async updateEmployer(@Param('id') id: string, @Body() body: CreateEmployerDto) { + return this.employerService.update(id, body); + } + + /** + * Delete Employer if exist + * @param employer {CreateEmployerDto} - Employer to delete + * @returns {Employer} + */ + @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin') + @ApiParam({ name: 'employer', type: CreateEmployerDto, required: true }) + public async deleteEmployer(@Param() params): Promise<Employer> { + this.logger.debug(`deleteEmployer: ${params.id}`); + // look for employer + const researchedEmployer = await this.employerService.findOne(params.id); + // look for any relations within user collection, reject action if found + if (researchedEmployer !== null) { + const isEmployerLinked = await this.usersService.isEmployerLinkedtoUser(researchedEmployer._id); + if (!isEmployerLinked) { + return this.employerService.deleteOneId(params.id); + } else { + throw new HttpException('Cannot delete employer. It has user(s) attached to it.', HttpStatus.FORBIDDEN); + } + } else { + throw new HttpException('Employer does not exists', HttpStatus.NOT_FOUND); + } + } } diff --git a/src/users/controllers/jobs.controller.spec.ts b/src/users/controllers/jobs.controller.spec.ts index 776fefaf6a589cb8fa1ec6167a9c1a4266bec2b1..e097cd60ac52bb6bef701cfbf518b278f9b35e76 100644 --- a/src/users/controllers/jobs.controller.spec.ts +++ b/src/users/controllers/jobs.controller.spec.ts @@ -1,11 +1,13 @@ -import { HttpModule } from '@nestjs/common'; +import { HttpModule, HttpStatus } from '@nestjs/common'; import { getModelToken } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; import { Types } from 'mongoose'; import { ConfigurationModule } from '../../configuration/configuration.module'; import { CreateJobDto } from '../dto/create-job.dto'; import { Job } from '../schemas/job.schema'; +import { User } from '../schemas/user.schema'; import { JobsService } from '../services/jobs.service'; +import { UsersService } from '../services/users.service'; import { JobsController } from './jobs.controller'; describe('JobsController', () => { @@ -15,6 +17,19 @@ describe('JobsController', () => { findAll: jest.fn(), findByName: jest.fn(), create: jest.fn(), + findOne: jest.fn(), + deleteOneId: jest.fn(), + findAllUnvalidated: jest.fn(), + createJobFromAdmin: jest.fn(), + validate: jest.fn(), + update: jest.fn(), + mergeJob: jest.fn(), + deleteInvalidJob: jest.fn(), + }; + + const userServiceMock = { + populateJobswithUsers: jest.fn(), + isJobLinkedtoUser: jest.fn(), }; beforeEach(async () => { @@ -25,10 +40,18 @@ describe('JobsController', () => { provide: JobsService, useValue: jobServiceMock, }, + { + provide: UsersService, + useValue: userServiceMock, + }, { provide: getModelToken('Job'), useValue: Job, }, + { + provide: getModelToken('User'), + useValue: User, + }, ], controllers: [JobsController], }).compile(); @@ -65,6 +88,7 @@ describe('JobsController', () => { it('should create job `Dev`', async () => { const newJob: CreateJobDto = { name: 'Dev', + hasPersonalOffer: false, }; const newCreatedJob: Job = { name: 'Dev', @@ -73,12 +97,14 @@ describe('JobsController', () => { }; jobServiceMock.findByName.mockResolvedValueOnce(null); jobServiceMock.create.mockResolvedValueOnce(newCreatedJob); - const createReply = await controller.createJob(newJob); + const req = { user: { _id: '6036721022462b001334c4bb' } }; + const createReply = await controller.createJob(req, newJob); expect(createReply).toEqual(newCreatedJob); }); it('should throw error on already existing job `Dev`', async () => { const newJob: CreateJobDto = { name: 'Dev', + hasPersonalOffer: false, }; jobServiceMock.findByName.mockResolvedValueOnce({ _id: Types.ObjectId('6231aefe76598527c8d0b5a7'), @@ -87,7 +113,8 @@ describe('JobsController', () => { hasPersonalOffer: false, }); try { - await controller.createJob(newJob); + const req = { user: { _id: '6036721022462b001334c4bb' } }; + await controller.createJob(req, newJob); expect(true).toBe(false); } catch (e) { expect(e.message).toBe('Job already exist'); @@ -95,4 +122,159 @@ describe('JobsController', () => { } }); }); + + describe('Create Job', () => { + it('should create a job', async () => { + jobServiceMock.findByName.mockResolvedValueOnce(null); + jobServiceMock.create.mockResolvedValueOnce({ + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Dev', + validated: true, + }); + const req = { user: { _id: '6036721022462b001334c4bb', role: 0 } }; + const reply = await controller.createJob(req, { name: 'Dev', hasPersonalOffer: true }); + expect(reply).toBeTruthy(); + }); + it('should not create if job already exists', async () => { + jobServiceMock.findByName.mockResolvedValueOnce({ + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Dev', + validated: true, + }); + const req = { user: { _id: '6036721022462b001334c4bb', role: 0 } }; + try { + await controller.createJob(req, { name: 'Dev', hasPersonalOffer: true }); + } catch (e) { + expect(e.message).toBe('Job already exist'); + expect(e.status).toBe(HttpStatus.UNPROCESSABLE_ENTITY); + } + }); + it('should call create with send notification to true if admin', async () => { + jobServiceMock.findByName.mockResolvedValueOnce(null); + jobServiceMock.create.mockResolvedValueOnce({ + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Dev', + validated: true, + }); + const req = { user: { _id: '6036721022462b001334c4bb', role: 1 } }; + const job = { name: 'Dev', hasPersonalOffer: true }; + const reply = await controller.createJob(req, job); + expect(reply).toBeTruthy(); + expect(jobServiceMock.create).toHaveBeenCalledWith(job, true, true, false); + }); + }); + describe('Validate Job', () => { + it('should validate a given job', async () => { + jobServiceMock.validate.mockResolvedValueOnce({ + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Dev', + validated: true, + }); + expect(await controller.validateJob({ id: '6231aefe76598527c8d0b5bc' })).toBeTruthy(); + }); + }); + describe('Get Jobs', () => { + it('should call all validated jobs and populate them with users attached to it', async () => { + const spyer = jest.spyOn(userServiceMock, 'populateJobswithUsers'); + jobServiceMock.findAll.mockResolvedValueOnce([ + { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Dev', + validated: true, + }, + { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Scrum', + validated: true, + }, + ]); + await controller.findValidatedJobs(); + expect(jobServiceMock.findAll.mock.calls.length).toBe(2); + expect(spyer.mock.calls.length).toBe(1); + }); + + it('should call all unvalidated jobs and populate them with users attached to it', async () => { + const spyer = jest.spyOn(userServiceMock, 'populateJobswithUsers'); + jobServiceMock.findAllUnvalidated.mockResolvedValueOnce([ + { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Dev', + validated: false, + }, + { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Scrum', + validated: false, + }, + ]); + await controller.findUnvalidatedJobs(); + expect(jobServiceMock.findAllUnvalidated.mock.calls.length).toBe(1); + expect(spyer.mock.calls.length).toBe(2); + }); + }); + describe('Edit Job', () => { + it('should update job', async () => { + jobServiceMock.update.mockResolvedValueOnce({ + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'DevMod', + validated: true, + }); + expect( + await controller.updateJob('6231aefe76598527c8d0b5bc', { name: 'DevMod', hasPersonalOffer: false }) + ).toBeTruthy(); + }); + + it('should delete an unvalidated job and replace all its occurence with a chosen validated job', async () => { + jobServiceMock.mergeJob.mockResolvedValueOnce({ + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Dev', + validated: true, + }); + jobServiceMock.deleteInvalidJob.mockResolvedValueOnce({ + n: 1, + ok: 1, + deletedCount: 1, + }); + + const reply = await controller.mergeJob({ + sourceJobId: '6231aefe76598527c8d0b5ba', + targetJobId: '6231aefe76598527c8d0b5bc', + }); + expect(reply).toBeTruthy(); + }); + }); + describe('Delete Job', () => { + jobServiceMock.deleteOneId.mockResolvedValueOnce({ + n: 1, + ok: 1, + deletedCount: 1, + }); + + it('should delete job', async () => { + jobServiceMock.findOne.mockResolvedValueOnce({ + _id: Types.ObjectId('6231aefe76598527c8d0b5ba'), + name: 'Dev', + validated: true, + }); + userServiceMock.isJobLinkedtoUser.mockResolvedValueOnce(false); + const reply = await controller.deleteJob({ id: '6231aefe76598527c8d0b5ba' }); + expect(reply).toBeTruthy(); + }); + + it('should not delete job if a user is linked', async () => { + jobServiceMock.findOne.mockResolvedValueOnce({ + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Dev', + validated: true, + }); + userServiceMock.isJobLinkedtoUser.mockResolvedValueOnce(true); + try { + await controller.deleteJob({ id: '6231aefe76598527c8d0b5bc' }); + expect(true).toBe(false); + } catch (e) { + expect(e.message).toEqual('Cannot delete job. It has user(s) attached to it.'); + expect(e.status).toEqual(HttpStatus.FORBIDDEN); + } + }); + }); }); diff --git a/src/users/controllers/jobs.controller.ts b/src/users/controllers/jobs.controller.ts index 7a6ceee14b3cf134a884928f05db60b1c36514fb..b716a01e68969435472252e30fbdfc4a4f8f7d3e 100644 --- a/src/users/controllers/jobs.controller.ts +++ b/src/users/controllers/jobs.controller.ts @@ -1,15 +1,34 @@ -import { Body, Controller, Get, HttpException, HttpStatus, Logger, Post } from '@nestjs/common'; -import { ApiParam } from '@nestjs/swagger'; -import { CreateEmployerDto } from '../dto/create-employer.dto'; +import { + Body, + Controller, + Delete, + Get, + HttpException, + HttpStatus, + Logger, + Param, + Post, + Put, + Request, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { MergeJobDto } from '../../admin/dto/merge-job.dto'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { hasAdminRole } from '../../shared/utils'; +import { Roles } from '../decorators/roles.decorator'; import { CreateJobDto } from '../dto/create-job.dto'; +import { RolesGuard } from '../guards/roles.guard'; +import { IJob } from '../interfaces/job.interface'; import { Job } from '../schemas/job.schema'; import { JobsService } from '../services/jobs.service'; +import { UsersService } from '../services/users.service'; @Controller('jobs') export class JobsController { private readonly logger = new Logger(JobsController.name); - constructor(private jobsService: JobsService) {} + constructor(private jobsService: JobsService, private usersService: UsersService) {} /** * Return every jobs @@ -22,18 +41,95 @@ export class JobsController { /** * Create a new job - * @param job {CreateEmployerDto} + * @param job {CreateJobDto} * @returns {Job} */ @Post() + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT') @ApiParam({ name: 'job', type: CreateJobDto, required: true }) - public async createJob(@Body() job: CreateEmployerDto): Promise<Job> { + public async createJob(@Request() req, @Body() job: CreateJobDto): Promise<Job> { this.logger.debug(`createJob: ${job.name}`); const existingJob = await this.jobsService.findByName(job.name); if (existingJob) { this.logger.warn(`Job already exist: ${job.name}`); throw new HttpException('Job already exist', HttpStatus.UNPROCESSABLE_ENTITY); } + // if user is admin, do not send notification + if (hasAdminRole(req.user)) { + return this.jobsService.create(job, true, job.hasPersonalOffer, false); + } return this.jobsService.create(job); } + + @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT') + @Roles('admin') + @Get('validated') + @ApiOperation({ description: 'Get validated jobs populated with users attached to it' }) + public async findValidatedJobs() { + return this.jobsService.findAll().then(async (jobs: IJob[]) => { + return this.usersService.populateJobswithUsers(jobs); + }); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT') + @Roles('admin') + @Get('unvalidated') + @ApiOperation({ description: 'Get unvalidated jobs populated with users attached to it' }) + public async findUnvalidatedJobs() { + return this.jobsService.findAllUnvalidated().then(async (jobs: IJob[]) => { + return this.usersService.populateJobswithUsers(jobs); + }); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT') + @Roles('admin') + @Post('validate/:id') + @ApiParam({ name: 'id', type: String, required: true }) + @ApiOperation({ description: 'Validate job' }) + public async validateJob(@Param() params) { + return this.jobsService.validate(params.id); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT') + @Roles('admin') + @Put('merge') + public async mergeJob(@Body() mergeJob: MergeJobDto) { + this.logger.debug('mergeJob'); + return this.jobsService.mergeJob(mergeJob); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @ApiBearerAuth('JWT') + @Roles('admin') + @Put(':id') + public async updateJob(@Param('id') id: string, @Body() body: CreateJobDto) { + this.logger.debug('updateJob'); + return this.jobsService.update(id, body); + } + + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin') + @Delete(':id') + @ApiBearerAuth('JWT') + @ApiParam({ name: 'id', type: String, required: true }) + public async deleteJob(@Param() params) { + // look for job + const researchedJob = await this.jobsService.findOne(params.id); + // look for any relations within user collection, reject action if found + if (researchedJob !== null) { + const isJobLinked = await this.usersService.isJobLinkedtoUser(researchedJob._id); + if (!isJobLinked) { + return this.jobsService.deleteOneId(params.id); + } else { + throw new HttpException('Cannot delete job. It has user(s) attached to it.', HttpStatus.FORBIDDEN); + } + } else { + throw new HttpException('Job does not exists', HttpStatus.NOT_FOUND); + } + } } diff --git a/src/users/dto/create-employer.dto.ts b/src/users/dto/create-employer.dto.ts index e90ac1a6ac56bc1d05bc13bff1bbc949eec39c62..78682d2dd3848cb0d1567d40e2f354a78c071ea0 100644 --- a/src/users/dto/create-employer.dto.ts +++ b/src/users/dto/create-employer.dto.ts @@ -1,5 +1,5 @@ -import { IsNotEmpty, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; export class CreateEmployerDto { @ApiProperty({ type: String, example: 'PIMMS Vaise' }) diff --git a/src/users/dto/create-job.dto.ts b/src/users/dto/create-job.dto.ts index 12eb86e2b94a2ccddba8c64eb0519cc95733e0fd..d120a8b182afee8cecff07c5e3f558962044e3bc 100644 --- a/src/users/dto/create-job.dto.ts +++ b/src/users/dto/create-job.dto.ts @@ -1,4 +1,4 @@ -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class CreateJobDto { @@ -6,4 +6,8 @@ export class CreateJobDto { @IsNotEmpty() @IsString() readonly name: string; + @ApiProperty({ type: Boolean, example: false }) + @IsNotEmpty() + @IsBoolean() + readonly hasPersonalOffer: boolean; } diff --git a/src/users/interfaces/employer.interface.ts b/src/users/interfaces/employer.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..f3ca8d206c7f4fa2e45005a867b445bef6030636 --- /dev/null +++ b/src/users/interfaces/employer.interface.ts @@ -0,0 +1,4 @@ +import { Document } from 'mongoose'; +import { Employer } from '../schemas/employer.schema'; + +export type IEmployer = Employer & Document; diff --git a/src/users/interfaces/job.interface.ts b/src/users/interfaces/job.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..737e2264d38aafb19d6d00b30b42ca47f2175be2 --- /dev/null +++ b/src/users/interfaces/job.interface.ts @@ -0,0 +1,4 @@ +import { Document } from 'mongoose'; +import { Job } from '../schemas/job.schema'; + +export type IJob = Job & Document; diff --git a/src/users/services/employer.service.spec.ts b/src/users/services/employer.service.spec.ts index 0dad1f2838e685100441a9bac2116fdf25e3c218..628eafc330cdd5f5d1f3668c6e310c3884254e34 100644 --- a/src/users/services/employer.service.spec.ts +++ b/src/users/services/employer.service.spec.ts @@ -1,14 +1,20 @@ +import { HttpModule, HttpStatus } from '@nestjs/common'; import { getModelToken } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; +import { AxiosResponse } from 'axios'; import { Types } from 'mongoose'; import { ConfigurationModule } from '../../configuration/configuration.module'; +import { MailerService } from '../../mailer/mailer.service'; import { CreateEmployerDto } from '../dto/create-employer.dto'; import { EmployerDocument } from '../schemas/employer.schema'; +import { User } from '../schemas/user.schema'; import { EmployerSearchService } from './employer-search.service'; import { EmployerService } from './employer.service'; +import { UsersService } from './users.service'; describe('EmployerService', () => { let service: EmployerService; + let mailer: MailerService; const mockEmployerSearchService = { indexEmployer: jest.fn(), @@ -20,13 +26,18 @@ describe('EmployerService', () => { const mockEmployerModel = { create: jest.fn(), findOne: jest.fn(), + findById: jest.fn(), deleteOne: jest.fn(), find: jest.fn(() => mockEmployerModel), sort: jest.fn(() => mockEmployerModel), }; + const mockUserService = { + replaceEmployers: jest.fn(), + getAdmins: jest.fn(), + }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigurationModule], + imports: [ConfigurationModule, HttpModule], providers: [ EmployerService, { @@ -37,9 +48,19 @@ describe('EmployerService', () => { provide: getModelToken('Employer'), useValue: mockEmployerModel, }, + { + provide: UsersService, + useValue: mockUserService, + }, + { + provide: getModelToken('User'), + useValue: User, + }, + MailerService, ], }).compile(); service = module.get<EmployerService>(EmployerService); + mailer = module.get<MailerService>(MailerService); }); it('should be defined', () => { @@ -62,35 +83,118 @@ describe('EmployerService', () => { const reply = await service.findAll(); expect(reply.length).toBe(2); }); - - it('findByName', async () => { - mockEmployerModel.findOne.mockResolvedValue({ + it('findOne', async () => { + mockEmployerModel.findById.mockResolvedValueOnce({ _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), name: 'Sopra', validated: true, }); - const reply = await service.findByName('Sopra'); + const reply = await service.findOne('6231aefe76598527c8d0b5bc'); expect(reply).toBeTruthy(); }); + it('findAllValidated', async () => { + mockEmployerModel.find.mockResolvedValueOnce([ + { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Sopra', + validated: true, + }, + ]); + const reply = await service.findAllValidated(); + expect(reply.length).toBe(1); + }); - it('create', async () => { - mockEmployerModel.create.mockResolvedValue({ - _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), - name: 'Sopra', - validated: true, - }); + it('findAllUnvalidated', async () => { + mockEmployerModel.find.mockResolvedValueOnce([ + { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Sopra', + validated: false, + }, + ]); + const reply = await service.findAllValidated(); + expect(reply.length).toBe(1); + }); + + it('finds all unvalidated employers', async () => { + mockEmployerModel.find.mockResolvedValueOnce([ + { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Metro', + validated: false, + }, + ]); + const reply = await service.findAllUnvalidated(); + expect(reply[0].validated).toBeFalsy; + }); + + it('finds all validated employers', async () => { + mockEmployerModel.find.mockResolvedValueOnce([ + { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Metro', + validated: true, + }, + { + _id: Types.ObjectId('6231aefe76598527c8d0b5b2'), + name: 'Sopra', + validated: true, + }, + ]); + const reply = await service.findAllUnvalidated(); + expect(reply.length).toBe(2); + }); + + it('findByName', async () => { mockEmployerModel.findOne.mockResolvedValue({ _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), name: 'Sopra', validated: true, }); - const createJob: CreateEmployerDto = { - name: 'Sopra', - }; - const reply = await service.create(createJob); + const reply = await service.findByName('Sopra'); expect(reply).toBeTruthy(); }); + describe('createEmployer', () => { + it('create', async () => { + mockEmployerModel.create.mockResolvedValue({ + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Sopra', + validated: false, + }); + mockEmployerModel.findOne.mockResolvedValue({ + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Sopra', + validated: false, + }); + const createEmployer: CreateEmployerDto = { + name: 'Sopra', + }; + jest.spyOn(service, 'sendAdminCreateNotification').mockResolvedValueOnce(); + const reply = await service.create(createEmployer); + expect(reply).toBeTruthy(); + expect(service.sendAdminCreateNotification).toBeCalledTimes(1); + }); + + it('should create validated employer and not send email to admins', async () => { + mockEmployerModel.create.mockResolvedValue({ + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Sopra', + validated: false, + }); + mockEmployerModel.findOne.mockResolvedValue({ + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Sopra', + validated: false, + }); + const createEmployer: CreateEmployerDto = { + name: 'Sopra', + }; + const reply = await service.create(createEmployer, true, false); + expect(reply).toBeTruthy(); + }); + }); + it('delete', async () => { mockEmployerModel.findOne.mockResolvedValueOnce({ _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), @@ -145,4 +249,218 @@ describe('EmployerService', () => { expect(reply.length).toBe(2); }); }); + + describe('mergeEmployer', () => { + it('should delete source employer', async () => { + const reply = { + _id: Types.ObjectId('623aed68c5d45b6fbbaa7e61'), + name: 'Metro', + validated: true, + }; + mockEmployerModel.findById + .mockResolvedValueOnce({ + _id: Types.ObjectId('623aed68c5d45b6fbbaa7e60'), + name: 'Sopra', + validated: false, + }) + .mockResolvedValueOnce(reply); + jest.spyOn(service, 'deleteInvalidEmployer').mockRejectedValueOnce({}); + mockUserService.replaceEmployers.mockResolvedValueOnce(null); + expect( + await service.mergeEmployer({ + sourceEmployerId: '623aed68c5d45b6fbbaa7e60', + targetEmployerId: '623aed68c5d45b6fbbaa7e61', + }) + ).toEqual(reply); + }); + it('should delete source employer', async () => { + mockEmployerModel.findById.mockResolvedValueOnce(null).mockResolvedValueOnce(null); + jest.spyOn(service, 'deleteInvalidEmployer').mockRejectedValueOnce({}); + mockUserService.replaceEmployers.mockResolvedValueOnce(null); + try { + await service.mergeEmployer({ + sourceEmployerId: '623aed68c5d45b6fbbaa7e60', + targetEmployerId: '623aed68c5d45b6fbbaa7e61', + }); + } catch (e) { + expect(e.message).toBe('Cannot operate on employer.'); + expect(e.status).toBe(HttpStatus.NOT_FOUND); + } + }); + }); + + describe('sendAdminCreateNotification', () => { + it('should sendAdminCreateNotification', async () => { + const employer = { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Metro', + validated: true, + }; + const result: AxiosResponse = { + data: { + status: 200, + content: { + success: true, + response: [7010], + }, + }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + }; + jest.spyOn(mailer, 'send').mockResolvedValueOnce(result); + const spyer = jest.spyOn(mailer, 'send'); + mockUserService.getAdmins.mockResolvedValueOnce([ + { + _id: '6231aefe76598527c8d0b5bc', + validationToken: + 'cf1c74c22cedb6b575945098db42d2f493fb759c9142c6aff7980f252886f36ee086574ee99a06bc99119079257116c959c8ec870949cebdef2b293666dbca42', + emailVerified: true, + email: 'admin@admin.com', + password: '$2a$12$vLQjJ9zAWyUwiFLeQDa6w.XzrlgPBhw.2GWrjog/yuEjIaZnQwmZu', + role: 0, + name: 'admin', + surname: 'admin', + personalOffers: [], + }, + ]); + await service.sendAdminCreateNotification( + employer as EmployerDocument, + 'adminEmployerCreate.ejs', + 'adminEmployerCreate.json' + ); + expect(spyer.mock.calls.length).toBe(1); + }); + }); + + describe('validate', () => { + it('should validate employer', async () => { + mockEmployerModel.findById.mockResolvedValueOnce({ + _id: Types.ObjectId('623aed68c5d45b6fbbaa7e60'), + name: 'Sopra', + validated: false, + save: jest.fn().mockResolvedValueOnce(null), + }); + const employer = await service.validate('623aed68c5d45b6fbbaa7e60'); + expect(employer.validated).toBe(true); + }); + it('should throw exception', async () => { + mockEmployerModel.findById.mockResolvedValueOnce(null); + try { + await service.validate('623aed68c5d45b6fbbaa7e60'); + expect(true).toBe(false); + } catch (e) { + expect(e.message).toBe('Cannot validate employer. It might have been already validate'); + expect(e.status).toBe(HttpStatus.NOT_FOUND); + } + }); + }); + + describe('update', () => { + it('should update employer', async () => { + mockEmployerModel.findById.mockResolvedValueOnce({ + _id: Types.ObjectId('623aed68c5d45b6fbbaa7e60'), + name: 'Sopraaa', + validated: false, + save: jest.fn().mockResolvedValueOnce(null), + }); + const employer = await service.update('623aed68c5d45b6fbbaa7e60', { name: 'Sopra' }); + expect(employer.name).toBe('Sopra'); + }); + it('should throw exception', async () => { + mockEmployerModel.findById.mockResolvedValueOnce(null); + try { + await service.update('623aed68c5d45b6fbbaa7e60', { name: 'Sopra' }); + expect(true).toBe(false); + } catch (e) { + expect(e.message).toBe('Cannot edit employer. It was not found in database.'); + expect(e.status).toBe(HttpStatus.NOT_FOUND); + } + }); + }); + + describe('deleteInvalidEmployer', () => { + it('should delete invalid employer', async () => { + mockEmployerModel.findById.mockResolvedValueOnce({ + _id: Types.ObjectId('623aed68c5d45b6fbbaa7e60'), + name: 'Sopra', + validated: false, + save: jest.fn().mockResolvedValueOnce(null), + }); + mockEmployerSearchService.deleteIndex.mockResolvedValueOnce(null); + mockEmployerModel.deleteOne.mockResolvedValueOnce({ + _id: Types.ObjectId('623aed68c5d45b6fbbaa7e60'), + name: 'Sopra', + validated: false, + save: jest.fn().mockResolvedValueOnce(null), + }); + await service.deleteInvalidEmployer('6231aefe76598527c8d0b5bc'); + expect(mockEmployerSearchService.deleteIndex).toBeCalled(); + expect(mockEmployerModel.deleteOne).toBeCalled(); + }); + }); + + describe('deleteOneId', () => { + it('should delete ', async () => { + mockEmployerModel.findOne.mockResolvedValueOnce({ + _id: '6231aefe76598527c8d0b5bc', + validationToken: + 'cf1c74c22cedb6b575945098db42d2f493fb759c9142c6aff7980f252886f36ee086574ee99a06bc99119079257116c959c8ec870949cebdef2b293666dbca42', + emailVerified: true, + email: 'admin@admin.com', + password: '$2a$12$vLQjJ9zAWyUwiFLeQDa6w.XzrlgPBhw.2GWrjog/yuEjIaZnQwmZu', + role: 0, + name: 'admin', + surname: 'admin', + personalOffers: [], + deleteOne: jest.fn().mockResolvedValueOnce({ + _id: '6231aefe76598527c8d0b5bc', + validationToken: + 'cf1c74c22cedb6b575945098db42d2f493fb759c9142c6aff7980f252886f36ee086574ee99a06bc99119079257116c959c8ec870949cebdef2b293666dbca42', + emailVerified: true, + email: 'admin@admin.com', + password: '$2a$12$vLQjJ9zAWyUwiFLeQDa6w.XzrlgPBhw.2GWrjog/yuEjIaZnQwmZu', + role: 0, + name: 'admin', + surname: 'admin', + personalOffers: [], + }), + }); + expect(await service.deleteOneId('6231aefe76598527c8d0b5bc')).toStrictEqual({ + _id: '6231aefe76598527c8d0b5bc', + validationToken: + 'cf1c74c22cedb6b575945098db42d2f493fb759c9142c6aff7980f252886f36ee086574ee99a06bc99119079257116c959c8ec870949cebdef2b293666dbca42', + emailVerified: true, + email: 'admin@admin.com', + password: '$2a$12$vLQjJ9zAWyUwiFLeQDa6w.XzrlgPBhw.2GWrjog/yuEjIaZnQwmZu', + role: 0, + name: 'admin', + surname: 'admin', + personalOffers: [], + }); + }); + it('should throw an error ', async () => { + mockEmployerModel.findOne.mockResolvedValueOnce(null); + + try { + expect(await service.deleteOneId('6231aefe76598527c8d0b5bc')).toStrictEqual({ + _id: '6231aefe76598527c8d0b5bc', + validationToken: + 'cf1c74c22cedb6b575945098db42d2f493fb759c9142c6aff7980f252886f36ee086574ee99a06bc99119079257116c959c8ec870949cebdef2b293666dbca42', + emailVerified: true, + email: 'admin@admin.com', + password: '$2a$12$vLQjJ9zAWyUwiFLeQDa6w.XzrlgPBhw.2GWrjog/yuEjIaZnQwmZu', + role: 0, + name: 'admin', + surname: 'admin', + personalOffers: [], + }); + expect(true).toBe(false); + } catch (e) { + expect(e.message).toBe('Invalid employer id'); + expect(e.status).toBe(HttpStatus.BAD_REQUEST); + } + }); + }); }); diff --git a/src/users/services/employer.service.ts b/src/users/services/employer.service.ts index 79a50594d47db379c06680332c2c019e188c83af..f1fb4e2e05aa850f0a8679c49711162af8aedaf2 100644 --- a/src/users/services/employer.service.ts +++ b/src/users/services/employer.service.ts @@ -1,9 +1,14 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Model, Query } from 'mongoose'; +import * as ejs from 'ejs'; +import { Model, Query, Types } from 'mongoose'; +import { MergeEmployerDto } from '../../admin/dto/merge-employer.dto'; +import { MailerService } from '../../mailer/mailer.service'; import { CreateEmployerDto } from '../dto/create-employer.dto'; +import { IEmployer } from '../interfaces/employer.interface'; import { Employer, EmployerDocument } from '../schemas/employer.schema'; import { EmployerSearchService } from './employer-search.service'; +import { UsersService } from './users.service'; @Injectable() export class EmployerService { @@ -11,6 +16,8 @@ export class EmployerService { constructor( @InjectModel(Employer.name) private employerModel: Model<EmployerDocument>, + private readonly userService: UsersService, + private readonly mailerService: MailerService, private readonly employerSearchService: EmployerSearchService ) {} @@ -19,19 +26,124 @@ export class EmployerService { return this.employerModel.find().sort({ name: 1 }); } + public async findOne(idParam: string): Promise<EmployerDocument> { + this.logger.debug('findOne'); + return await this.employerModel.findById(Types.ObjectId(idParam)); + } + + public async findAllUnvalidated(): Promise<Employer[]> { + this.logger.debug('findAllUnvalidated'); + return this.employerModel.find({ validated: false }); + } + + public async findAllValidated(): Promise<IEmployer[]> { + this.logger.debug(`findAllValidated`); + return this.employerModel.find({ validated: true }); + } + public async findByName(name: string): Promise<EmployerDocument> { this.logger.debug('findByName'); return this.employerModel.findOne({ name }); } - public async create(employer: CreateEmployerDto, validated = false): Promise<Employer> { + public async create(employer: CreateEmployerDto, validated = false, sendAdminNotification = true): Promise<Employer> { this.logger.debug(`createEmployer: ${employer.name}`); - await this.employerModel.create({ name: employer.name, validated: validated }); + await this.employerModel.create({ name: employer.name, validated }); const document = await this.findByName(employer.name); this.employerSearchService.indexEmployer(document); + if (sendAdminNotification) { + this.sendAdminCreateNotification( + document, + this.mailerService.config.templates.adminEmployerCreate.ejs, + this.mailerService.config.templates.adminEmployerCreate.json + ); + } return document; } + public async sendAdminCreateNotification( + employer: EmployerDocument, + templateLocation: any, + jsonConfigLocation: any + ): Promise<void> { + const config = this.mailerService.config; + const ejsPath = this.mailerService.getTemplateLocation(templateLocation); + const jsonConfig = this.mailerService.loadJsonConfig(jsonConfigLocation); + const html = await ejs.renderFile(ejsPath, { + config, + employerName: employer ? employer.name : '', + }); + const admins = await this.userService.getAdmins(); + admins.forEach((admin) => { + this.mailerService.send(admin.email, jsonConfig.subject, html); + }); + } + + public async validate(employerId: string): Promise<Employer> { + this.logger.debug(`validateEmployer: ${employerId}`); + const employer = await this.employerModel.findById(Types.ObjectId(employerId)); + if (employer) { + employer.validated = true; + employer.save(); + return employer; + } else { + throw new HttpException('Cannot validate employer. It might have been already validate', HttpStatus.NOT_FOUND); + } + } + + public async update(employerId: string, newEmployer: CreateEmployerDto): Promise<Employer> { + this.logger.debug(`editEmployer: ${employerId}`); + const employer = await this.employerModel.findById(Types.ObjectId(employerId)); + if (employer) { + employer.name = newEmployer.name; + employer.save(); + return employer; + } else { + throw new HttpException('Cannot edit employer. It was not found in database.', HttpStatus.NOT_FOUND); + } + } + + public async mergeEmployer({ + sourceEmployerId, + targetEmployerId, + }: MergeEmployerDto): Promise<Employer | HttpException> { + this.logger.debug(`mergeEmployer: ${sourceEmployerId} into ${targetEmployerId}`); + const sourceEmployer = await this.employerModel.findById(Types.ObjectId(sourceEmployerId)); + const targetEmployer = await this.employerModel.findById(Types.ObjectId(targetEmployerId)); + if (targetEmployer && sourceEmployer) { + this.userService.replaceEmployers(sourceEmployer, targetEmployer); + if (!sourceEmployer.validated) { + this.deleteInvalidEmployer(sourceEmployerId); + } + return targetEmployer; + } else { + throw new HttpException('Cannot operate on employer.', HttpStatus.NOT_FOUND); + } + } + + public async deleteInvalidEmployer( + id: string + ): Promise< + Query<{ + ok?: number; + n?: number; + deletedCount?: number; + }> + > { + this.logger.debug(`deleteInvalidEmployer: ${id}`); + const document = await this.employerModel.findById(Types.ObjectId(id)); + this.employerSearchService.deleteIndex(document, document._id); + return this.employerModel.deleteOne({ _id: id }); + } + + public async deleteOneId(id: string): Promise<Employer> { + const employer = await this.employerModel.findOne({ _id: id }); + if (!employer) { + throw new HttpException('Invalid employer id', HttpStatus.BAD_REQUEST); + } + return employer.deleteOne(); + } + public async deleteByName( name: string ): Promise< diff --git a/src/users/services/jobs.service.spec.ts b/src/users/services/jobs.service.spec.ts index 697adbcc021ccef26fe62d26ca8c1d72c5132375..083f6531f0b160b25544c3f9f00ad6ffa1a517a5 100644 --- a/src/users/services/jobs.service.spec.ts +++ b/src/users/services/jobs.service.spec.ts @@ -1,48 +1,106 @@ +import { HttpModule, HttpService, HttpStatus } from '@nestjs/common'; import { getModelToken } from '@nestjs/mongoose'; import { Test, TestingModule } from '@nestjs/testing'; +import { AxiosResponse } from 'axios'; import { Types } from 'mongoose'; +import { of } from 'rxjs'; import { ConfigurationModule } from '../../configuration/configuration.module'; +import { MailerService } from '../../mailer/mailer.service'; import { CreateJobDto } from '../dto/create-job.dto'; +import { JobDocument } from '../schemas/job.schema'; import { JobsService } from './jobs.service'; +import { UsersService } from './users.service'; describe('JobsService', () => { let service: JobsService; + let httpService: HttpService; + let mailer: MailerService; const mockJobModel = { create: jest.fn(), find: jest.fn(), findOne: jest.fn(), + findById: jest.fn(), + deleteOne: jest.fn(), + }; + + const mockUserModel = { + find: jest.fn(), + findOne: jest.fn(), + findById: jest.fn(), + }; + const mockUserService = { + replaceJobs: jest.fn(), + getAdmins: jest.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigurationModule], + imports: [ConfigurationModule, HttpModule], providers: [ JobsService, { provide: getModelToken('Job'), useValue: mockJobModel, }, + { + provide: UsersService, + useValue: mockUserService, + }, + { + provide: getModelToken('User'), + useValue: mockUserModel, + }, + MailerService, ], }).compile(); service = module.get<JobsService>(JobsService); + mailer = module.get<MailerService>(MailerService); + httpService = module.get<HttpService>(HttpService); }); it('should be defined', () => { expect(service).toBeDefined(); }); - it('findAll', async () => { + describe('findAll', () => { + it('should findAll validated jobs', async () => { + mockJobModel.find.mockResolvedValue([ + { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'CNFS', + validated: true, + hasPersonalOffer: true, + }, + ]); + const reply = await service.findAll(); + expect(reply.length).toBe(1); + }); + it('should findAll all jobs', async () => { + mockJobModel.find.mockResolvedValue([ + { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'CNFSssss', + validated: false, + hasPersonalOffer: true, + }, + ]); + const reply = await service.findAll(false); + expect(reply.length).toBe(1); + }); + }); + + it('findUnvalidated', async () => { mockJobModel.find.mockResolvedValue([ { _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), name: 'CNFS', - validated: true, + validated: false, hasPersonalOffer: true, }, ]); - const reply = await service.findAll(); - expect(reply.length).toBe(1); + const reply = await service.findAllUnvalidated(); + expect(reply[0].validated).toBeFalsy; }); it('findByName', async () => { @@ -55,18 +113,255 @@ describe('JobsService', () => { const reply = await service.findByName('CNFS'); expect(reply).toBeTruthy(); }); - - it('create', async () => { - mockJobModel.create.mockResolvedValue({ + it('findOne', async () => { + mockJobModel.findById.mockResolvedValue({ _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), name: 'CNFS', validated: true, hasPersonalOffer: true, }); + const reply = await service.findOne('6231aefe76598527c8d0b5bc'); + expect(reply).toBeTruthy(); + }); + + describe('createJob', () => { + it('create', async () => { + mockJobModel.create.mockResolvedValue({ + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Dev', + validated: false, + hasPersonalOffer: false, + }); + const createJob: CreateJobDto = { + name: 'Dev', + hasPersonalOffer: false, + }; + const reply = await service.create(createJob); + expect(reply).toBeTruthy(); + }); + + it('should send an email to admins', async () => { + //todo fetch admin infos and check httpservice is called further + const result: AxiosResponse = { + data: { + status: 200, + content: { + success: true, + response: [7010], + }, + }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + }; + jest.spyOn(httpService, 'post').mockImplementationOnce(() => of(result)); + expect(await mailer.send('a@a.com', 'test', '<p>This is a test</p>')).toBe(result); + }); + }); + + it('createFromAdmin', async () => { + mockJobModel.create.mockResolvedValue({ + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Dev', + validated: true, + hasPersonalOffer: false, + }); const createJob: CreateJobDto = { name: 'Dev', + hasPersonalOffer: false, }; const reply = await service.create(createJob); expect(reply).toBeTruthy(); }); + + describe('sendAdminCreateNotification', () => { + it('should sendAdminCreateNotification', async () => { + const job = { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'Metro', + validated: true, + }; + const result: AxiosResponse = { + data: { + status: 200, + content: { + success: true, + response: [7010], + }, + }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + }; + jest.spyOn(mailer, 'send').mockResolvedValueOnce(result); + const spyer = jest.spyOn(mailer, 'send'); + mockUserService.getAdmins.mockResolvedValueOnce([ + { + _id: '6231aefe76598527c8d0b5bc', + validationToken: + 'cf1c74c22cedb6b575945098db42d2f493fb759c9142c6aff7980f252886f36ee086574ee99a06bc99119079257116c959c8ec870949cebdef2b293666dbca42', + emailVerified: true, + email: 'admin@admin.com', + password: '$2a$12$vLQjJ9zAWyUwiFLeQDa6w.XzrlgPBhw.2GWrjog/yuEjIaZnQwmZu', + role: 0, + name: 'admin', + surname: 'admin', + personalOffers: [], + }, + ]); + await service.sendAdminCreateNotification(job as JobDocument, 'adminJobCreate.ejs', 'adminJobCreate.json'); + expect(spyer.mock.calls.length).toBe(1); + }); + }); + + describe('validate', () => { + it('should validate job', async () => { + mockJobModel.findById.mockResolvedValueOnce({ + _id: Types.ObjectId('623aed68c5d45b6fbbaa7e60'), + name: 'Dev', + validated: false, + save: jest.fn().mockResolvedValueOnce(null), + }); + const job = await service.validate('623aed68c5d45b6fbbaa7e60'); + expect(job.validated).toBe(true); + }); + it('should throw exception', async () => { + mockJobModel.findById.mockResolvedValueOnce(null); + try { + await service.validate('623aed68c5d45b6fbbaa7e60'); + expect(true).toBe(false); + } catch (e) { + expect(e.message).toBe('Cannot validate job. It might have been already validate'); + expect(e.status).toBe(HttpStatus.NOT_FOUND); + } + }); + }); + + describe('mergeJob', () => { + it('should delete source job', async () => { + const reply = { + _id: Types.ObjectId('623aed68c5d45b6fbbaa7e61'), + name: 'Dev 2', + validated: true, + hasPersonalOffer: false, + }; + mockJobModel.findById + .mockResolvedValueOnce({ + _id: Types.ObjectId('623aed68c5d45b6fbbaa7e60'), + name: 'Dev', + validated: false, + hasPersonalOffer: false, + }) + .mockResolvedValueOnce(reply); + jest.spyOn(service, 'deleteInvalidJob').mockRejectedValueOnce({}); + + expect( + await service.mergeJob({ sourceJobId: '623aed68c5d45b6fbbaa7e60', targetJobId: '623aed68c5d45b6fbbaa7e61' }) + ).toEqual(reply); + }); + it('should throw error if target job is not validated', async () => { + mockJobModel.findById + .mockResolvedValueOnce({ + _id: Types.ObjectId('623aed68c5d45b6fbbaa7e60'), + name: 'Dev', + validated: false, + hasPersonalOffer: false, + }) + .mockResolvedValueOnce(null); + try { + await service.mergeJob({ sourceJobId: '623aed68c5d45b6fbbaa7e60', targetJobId: '623aed68c5d45b6fbbaa7e61' }); + expect(true).toBe(false); + } catch (e) { + expect(e.message).toBe('Cannot operate on job.'); + expect(e.status).toBe(HttpStatus.NOT_FOUND); + } + }); + it('should throw error if no source or target job', async () => { + mockJobModel.findById.mockResolvedValueOnce(null).mockResolvedValueOnce({ + _id: Types.ObjectId('623aed68c5d45b6fbbaa7e60'), + name: 'Dev', + validated: false, + hasPersonalOffer: false, + }); + try { + await service.mergeJob({ sourceJobId: '623aed68c5d45b6fbbaa7e60', targetJobId: '623aed68c5d45b6fbbaa7e61' }); + expect(true).toBe(false); + } catch (e) { + expect(e.message).toBe('Cannot operate on job.'); + expect(e.status).toBe(HttpStatus.NOT_FOUND); + } + }); + }); + + it('deleteInvalidJob', async () => { + mockJobModel.deleteOne.mockResolvedValueOnce(null); + const spyer = jest.spyOn(service, 'deleteInvalidJob'); + await service.deleteInvalidJob('623aed68c5d45b6fbbaa7e60'); + expect(spyer.mock.calls.length).toEqual(1); + }); + + describe('deleteOneId', () => { + it('should delete ', async () => { + mockJobModel.findOne.mockResolvedValueOnce({ + _id: Types.ObjectId('623aed68c5d45b6fbbaa7e60'), + name: 'Dev', + validated: false, + deleteOne: jest.fn().mockResolvedValueOnce('toto'), + }); + const reply = await service.deleteOneId('623aed68c5d45b6fbbaa7e60'); + expect(reply).toBe('toto'); + }); + it('should delete ', async () => { + mockJobModel.findOne.mockResolvedValueOnce(null); + try { + await service.deleteOneId('623aed68c5d45b6fbbaa7e60'); + expect(true).toBe(false); + } catch (e) { + expect(e.message).toBe('Invalid job id'); + expect(e.status).toBe(HttpStatus.BAD_REQUEST); + } + }); + }); + + describe('update', () => { + it('should update', async () => { + const newJobDto = { + name: 'CNFS', + hasPersonalOffer: true, + }; + const newJob = { + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'CNFS', + validated: true, + hasPersonalOffer: true, + }; + mockJobModel.findById.mockResolvedValue({ + _id: Types.ObjectId('6231aefe76598527c8d0b5bc'), + name: 'CNFS old', + validated: true, + hasPersonalOffer: false, + save: jest.fn().mockResolvedValueOnce(newJob), + }); + const reply = await service.update('623aed68c5d45b6fbbaa7e60', newJobDto); + expect(reply.name).toBe('CNFS'); + expect(reply.hasPersonalOffer).toBe(true); + }); + }); + + it('should throw error', async () => { + const newJobDto = { + name: 'CNFS', + hasPersonalOffer: true, + }; + mockJobModel.findById.mockResolvedValue(null); + try { + await service.update('623aed68c5d45b6fbbaa7e60', newJobDto); + expect(true).toBe(false); + } catch (e) { + expect(e.message).toBe('Cannot edit job. It was not found in database.'); + expect(e.status).toBe(HttpStatus.NOT_FOUND); + } + }); }); diff --git a/src/users/services/jobs.service.ts b/src/users/services/jobs.service.ts index 43e77b4c6fc19ef73e7da020b31308a05aa2c6aa..56442b52db120566a7c8164df4d383cad4afcb3c 100644 --- a/src/users/services/jobs.service.ts +++ b/src/users/services/jobs.service.ts @@ -1,18 +1,27 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { Model } from 'mongoose'; +import * as ejs from 'ejs'; +import { Model, Query, Types } from 'mongoose'; +import { MergeJobDto } from '../../admin/dto/merge-job.dto'; +import { MailerService } from '../../mailer/mailer.service'; import { CreateJobDto } from '../dto/create-job.dto'; +import { IJob } from '../interfaces/job.interface'; import { Job, JobDocument } from '../schemas/job.schema'; +import { UsersService } from './users.service'; @Injectable() export class JobsService { private readonly logger = new Logger(JobsService.name); - constructor(@InjectModel(Job.name) private jobModel: Model<JobDocument>) {} + constructor( + @InjectModel(Job.name) private jobModel: Model<JobDocument>, + private readonly userService: UsersService, + private readonly mailerService: MailerService + ) {} - public async findAll(filterValidetedJobs = true): Promise<Job[]> { - this.logger.debug(`findAll with filterValidetedJobs: ${filterValidetedJobs}`); - if (!filterValidetedJobs) { + public async findAll(filterValidatedJobs = true): Promise<IJob[]> { + this.logger.debug(`findAll with filterValidetedJobs: ${filterValidatedJobs}`); + if (!filterValidatedJobs) { return this.jobModel.find(); } else { return this.jobModel.find({ validated: true }); @@ -24,8 +33,115 @@ export class JobsService { return this.jobModel.findOne({ name }); } - public async create(job: CreateJobDto, validated = false, hasPersonalOffer = true): Promise<Job> { - this.logger.debug(`createJob: ${job.name}`); - return this.jobModel.create({ name: job.name, validated, hasPersonalOffer }); + public async findOne(idParam: string): Promise<JobDocument> { + this.logger.debug('findOne'); + return this.jobModel.findById(Types.ObjectId(idParam)); + } + + public async findAllUnvalidated(): Promise<IJob[]> { + this.logger.debug('findAllUnvalidated'); + return this.jobModel.find({ validated: false }); + } + + public async create( + job: CreateJobDto, + validated = false, + hasPersonalOffer = false, + sendNotification = true + ): Promise<Job> { + this.logger.debug(`createJob: ${job.name} | notificationSending ${sendNotification}`); + const result = this.jobModel + .create({ name: job.name, validated, hasPersonalOffer }) + .then(async (postCreate: JobDocument) => { + if (sendNotification) { + this.sendAdminCreateNotification( + postCreate, + this.mailerService.config.templates.adminJobCreate.ejs, + this.mailerService.config.templates.adminJobCreate.json + ); + } + return postCreate; + }); + return result; + } + + public async sendAdminCreateNotification( + job: JobDocument, + templateLocation: any, + jsonConfigLocation: any + ): Promise<void> { + const config = this.mailerService.config; + const ejsPath = this.mailerService.getTemplateLocation(templateLocation); + const jsonConfig = this.mailerService.loadJsonConfig(jsonConfigLocation); + const html = await ejs.renderFile(ejsPath, { + config, + jobName: job ? job.name : '', + }); + const admins = await this.userService.getAdmins(); + admins.forEach((admin) => { + this.mailerService.send(admin.email, jsonConfig.subject, html); + }); + } + + public async validate(jobId: string): Promise<Job> { + this.logger.debug(`validateJob: ${jobId}`); + const job = await this.jobModel.findById(Types.ObjectId(jobId)); + if (job) { + job.validated = true; + job.save(); + return job; + } else { + throw new HttpException('Cannot validate job. It might have been already validate', HttpStatus.NOT_FOUND); + } + } + + public async mergeJob({ sourceJobId, targetJobId }: MergeJobDto): Promise<Job | HttpException> { + this.logger.debug(`mergeJob: ${sourceJobId} into ${targetJobId}`); + const sourceJob = await this.jobModel.findById(Types.ObjectId(sourceJobId)); + const targetJob = await this.jobModel.findById(Types.ObjectId(targetJobId)); + if (targetJob && sourceJob) { + this.logger.debug(`Both jobs : ${sourceJob}, ${targetJob}`); + this.userService.replaceJobs(sourceJob, targetJob); + if (!sourceJob.validated) { + this.deleteInvalidJob(sourceJobId); + } + return targetJob; + } else { + throw new HttpException('Cannot operate on job.', HttpStatus.NOT_FOUND); + } + } + + public async deleteInvalidJob( + id: string + ): Promise< + Query<{ + ok?: number; + n?: number; + deletedCount?: number; + }> + > { + this.logger.debug(`deleteInvalidJob: ${id}`); + return this.jobModel.deleteOne({ _id: id }); + } + + public async deleteOneId(id: string): Promise<Job> { + const job = await this.jobModel.findOne({ _id: id }); + if (!job) { + throw new HttpException('Invalid job id', HttpStatus.BAD_REQUEST); + } + return job.deleteOne(); + } + + public async update(jobId: string, newJob: CreateJobDto): Promise<Job> { + this.logger.debug(`editJob: ${jobId}`); + const job = await this.jobModel.findById(Types.ObjectId(jobId)); + if (job) { + job.name = newJob.name; + job.hasPersonalOffer = newJob.hasPersonalOffer; + job.save(); + return job; + } else { + throw new HttpException('Cannot edit job. It was not found in database.', HttpStatus.NOT_FOUND); + } } } diff --git a/src/users/services/users.service.spec.ts b/src/users/services/users.service.spec.ts index 2eaee5ce895a86269eed4f69716d3e4f561edd61..11079f622c9148565b967cb2c9acff4c5ec6ab00 100644 --- a/src/users/services/users.service.spec.ts +++ b/src/users/services/users.service.spec.ts @@ -7,6 +7,7 @@ import * as bcrypt from 'bcrypt'; import { personalOffersDataMock } from '../../../test/mock/data/personalOffers.mock.data'; import { PersonalOfferDocument } from '../../personal-offers/schemas/personal-offer.schema'; import { usersMockData } from '../../../test/mock/data/users.mock.data'; +import { employersMockData } from '../../../test/mock/data/employers.mock.data'; import { LoginDto } from '../../auth/login-dto'; import { ConfigurationModule } from '../../configuration/configuration.module'; import { MailerModule } from '../../mailer/mailer.module'; @@ -19,6 +20,17 @@ function hashPassword() { return bcrypt.hashSync(process.env.USER_PWD, process.env.SALT); } +const mockUserModel = { + create: jest.fn(), + findOne: jest.fn(), + findById: jest.fn(), + deleteOne: jest.fn(), + updateMany: jest.fn(), + exec: jest.fn(), + find: jest.fn(() => mockUserModel), + sort: jest.fn(() => mockUserModel), +}; + describe('UsersService', () => { let service: UsersService; @@ -29,7 +41,7 @@ describe('UsersService', () => { UsersService, { provide: getModelToken('User'), - useValue: User, + useValue: mockUserModel, }, ], }).compile(); @@ -405,4 +417,29 @@ describe('UsersService', () => { } }); }); + + describe('user employer', () => { + it('should replace employer with a new one', async () => { + const spyer = jest.spyOn(mockUserModel, 'updateMany'); + + mockUserModel.updateMany.mockReturnThis(); + mockUserModel.exec.mockResolvedValueOnce([usersMockData[0]]); + await service.replaceEmployers(employersMockData[0], employersMockData[1]); + expect(spyer).toBeCalledTimes(1); + }); + + it('should return true if a user is linked to a given employer', async () => {}); + it('should fetch users attached to a given employer', async () => {}); + it('should populate an employer with a list of attached users', async () => {}); + it("should update user's employer ", async () => {}); + }); + + describe('user job', () => { + it('should replace job with a new one', async () => {}); + + it('should return true if a user is linked to a given job', async () => {}); + it('should fetch users attached to a given job', async () => {}); + it('should populate an job with a list of attached users', async () => {}); + it("should update user's job ", async () => {}); + }); }); diff --git a/src/users/services/users.service.ts b/src/users/services/users.service.ts index fecd0be4252b9f10e519c2f4835408908e34dc32..676c6047162c2e60a0d8cab30124561f93d4ac58 100644 --- a/src/users/services/users.service.ts +++ b/src/users/services/users.service.ts @@ -14,8 +14,8 @@ import { PendingStructureDto } from '../../admin/dto/pending-structure.dto'; import { OwnerDto } from '../dto/owner.dto'; import { StructureDocument } from '../../structures/schemas/structure.schema'; import { ConfigurationService } from '../../configuration/configuration.service'; -import { EmployerDocument } from '../schemas/employer.schema'; -import { JobDocument } from '../schemas/job.schema'; +import { Employer, EmployerDocument } from '../schemas/employer.schema'; +import { Job, JobDocument } from '../schemas/job.schema'; import { PersonalOfferDocument } from '../../personal-offers/schemas/personal-offer.schema'; @Injectable() @@ -87,7 +87,7 @@ export class UsersService { } public findAll(): Promise<User[]> { - return this.userModel.find().exec(); + return this.userModel.find().populate('employer').populate('job').select('-password').exec(); } public findAllUnattached(): Promise<IUser[]> { @@ -98,6 +98,9 @@ export class UsersService { .where('structuresLink') .size(0) .sort({ surname: 1 }) + .populate('employer') + .populate('job') + .select('-password') .exec(); } @@ -107,18 +110,29 @@ export class UsersService { .where('emailVerified') .equals(true) .sort({ surname: 1 }) + .populate('employer') + .populate('job') + .select('-password') .exec(); } public findAllUnVerified(): Promise<IUser[]> { - return this.userModel.find().where('emailVerified').equals(false).sort({ surname: 1 }).exec(); + return this.userModel + .find() + .where('emailVerified') + .equals(false) + .sort({ surname: 1 }) + .populate('employer') + .populate('job') + .select('-password') + .exec(); } public async findById(id: string, passwordQuery?: boolean): Promise<IUser | undefined> { if (passwordQuery) { - return this.userModel.findById(id).exec(); + return this.userModel.findById(id).populate('employer').populate('job').exec(); } - return this.userModel.findById(id).select('-password').exec(); + return this.userModel.findById(id).populate('employer').populate('job').select('-password').exec(); } public async removeStructureIdFromUsers(structureId: Types.ObjectId): Promise<IUser[] | undefined> { @@ -127,6 +141,19 @@ export class UsersService { .exec(); } + public async replaceEmployers( + sourceEmployer: EmployerDocument, + targetEmployer: EmployerDocument + ): Promise<IUser[] | undefined> { + return this.userModel + .updateMany({ employer: sourceEmployer._id }, { $set: { employer: targetEmployer._id } }) + .exec(); + } + + public async replaceJobs(sourceJob: JobDocument, targetJob: JobDocument): Promise<IUser[] | undefined> { + return this.userModel.updateMany({ job: sourceJob._id }, { $set: { job: targetJob._id } }).exec(); + } + /** * Return a user after credential checking. * Use for login action @@ -359,6 +386,63 @@ export class UsersService { return this.userModel.findOne({ structuresLink: Types.ObjectId(structureId) }).exec(); } + public async isEmployerLinkedtoUser(id: string): Promise<boolean> { + const users = await this.userModel.find().exec(); + let hasLinkedUser = false; + users.map((x) => { + if (String(x.employer) === id.toString()) { + hasLinkedUser = true; + } + }); + return hasLinkedUser; + } + + public async isJobLinkedtoUser(id: string): Promise<boolean> { + const users = await this.userModel.find().exec(); + let hasLinkedUser = false; + users.map((x) => { + if (String(x.job) === id.toString()) { + hasLinkedUser = true; + } + }); + return hasLinkedUser; + } + + public getJobAttachedUsers(jobId: JobDocument): Promise<IUser[]> { + return this.userModel.find({ job: jobId._id }).select('-password -job').exec(); + } + + public getEmployerAttachedUsers(employerId: EmployerDocument): Promise<IUser[]> { + return this.userModel.find({ employer: employerId._id }).select('-password -employer').exec(); + } + + public async populateJobswithUsers(jobs: JobDocument[]): Promise<any[]> { + return Promise.all( + jobs.map(async (job) => { + return { + _id: job._id, + name: job.name, + validated: job.validated, + hasPersonalOffer: job.hasPersonalOffer, + users: await this.getJobAttachedUsers(job), + }; + }) + ); + } + + public async populateEmployerswithUsers(employers: EmployerDocument[]): Promise<any[]> { + return Promise.all( + employers.map(async (employer) => { + return { + _id: employer._id, + name: employer.name, + validated: employer.validated, + users: await this.getEmployerAttachedUsers(employer), + }; + }) + ); + } + public getStructureOwners(structureId: string): Promise<IUser[]> { return this.userModel.find({ structuresLink: Types.ObjectId(structureId) }).exec(); } @@ -529,7 +613,7 @@ export class UsersService { return this.getPendingStructures(); } else { throw new HttpException( - 'Cannot validate strucutre. It might have been already validate, or the structure does`nt belong to the user', + 'Cannot validate strucutre. It might have been already validate, or the structure doesn`t belong to the user', HttpStatus.NOT_FOUND ); } @@ -608,6 +692,7 @@ export class UsersService { } /** + * * @param profile * @param userId */ @@ -615,4 +700,24 @@ export class UsersService { this.logger.debug(`updateUserProfile | ${userId}`); return this.userModel.updateOne({ _id: userId }, { $set: { employer: employer._id, job: job._id } }); } + + /** + * + * @param job + * @param userId + */ + public async updateUserJob(userId: Types.ObjectId, job: JobDocument): Promise<any> { + this.logger.debug(`updateUserProfile - Job | ${userId}`); + return this.userModel.updateOne({ _id: userId }, { $set: { job: job._id } }); + } + + /** + * + * @param employer + * @param userId + */ + public async updateUserEmployer(userId: Types.ObjectId, employer: EmployerDocument): Promise<any> { + this.logger.debug(`updateUserProfile - Employer | ${userId}`); + return this.userModel.updateOne({ _id: userId }, { $set: { employer: employer._id } }); + } } diff --git a/test/mock/data/employers.mock.data.ts b/test/mock/data/employers.mock.data.ts new file mode 100644 index 0000000000000000000000000000000000000000..073aa6d65c5cb07aee5b5590314a87ab76d3d1ac --- /dev/null +++ b/test/mock/data/employers.mock.data.ts @@ -0,0 +1,14 @@ +import { IEmployer } from '../../../src/users/interfaces/employer.interface'; + +export const employersMockData: IEmployer[] = [ + { + _id: '6036721022462b001334c4ba', + name: 'Metro', + validated: true, + } as any, + { + _id: '6036721022462b001334c4bb', + name: 'Sopra', + validated: true, + } as any, +] as IEmployer[]; diff --git a/test/mock/services/user.mock.service.ts b/test/mock/services/user.mock.service.ts index e13d723466b5f39ee643c871367e8a36f8c4907f..1ef41de52cc36e0d195a566978e47bf21d772275 100644 --- a/test/mock/services/user.mock.service.ts +++ b/test/mock/services/user.mock.service.ts @@ -1,4 +1,5 @@ import { HttpException, HttpStatus } from '@nestjs/common'; +import { isValidObjectId } from 'mongoose'; import { PendingStructureDto } from '../../../src/admin/dto/pending-structure.dto'; import { LoginDto } from '../../../src/auth/login-dto'; import { PersonalOffer, PersonalOfferDocument } from '../../../src/personal-offers/schemas/personal-offer.schema'; @@ -66,17 +67,66 @@ export class UsersServiceMock { return user; } + findById(id: string) { + if (isValidObjectId(id)) { + return { + _id: id, + validationToken: + 'cf1c74c22cedb6b575945098db42d2f493fb759c9142c6aff7980f252886f36ee086574ee99a06bc99119079257116c959c8ec870949cebdef2b293666dbca42', + emailVerified: true, + email: 'pauline.dupont@mii.com', + password: '$2a$12$vLQjJ9zAWyUwiFLeQDa6w.XzrlgPBhw.2GWrjog/yuEjIaZnQwmZu', + role: 0, + name: 'DUPONT', + surname: 'Pauline', + personalOffers: [], + }; + } else { + return null; + } + } + + isEmployerLinkedtoUser(id: string) { + if (id == '6231aefe76598527c8d0b5bc') return true; + else { + return false; + } + } + + isJobLinkedtoUser(id: string) { + if (id == '6231aefe76598527c8d0b5bc') return true; + else { + return false; + } + } + getAdmins() { + return { + _id: '6231aefe76598527c8d0b5bc', + validationToken: + 'cf1c74c22cedb6b575945098db42d2f493fb759c9142c6aff7980f252886f36ee086574ee99a06bc99119079257116c959c8ec870949cebdef2b293666dbca42', + emailVerified: true, + email: 'admin@admin.com', + password: '$2a$12$vLQjJ9zAWyUwiFLeQDa6w.XzrlgPBhw.2GWrjog/yuEjIaZnQwmZu', + role: 0, + name: 'admin', + surname: 'admin', + personalOffers: [], + }; + } + getPendingStructures() { return [ { structureId: '6093ba0e2ab5775cfc01ed3e', structureName: 'a', userEmail: 'paula.dubois@mii.com', + updatedAt: '2021-03-02T10:07:48.000Z', }, { structureId: '6903ba0e2ab5775cfc01ed4d', structureName: "L'Atelier Numérique", userEmail: 'jacques.dupont@mii.com', + updatedAt: '2021-03-02T10:07:48.000Z', }, ]; } @@ -211,6 +261,18 @@ export class UsersServiceMock { return await []; } + async populateEmployerswithUsers(): Promise<User[]> { + return await []; + } + + async populateJobswithUsers(): Promise<User[]> { + return await []; + } + + replaceEmployers(source, tar): IUser[] { + return []; + } + public async addPersonalOffer(userId: string, personalOfferDocument: PersonalOfferDocument): Promise<IUser> { if (userId === '6036721022462b001334c4bb') { const personalOffer: PersonalOffer = { ...personalOfferDocument } as PersonalOffer; @@ -240,4 +302,12 @@ export class UsersServiceMock { } throw new HttpException('User not found for the personal offer attachment', HttpStatus.BAD_REQUEST); } + + async updateUserJob() { + return await []; + } + + async updateUserEmployer() { + return await []; + } }