diff --git a/package-lock.json b/package-lock.json index f10f9a2a45196ae95f7889d264d1cf2c5a19755b..2393a08417e23f492a977a8607d146de5c1dea34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2340,7 +2340,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "dev": true }, "proxy-middleware": { diff --git a/package.json b/package.json index eebbecb226b9c83119f81144dabab412784edee3..218ba105a0470e3f8c7c647e049ee393a8cc339e 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "release": "standard-version", "init-db": "node ./scripts/init-db.js", - "test": "jest", + "test": "jest --config ./test/jest.json --coverage", "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", diff --git a/scripts/data/employers.js b/scripts/data/employers.js new file mode 100644 index 0000000000000000000000000000000000000000..74be23fb24eb7a6ac645cc00ad167c10d64b4da7 --- /dev/null +++ b/scripts/data/employers.js @@ -0,0 +1,112 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const mongoose = require('mongoose'); + +module.exports = { + data: [ + { + _id: mongoose.Types.ObjectId('6285289d02c9259438c802d3'), + name: 'Association', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('62867faf7a5b13480c22b2fc'), + name: 'CAF', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('628dfc353a0f370acc450ac3'), + name: 'CARSAT', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('628dfc353a0f370acc450ac4'), + name: 'CCAS', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('628dfc353a0f370acc450ac5'), + name: 'CPAM', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('628dfc353a0f370acc450ac6'), + name: 'Centre socio-culturel', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('628dfc353a0f370acc450ac7'), + name: 'Cyberbase', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('628dfc353a0f370acc450ac8'), + name: 'Espace de coworking', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('628dfc353a0f370acc450ac9'), + name: 'Fablab', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca830'), + name: 'MJC', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca831'), + name: 'Mairie', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca832'), + name: 'Maison France Service', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca833'), + name: 'Missions locales', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca834'), + name: 'Médiathèque / Bibliothèque', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca835'), + name: 'Métropole de Lyon', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca836'), + name: 'Pimms', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca837'), + name: 'Pôle emploi', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca838'), + name: 'Ressourcerie', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca839'), + name: "Structure d'insertion", + validated: true, + }, + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca83a'), + name: 'Structure de formation', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca83b'), + name: 'Structure information jeunesse (SIJ)', + validated: true, + }, + ], +}; diff --git a/scripts/data/jobs.js b/scripts/data/jobs.js new file mode 100644 index 0000000000000000000000000000000000000000..01d5076cca78f620c8f7cf975d1d781683360566 --- /dev/null +++ b/scripts/data/jobs.js @@ -0,0 +1,49 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const mongoose = require('mongoose'); + +module.exports = { + data: [ + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca845'), + hasPersonalOffer: true, + name: 'Formateur', + validated: true, + }, + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca846'), + name: 'CNFS', + hasPersonalOffer: true, + validated: true, + }, + { + _id: mongoose.Types.ObjectId('627b6ca899862168705ca847'), + hasPersonalOffer: true, + name: 'Médiateur numérique', + validated: true, + }, + { + hasPersonalOffer: false, + _id: mongoose.Types.ObjectId('627b6ca899862168705ca848'), + name: 'Travailleur social', + validated: true, + }, + { + hasPersonalOffer: false, + _id: mongoose.Types.ObjectId('627b6ca899862168705ca849'), + name: 'Conseiller', + validated: true, + }, + { + hasPersonalOffer: false, + _id: mongoose.Types.ObjectId('627b6ca899862168705ca84a'), + name: 'Chef de projet', + validated: true, + }, + { + hasPersonalOffer: false, + _id: mongoose.Types.ObjectId('6283603c51d1098fd4a8facf'), + name: 'Élu', + validated: true, + }, + ], +}; diff --git a/scripts/data/users.js b/scripts/data/users.js index fb5ebc814ed025c1c234f379aa67412d9f4f7be2..0ca9299accd0820ad23df9f5e9ccd878802a0b24 100644 --- a/scripts/data/users.js +++ b/scripts/data/users.js @@ -17,6 +17,8 @@ module.exports = { emailVerified: true, email: 'admin@admin.com', structureOutdatedMailSent: [], + employer: mongoose.Types.ObjectId('627b6ca899862168705ca836'), + job: mongoose.Types.ObjectId('627b6ca899862168705ca846'), }, { structureOutdatedMailSent: [], @@ -28,10 +30,12 @@ module.exports = { resetPasswordToken: null, validationToken: null, emailVerified: true, - email: 'paula.dubois@mii.com', + email: 'paula.dubois@test.com', name: 'Paula', surname: 'DUBOIS', phone: '06 07 08 09 10', + employer: mongoose.Types.ObjectId('627b6ca899862168705ca836'), + job: mongoose.Types.ObjectId('627b6ca899862168705ca846'), }, { structureOutdatedMailSent: [], @@ -52,6 +56,128 @@ module.exports = { name: 'Jean-Paul', surname: 'DESCHAMPS', phone: '06 11 11 11 11', + employer: mongoose.Types.ObjectId('627b6ca899862168705ca835'), + job: mongoose.Types.ObjectId('627b6ca899862168705ca848'), + }, + { + structureOutdatedMailSent: [], + pendingStructuresLink: [], + structuresLink: [ + mongoose.Types.ObjectId('61e9260c2ac971550065e262'), + mongoose.Types.ObjectId('61e9260b2ac971550065e261'), + ], + newEmail: null, + changeEmailToken: null, + role: 0, + resetPasswordToken: null, + validationToken: null, + emailVerified: true, + email: 'marine.ducanal@test.com', + name: 'Marine', + surname: 'DUCANAL', + phone: '06 11 11 11 11', + employer: mongoose.Types.ObjectId('627b6ca899862168705ca837'), + job: mongoose.Types.ObjectId('627b6ca899862168705ca848'), + }, + { + structureOutdatedMailSent: [], + pendingStructuresLink: [], + structuresLink: [ + mongoose.Types.ObjectId('61e9260c2ac971550065e262'), + mongoose.Types.ObjectId('61e9260b2ac971550065e261'), + ], + newEmail: null, + changeEmailToken: null, + role: 0, + resetPasswordToken: null, + validationToken: null, + emailVerified: true, + email: 'hugo.nouts@test.com', + name: 'Hugo', + surname: 'NOUTS', + phone: '06 11 11 11 11', + employer: mongoose.Types.ObjectId('627b6ca899862168705ca833'), + job: mongoose.Types.ObjectId('627b6ca899862168705ca846'), + }, + { + structureOutdatedMailSent: [], + pendingStructuresLink: [], + structuresLink: [ + mongoose.Types.ObjectId('61e9260c2ac971550065e262'), + mongoose.Types.ObjectId('61e9260b2ac971550065e261'), + ], + newEmail: null, + changeEmailToken: null, + role: 0, + resetPasswordToken: null, + validationToken: null, + emailVerified: true, + email: 'bastien.dumont@test.com', + name: 'Bastien', + surname: 'DUMONT', + phone: '06 11 11 11 11', + employer: mongoose.Types.ObjectId('627b6ca899862168705ca835'), + job: mongoose.Types.ObjectId('627b6ca899862168705ca849'), + }, + { + structureOutdatedMailSent: [], + pendingStructuresLink: [], + structuresLink: [ + mongoose.Types.ObjectId('61e9260c2ac971550065e262'), + mongoose.Types.ObjectId('61e9260b2ac971550065e261'), + ], + newEmail: null, + changeEmailToken: null, + role: 0, + resetPasswordToken: null, + validationToken: null, + emailVerified: true, + email: 'guilhem.carron@test.com', + name: 'Guilhem', + surname: 'CARRON', + phone: '06 11 11 11 11', + employer: mongoose.Types.ObjectId('627b6ca899862168705ca834'), + job: mongoose.Types.ObjectId('627b6ca899862168705ca848'), + }, + { + structureOutdatedMailSent: [], + pendingStructuresLink: [], + structuresLink: [ + mongoose.Types.ObjectId('61e9260c2ac971550065e262'), + mongoose.Types.ObjectId('61e9260b2ac971550065e261'), + ], + newEmail: null, + changeEmailToken: null, + role: 0, + resetPasswordToken: null, + validationToken: null, + emailVerified: true, + email: 'hugo.subtil@test.com', + name: 'Hugo', + surname: 'SUBTIL', + phone: '06 11 11 11 11', + employer: mongoose.Types.ObjectId('627b6ca899862168705ca835'), + job: mongoose.Types.ObjectId('627b6ca899862168705ca846'), + }, + { + structureOutdatedMailSent: [], + pendingStructuresLink: [], + structuresLink: [ + mongoose.Types.ObjectId('61e9260c2ac971550065e262'), + mongoose.Types.ObjectId('61e9260b2ac971550065e261'), + ], + newEmail: null, + changeEmailToken: null, + role: 0, + resetPasswordToken: null, + validationToken: null, + emailVerified: true, + email: 'remi.pailharey@test.com', + name: 'Rémi', + surname: 'PAILHAREY', + phone: '06 11 11 11 11', + employer: mongoose.Types.ObjectId('627b6ca899862168705ca835'), + job: mongoose.Types.ObjectId('627b6ca899862168705ca845'), }, ], }; diff --git a/scripts/init-db.js b/scripts/init-db.js index 52c7ce70bb70524d78a66f44a1ede1ada701094a..2041dc2562be77a1e59d115dc7fe69aa162017af 100644 --- a/scripts/init-db.js +++ b/scripts/init-db.js @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ // eslint-disable-next-line @typescript-eslint/no-var-requires const mongoose = require('mongoose'); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -7,6 +8,8 @@ const categoriesOthersData = require('./data/categoriesOthers'); const categoriesAccompanementsData = require('./data/categoriesAccompanements'); const categoriesFormationData = require('./data/categoriesFormation'); const structuresData = require('./data/structures'); +const jobsData = require('./data/jobs'); +const employersData = require('./data/employers'); // eslint-disable-next-line @typescript-eslint/no-var-requires const bcrypt = require('bcrypt'); // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -40,8 +43,9 @@ const handleError = async (name, err) => { else throw err; } else console.log(`-- ${name} collection dropped --`); }; + // define Schema -var usersSchema = mongoose.Schema({ +const usersSchema = mongoose.Schema({ name: String, surname: String, email: String, @@ -56,30 +60,50 @@ var usersSchema = mongoose.Schema({ pendingStructuresLink: [], password: String, phone: String, + employer: String, + job: String, }); -var structuresTypeSchema = mongoose.Schema( + +const jobsSchema = mongoose.Schema( + { + name: String, + hasPersonalOffer: Boolean, + validated: Boolean, + }, + { collection: 'jobs' } +); + +const employersSchema = mongoose.Schema( + { + name: String, + validated: Boolean, + }, + { collection: 'employers' } +); + +const structuresTypeSchema = mongoose.Schema( { name: String, values: [], }, { collection: 'structuretype' } ); -var categoriesOthersSchema = mongoose.Schema({ +const categoriesOthersSchema = mongoose.Schema({ name: String, id: String, modules: [], }); -var categoriesAccompanementsSchema = mongoose.Schema({ +const categoriesAccompanementsSchema = mongoose.Schema({ name: String, id: String, modules: [], }); -var categoriesFormationSchema = mongoose.Schema({ +const categoriesFormationSchema = mongoose.Schema({ name: String, id: String, modules: [], }); -var structuresSchema = mongoose.Schema({ +const structuresSchema = mongoose.Schema({ numero: String, createdAt: String, updatedAt: String, @@ -123,12 +147,14 @@ var structuresSchema = mongoose.Schema({ }); // compile schema to model -var User = mongoose.model('Users', usersSchema); -var structuresType = mongoose.model('structureType', structuresTypeSchema); -var categoriesOthers = mongoose.model('categoriesOthers', categoriesOthersSchema); -var categoriesAccompanements = mongoose.model('CategoriesAccompagnement', categoriesAccompanementsSchema); -var categoriesFormation = mongoose.model('categoriesFormation', categoriesFormationSchema); -var structures = mongoose.model('structures', structuresSchema); +const user = mongoose.model('users', usersSchema); +const structuresType = mongoose.model('structureType', structuresTypeSchema); +const categoriesOthers = mongoose.model('categoriesOthers', categoriesOthersSchema); +const categoriesAccompanements = mongoose.model('CategoriesAccompagnement', categoriesAccompanementsSchema); +const categoriesFormation = mongoose.model('categoriesFormation', categoriesFormationSchema); +const structures = mongoose.model('structures', structuresSchema); +const jobs = mongoose.model('jobs', jobsSchema); +const employers = mongoose.model('employers', employersSchema); /* drop users collections */ mongoose.connection.dropCollection('users', async (err) => { @@ -140,7 +166,7 @@ mongoose.connection.dropCollection('users', async (err) => { user.password = hashPassword(); }); // save model to database - User.create(userData.data, function (error, user) { + user.create(userData.data, (error) => { if (error) return console.error(error); console.log('-- Users collection initialized --'); }); @@ -180,6 +206,20 @@ mongoose.connection.dropCollection('structures', async (err) => { await handleError('structures', err); structures.create(structuresData.data, (error) => { if (error) return console.error(error); + }); +}); +/* Create structures */ +mongoose.connection.dropCollection('jobs', async (err) => { + await handleError('jobs', err); + jobs.create(jobsData.data, (error) => { + if (error) return console.error(error); + }); +}); +/* Create structures */ +mongoose.connection.dropCollection('employers', async (err) => { + await handleError('employers', err); + employers.create(employersData.data, (error) => { + if (error) return console.error(error); process.exit(0); }); }); diff --git a/src/auth/auth.controller.spec.ts b/src/auth/auth.controller.spec.ts index 95c9ef730d15cbedc989a24e41e9618795c86053..8fccb88aae347fbacbe9a58e587e33013ac4e158 100644 --- a/src/auth/auth.controller.spec.ts +++ b/src/auth/auth.controller.spec.ts @@ -6,6 +6,7 @@ import { AuthServiceMock } from '../../test/mock/services/auth.mock.service'; import { ConfigurationModule } from '../configuration/configuration.module'; import { MailerModule } from '../mailer/mailer.module'; import { User } from '../users/schemas/user.schema'; +import { UserRegistrySearchService } from '../users/services/userRegistry-search.service'; import { UsersService } from '../users/services/users.service'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; @@ -29,6 +30,10 @@ describe('AuthController', () => { providers: [ AuthService, UsersService, + { + provide: UserRegistrySearchService, + useClass: jest.fn(), + }, { provide: AuthService, useClass: AuthServiceMock, diff --git a/src/users/controllers/userRegistry.controller.spec.ts b/src/users/controllers/userRegistry.controller.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..1cbfb3d63e9e376ac30804a647a558b406dcd001 --- /dev/null +++ b/src/users/controllers/userRegistry.controller.spec.ts @@ -0,0 +1,125 @@ +import { getModelToken } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; +import { mulitpleUsers, mulitpleUsersES } from '../../../test/mock/data/users.mock.data'; +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 { ConfigurationModule } from '../../configuration/configuration.module'; +import { RolesGuard } from '../guards/roles.guard'; +import { User } from '../schemas/user.schema'; +import { UserRegistryService } from '../services/userRegistry.service'; +import { UsersRegistryController } from './userRegistry.controller'; + +describe('UserRegistryController', () => { + let controller: UsersRegistryController; + + const userRegistryServiceMock = { + findAllForIndexation: jest.fn(), + countAllUserRegistry: jest.fn(), + findAllUserRegistry: jest.fn(), + findUsersByNameEmployerOrJob: jest.fn(), + searchByNameAndSurname: jest.fn(), + initUserRegistryIndex: jest.fn(), + populateES: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigurationModule], + providers: [ + { + provide: UserRegistryService, + useValue: userRegistryServiceMock, + }, + { + provide: getModelToken('User'), + useValue: User, + }, + ], + controllers: [UsersRegistryController], + }) + .overrideGuard(JwtAuthGuard) + .useValue(mockJwtAuthGuard) + .overrideGuard(RolesGuard) + .useValue(mockRoleGuard) + .compile(); + + controller = module.get<UsersRegistryController>(UsersRegistryController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('findAll', () => { + it('should findAll with searchTerm, job and employer', async () => { + userRegistryServiceMock.findUsersByNameEmployerOrJob.mockResolvedValue([mulitpleUsers[0]]); + const reply = await controller.findAll({ search: 'adm' }, { page: 1, job: ['CNFS'], employer: ['Pimms'] }); + expect(reply).toStrictEqual([mulitpleUsers[0]]); + }); + it('should findAll with searchTerm, empty job and empty employer', async () => { + userRegistryServiceMock.findUsersByNameEmployerOrJob.mockResolvedValue([mulitpleUsers[0]]); + const reply = await controller.findAll({ search: 'adm' }, { page: 1, job: [], employer: [] }); + expect(reply).toStrictEqual([mulitpleUsers[0]]); + }); + it('should findAll with searchTerm and no filter arrays', async () => { + userRegistryServiceMock.findUsersByNameEmployerOrJob.mockResolvedValue([mulitpleUsers[0]]); + const reply = await controller.findAll({ search: 'adm' }, { page: 1 }); + expect(reply).toStrictEqual([mulitpleUsers[0]]); + }); + it('should findAll with searchTerm and empty job', async () => { + userRegistryServiceMock.findUsersByNameEmployerOrJob.mockResolvedValue([mulitpleUsers[0]]); + const reply = await controller.findAll({ search: 'adm' }, { page: 1, job: [] }); + expect(reply).toStrictEqual([mulitpleUsers[0]]); + }); + it('should findAll with searchTerm and empty employer', async () => { + userRegistryServiceMock.findUsersByNameEmployerOrJob.mockResolvedValue([mulitpleUsers[0]]); + const reply = await controller.findAll({ search: 'adm' }, { page: 1, employer: [] }); + expect(reply).toStrictEqual([mulitpleUsers[0]]); + }); + it('should findAll with no searchTerm and employer filter', async () => { + userRegistryServiceMock.findUsersByNameEmployerOrJob.mockResolvedValue([mulitpleUsers[0]]); + const reply = await controller.findAll({ search: '' }, { page: 1, employer: ['CAF'] }); + expect(reply).toStrictEqual([mulitpleUsers[0]]); + }); + it('should findAll with no searchTerm and job filter', async () => { + userRegistryServiceMock.findUsersByNameEmployerOrJob.mockResolvedValue([mulitpleUsers[0]]); + const reply = await controller.findAll({ search: '' }, { page: 1, job: ['CNFS'] }); + expect(reply).toStrictEqual([mulitpleUsers[0]]); + }); + it('should findAll with no searchTerm and filters', async () => { + userRegistryServiceMock.findUsersByNameEmployerOrJob.mockResolvedValue([mulitpleUsers[0]]); + const reply = await controller.findAll({ search: '' }, { page: 1, job: ['CNFS'], employer: ['CAF'] }); + expect(reply).toStrictEqual([mulitpleUsers[0]]); + }); + it('should findAll with searchTerm and undefined filters', async () => { + userRegistryServiceMock.findUsersByNameEmployerOrJob.mockResolvedValue([mulitpleUsers[0]]); + const reply = await controller.findAll({ search: 'adm' }); + expect(reply).toStrictEqual([mulitpleUsers[0]]); + }); + it('should findAll with no searchTerm and no filter arrays', async () => { + userRegistryServiceMock.findAllUserRegistry.mockResolvedValue(mulitpleUsers); + const reply = await controller.findAll({ search: '' }, { page: 1 }); + expect(reply).toBe(mulitpleUsers); + }); + it('should findAll with empty search end undefined filters', async () => { + userRegistryServiceMock.findAllUserRegistry.mockResolvedValue(mulitpleUsers); + const reply = await controller.findAll({ search: '' }); + expect(reply).toBe(mulitpleUsers); + }); + }); + describe('findAllCount', () => { + it('should findAllCount', async () => { + userRegistryServiceMock.countAllUserRegistry.mockResolvedValue(10); + const reply = await controller.findAllCount(); + expect(reply).toStrictEqual(10); + }); + }); + describe('resetES', () => { + it('should reset elastic search indexes', async () => { + userRegistryServiceMock.initUserRegistryIndex.mockResolvedValue(mulitpleUsersES); + const reply = await controller.resetES(); + expect(reply).toStrictEqual(mulitpleUsersES); + }); + }); +}); diff --git a/src/users/controllers/userRegistry.controller.ts b/src/users/controllers/userRegistry.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4cbb8da3a340927060fc7de8e94f1d87fce90bb --- /dev/null +++ b/src/users/controllers/userRegistry.controller.ts @@ -0,0 +1,60 @@ +import { Body, Controller, Get, Logger, Post, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { Roles } from '../decorators/roles.decorator'; +import { RolesGuard } from '../guards/roles.guard'; +import { IUserRegistry, UserRegistryPaginatedResponse } from '../interfaces/userRegistry.interface'; +import { UserRegistryService } from '../services/userRegistry.service'; + +@ApiTags('userRegistry') +@Controller('userRegistry') +export class UsersRegistryController { + private readonly logger = new Logger(UsersRegistryController.name); + constructor(private userRegistryService: UserRegistryService) {} + + /** + * Find all users in Registry. If search is given as param, filter on it. Otherwise return everything. Results comes paginated (default is 20 per page, defined in service) + * @param query {Query} + * @returns {PaginatedResponse} + */ + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT') + @Post() + public async findAll( + @Query() query?: { search: string }, + @Body() filters?: { job?: string[]; employer?: string[]; page: number } + ): Promise<UserRegistryPaginatedResponse> { + if (query.search || filters?.job?.length || filters?.employer?.length) { + this.logger.debug(`findAll with query ${query.search}`); + return this.userRegistryService.findUsersByNameEmployerOrJob( + query.search || '', + filters?.page || 1, + filters?.job || [], + filters?.employer || [] + ); + } + this.logger.debug('findAll without query'); + return this.userRegistryService.findAllUserRegistry(filters?.page || 1); + } + + /** + * Return every user in registry + * @returns {Promise<number>} + */ + @Get('/count') + public async findAllCount(): Promise<number> { + this.logger.debug('findAll UserRegistry'); + return this.userRegistryService.countAllUserRegistry(); + } + + /** + * Init or reset search index for users + * @returns {IUserRegistry[]} + */ + @Post('searchIndex') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin') + public async resetES(): Promise<IUserRegistry[]> { + return this.userRegistryService.initUserRegistryIndex(); + } +} diff --git a/src/users/interfaces/userRegistry-search-body.interface.ts b/src/users/interfaces/userRegistry-search-body.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2e07925ec6ec143aef9e5541b9cdc7a3a294335 --- /dev/null +++ b/src/users/interfaces/userRegistry-search-body.interface.ts @@ -0,0 +1,10 @@ +import { Employer } from '../schemas/employer.schema'; +import { Job } from '../schemas/job.schema'; + +export interface UserRegistrySearchBody { + id: string; + name: string; + surname: string; + job: Job; + employer: Employer; +} diff --git a/src/users/interfaces/userRegistry-search-response.interface.ts b/src/users/interfaces/userRegistry-search-response.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..1359d0bc1d97892f418e1074464c08bbfd5266c4 --- /dev/null +++ b/src/users/interfaces/userRegistry-search-response.interface.ts @@ -0,0 +1,12 @@ +import { UserRegistrySearchBody } from './userRegistry-search-body.interface'; + +export interface UserRegistrySearchResult { + hits: { + total: number; + max_score: number; + hits: Array<{ + _score: number; + _source: UserRegistrySearchBody; + }>; + }; +} diff --git a/src/users/interfaces/userRegistry.interface.ts b/src/users/interfaces/userRegistry.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4843544ecfce5ba07c33a7085f9c2ee1f2d4a42 --- /dev/null +++ b/src/users/interfaces/userRegistry.interface.ts @@ -0,0 +1,23 @@ +import { IUser } from './user.interface'; + +export type IUserRegistry = Omit< + IUser, + | 'email' + | 'phone' + | 'password' + | 'emailVerified' + | 'validationToken' + | 'resetPasswordToken' + | 'role' + | 'changeEmailToken' + | 'newEmail' + | 'structuresLink' + | 'pendingStructuresLink' + | 'structureOutdatedMailSent' + | 'personalOffers' + | 'description' +>; +export interface UserRegistryPaginatedResponse { + count: number; + docs: IUserRegistry[]; +} diff --git a/src/users/schemas/user.schema.ts b/src/users/schemas/user.schema.ts index 20123c65d0fb507671c751507222a547539e729d..3c1b9da92e756f6b7edb3ecb2b29925d8b35799a 100644 --- a/src/users/schemas/user.schema.ts +++ b/src/users/schemas/user.schema.ts @@ -66,3 +66,4 @@ export class User { } export const UserSchema = SchemaFactory.createForClass(User); +UserSchema.index({ name: 'text', surname: 'text' }); diff --git a/src/users/services/userRegistry-search.service.spec.ts b/src/users/services/userRegistry-search.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..b790a65f28ef5530ef6ddc35d6f81dd21d3de7c0 --- /dev/null +++ b/src/users/services/userRegistry-search.service.spec.ts @@ -0,0 +1,64 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { mulitpleUsers, mulitpleUsersES } from '../../../test/mock/data/users.mock.data'; +import { ConfigurationModule } from '../../configuration/configuration.module'; +import { SearchModule } from '../../search/search.module'; +import { IUserRegistry } from '../interfaces/userRegistry.interface'; +import { UserRegistrySearchService } from './userRegistry-search.service'; + +describe('UserRegistrySearchService Search cases', () => { + let service: UserRegistrySearchService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [SearchModule, ConfigurationModule], + providers: [UserRegistrySearchService], + }).compile(); + + service = module.get<UserRegistrySearchService>(UserRegistrySearchService); + service['index'] = 'user-unit-test'; + // Init test cases + await service.dropIndex(); + await service.createUserRegistryIndex(); + await Promise.all(mulitpleUsersES.map((user: any) => service.indexUserRegistry(user))); + + // wait for the new structures to be indexed before search + await service.refreshIndexUserRegistry(); + await new Promise((r) => setTimeout(r, 2000)); + }, 10000); + + it('should be defined', async () => { + expect(service).toBeDefined(); + }); + describe('Search method', () => { + it('should find Guilhem', async () => { + const res = await service.search('Guilhem'); + expect(res[0].surname).toBe('Guilhem'); + expect(res.length).toBe(1); + }); + it('should find adm', async () => { + const res = await service.search('adm'); + expect(res[0].name).toBe('Admin'); + expect(res.length).toBe(1); + }); + it('should find empty string', async () => { + const res = await service.search(''); + expect(res.length).toBe(6); + }); + }); + describe('Indexation methods', () => { + it('should index User', async () => { + const res = await Promise.all(mulitpleUsersES.map((user: any) => service.indexUserRegistry(user))); + expect(res).toBeTruthy(); + expect(res.length).toBe(6); + }); + it('should update index', async () => { + const res = await service.update(mulitpleUsers[0] as IUserRegistry); + expect(res).toBeTruthy(); + }); + it('should delete index', async () => { + const resAdm = await service.search('adm'); + const res = await service.deleteIndex(resAdm[0] as IUserRegistry); + expect(res).toBeTruthy(); + }); + }); +}); diff --git a/src/users/services/userRegistry-search.service.ts b/src/users/services/userRegistry-search.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..43dd4cb63808bd31481bb131fb76911f81f0178a --- /dev/null +++ b/src/users/services/userRegistry-search.service.ts @@ -0,0 +1,120 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ElasticsearchService } from '@nestjs/elasticsearch'; +import { UserRegistrySearchBody } from '../interfaces/userRegistry-search-body.interface'; +import { UserRegistrySearchResult } from '../interfaces/userRegistry-search-response.interface'; +import { IUserRegistry } from '../interfaces/userRegistry.interface'; + +@Injectable() +export class UserRegistrySearchService { + private index = 'user'; + private readonly logger = new Logger(UserRegistrySearchService.name); + + constructor(private readonly elasticsearchService: ElasticsearchService) {} + + public async indexUserRegistry(user: IUserRegistry): Promise<IUserRegistry> { + this.logger.debug(`indexUser ${user.name} ${user.surname}`); + this.elasticsearchService.index<UserRegistrySearchResult, UserRegistrySearchBody>({ + index: this.index, + id: user.id, + body: { + name: user.name, + id: user.id, + surname: user.surname, + job: user.job, + employer: user.employer, + }, + }); + return user; + } + + public async createUserRegistryIndex(): Promise<any> { + this.logger.debug('createUserRegistryIndex'); + return this.elasticsearchService.indices.create({ + index: this.index, + body: { + settings: { + analysis: { + analyzer: { + default: { + type: 'french', + }, + default_search: { + type: 'french', + }, + }, + }, + }, + }, + }); + } + + public async dropIndex(): Promise<any> { + this.logger.debug('dropIndex'); + const foundIndexes = ( + await this.elasticsearchService.indices.exists({ + index: this.index, + }) + ).body; + if (foundIndexes) { + return this.elasticsearchService.indices.delete({ + index: this.index, + }); + } + } + + public async deleteIndex(user: IUserRegistry): Promise<IUserRegistry> { + this.logger.debug('deleteIndex'); + this.elasticsearchService.delete<UserRegistrySearchResult, IUserRegistry>({ + index: this.index, + id: user._id, + }); + return user; + } + + public async refreshIndexUserRegistry(): Promise<any> { + this.logger.debug('refreshIndexUserRegistry'); + return this.elasticsearchService.indices.refresh({ + index: this.index, + }); + } + + public async search(searchString: string): Promise<any[]> { + this.logger.debug(`search user with query: ${searchString}`); + searchString = searchString ? '*' + searchString + '*' : '*'; + const { body } = await this.elasticsearchService.search<UserRegistrySearchResult>({ + index: this.index, + body: { + from: 0, + size: 200, + query: { + query_string: { + analyze_wildcard: 'true', + query: searchString, + fields: ['name', 'surname'], + fuzziness: 'AUTO', + }, + }, + }, + }); + const sortedHits = body.hits.hits.filter(function (elem) { + return elem._score >= body.hits.max_score; + }); + return sortedHits.map((item) => item._source); + } + + public async update(user: IUserRegistry): Promise<any> { + this.logger.debug(`Updates user : ${user._id} `); + return this.elasticsearchService.update({ + index: this.index, + id: user._id, + body: { + doc: { + name: user.name, + surname: user.surname, + job: user.job, + employer: user.employer, + }, + }, + }); + } +} diff --git a/src/users/services/userRegistry.service.spec.ts b/src/users/services/userRegistry.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..0df3a840e63bbdc962004ba3b2c557a5b2361d64 --- /dev/null +++ b/src/users/services/userRegistry.service.spec.ts @@ -0,0 +1,241 @@ +import { HttpModule } from '@nestjs/axios'; +import { getModelToken } from '@nestjs/mongoose'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Types } from 'mongoose'; +import { mulitpleUsers, mulitpleUsersES } from '../../../test/mock/data/users.mock.data'; +import { ConfigurationModule } from '../../configuration/configuration.module'; +import { IUserRegistry } from '../interfaces/userRegistry.interface'; +import { EmployerService } from './employer.service'; +import { JobsService } from './jobs.service'; +import { UserRegistrySearchService } from './userRegistry-search.service'; +import { UserRegistryService } from './userRegistry.service'; + +describe('userRegistryService', () => { + let service: UserRegistryService; + const mockUserRegistryModel = { + find: jest.fn(() => mockUserRegistryModel), + populate: jest.fn(() => mockUserRegistryModel), + sort: jest.fn(() => mockUserRegistryModel), + select: jest.fn(() => mockUserRegistryModel), + exec: jest.fn(() => mockUserRegistryModel), + count: jest.fn(() => mockUserRegistryModel), + limit: jest.fn(() => mockUserRegistryModel), + }; + + const mockUserRegistrySearchService = { + search: jest.fn(), + dropIndex: jest.fn(), + deleteIndex: jest.fn(), + createUserRegistryIndex: jest.fn(), + indexUserRegistry: jest.fn(), + }; + const mockEmployersService = { + findByName: jest.fn(), + }; + const mockJobsService = { + findByName: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigurationModule, HttpModule], + providers: [ + UserRegistryService, + { + provide: UserRegistrySearchService, + useValue: mockUserRegistrySearchService, + }, + { + provide: getModelToken('User'), + useValue: mockUserRegistryModel, + }, + { + provide: JobsService, + useValue: mockJobsService, + }, + { + provide: EmployerService, + useValue: mockEmployersService, + }, + ], + }).compile(); + service = module.get<UserRegistryService>(UserRegistryService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findAll', () => { + const result: IUserRegistry[] = [ + { + _id: Types.ObjectId('6319dfa79672971e1f8fe1b7'), + surname: 'ADMIN', + name: 'Admin', + employer: { + name: 'Pimms', + validated: true, + }, + job: { + hasPersonalOffer: true, + + name: 'CNFS', + validated: true, + }, + }, + ] as IUserRegistry[]; + it('should findAll UserRegistry for indexation', async () => { + mockUserRegistryModel.exec.mockResolvedValueOnce(result); + expect(await service.findAllForIndexation()).toBe(result); + }); + + it('should findAll UserRegistry count', async () => { + mockUserRegistryModel.exec.mockResolvedValueOnce(result.length); + expect(await service.countAllUserRegistry()).toBe(result.length); + }); + it('should findAllUserRegistry with page number 1', async () => { + const res = { count: 1, docs: result }; + mockUserRegistryModel.exec.mockResolvedValueOnce(1); + mockUserRegistryModel.exec.mockResolvedValueOnce(result); + expect(await service.findAllUserRegistry(1)).toStrictEqual(res); + }); + }); + describe('find with filter', () => { + const result: IUserRegistry[] = [ + { + _id: Types.ObjectId('6319dfa79672971e1f8fe1b7'), + surname: 'ADMIN', + name: 'Admin', + employer: { + name: 'Pimms', + validated: true, + }, + job: { + hasPersonalOffer: true, + + name: 'CNFS', + validated: true, + }, + }, + ] as IUserRegistry[]; + const res = { + count: 1, + docs: [ + { + _id: Types.ObjectId('6319dfa79672971e1f8fe1b7'), + surname: 'ADMIN', + name: 'Admin', + employer: { + name: 'Pimms', + validated: true, + }, + job: { + hasPersonalOffer: true, + + name: 'CNFS', + validated: true, + }, + }, + ], + }; + it('should findUsersByNameEmployerOrJob with string param and get a result', async () => { + mockUserRegistrySearchService.search.mockResolvedValueOnce(result); + expect(await service.findUsersByNameEmployerOrJob('adm', 1)).toStrictEqual(res); + }); + it('should findUsersByNameEmployerOrJob with string param and get no result', async () => { + const emptyRes = { count: 0, docs: [] }; + mockUserRegistrySearchService.search.mockResolvedValueOnce([]); + expect(await service.findUsersByNameEmployerOrJob('azerty', 1)).toStrictEqual(emptyRes); + }); + it('should findUsersByNameEmployerOrJob with no string param and filters', async () => { + const res = { count: 1, docs: [mulitpleUsersES[1]] }; + const jobList = [ + { + hasPersonalOffer: true, + name: 'CNFS', + validated: true, + }, + ]; + const employerList = [{ name: 'Métropole', validated: true }]; + mockUserRegistrySearchService.search.mockResolvedValueOnce(mulitpleUsersES); + mockJobsService.findByName.mockResolvedValueOnce(jobList[0]); + mockEmployersService.findByName.mockResolvedValueOnce(employerList[0]); + mockUserRegistryModel.exec.mockResolvedValueOnce(mulitpleUsers); + expect(await service.findUsersByNameEmployerOrJob('', 1, ['CNFS'], ['Métropole'])).toStrictEqual(res); + }); + + it('should findUsersByNameEmployerOrJob with string param and filters', async () => { + const res = { count: 1, docs: [mulitpleUsersES[1]] }; + const jobList = [ + { + hasPersonalOffer: true, + name: 'CNFS', + validated: true, + }, + ]; + const employerList = [{ name: 'Métropole', validated: true }]; + mockUserRegistrySearchService.search.mockResolvedValueOnce([mulitpleUsersES[1]]); + mockJobsService.findByName.mockResolvedValueOnce(jobList[0]); + mockEmployersService.findByName.mockResolvedValueOnce(employerList[0]); + mockUserRegistryModel.exec.mockResolvedValueOnce(mulitpleUsers); + expect(await service.findUsersByNameEmployerOrJob('Guil', 1, ['CNFS'], ['Métropole'])).toStrictEqual(res); + }); + it('should findUsersByNameEmployerOrJob with string param and filters and return empty', async () => { + const res = { count: 0, docs: [] }; + const jobList = [ + { + hasPersonalOffer: true, + name: 'CNFS', + validated: true, + }, + ]; + const employerList = [{ name: 'Métropole', validated: true }]; + mockUserRegistrySearchService.search.mockResolvedValueOnce([]); + mockJobsService.findByName.mockResolvedValueOnce(jobList[0]); + mockEmployersService.findByName.mockResolvedValueOnce(employerList[0]); + mockUserRegistryModel.exec.mockResolvedValueOnce([]); + expect(await service.findUsersByNameEmployerOrJob('azerrttt', 1, ['CNFS'], ['Métropole'])).toStrictEqual(res); + }); + it('should findUsersByNameEmployerOrJob with string param and one filter', async () => { + const res = { count: 2, docs: [mulitpleUsersES[0], mulitpleUsersES[2]] }; + const employerList = [{ name: 'CAF', validated: true }]; + mockUserRegistrySearchService.search.mockResolvedValueOnce(mulitpleUsersES); + mockEmployersService.findByName.mockResolvedValueOnce(employerList[0]); + mockUserRegistryModel.exec.mockResolvedValueOnce(mulitpleUsers); + expect(await service.findUsersByNameEmployerOrJob('a', 1, [], ['CAF'])).toStrictEqual(res); + }); + + it('should findUsersByNameEmployerOrJob with no string param and one employer filter', async () => { + const res = { count: 2, docs: [mulitpleUsersES[2], mulitpleUsersES[3]] }; + + const jobList = [ + { + hasPersonalOffer: true, + name: 'Conseiller', + validated: true, + }, + ]; + mockUserRegistrySearchService.search.mockResolvedValueOnce(mulitpleUsersES); + + mockJobsService.findByName.mockResolvedValueOnce(jobList[0]); + mockUserRegistryModel.exec.mockResolvedValueOnce(mulitpleUsers); + expect(await service.findUsersByNameEmployerOrJob('', 1, ['Conseiller'], [])).toStrictEqual(res); + }); + }); + + describe('Search', () => { + it('should initUserRegistryIndex', async () => { + mockUserRegistryModel.exec.mockResolvedValueOnce(mulitpleUsers); + mockUserRegistrySearchService.dropIndex.mockResolvedValueOnce({}); + mockUserRegistrySearchService.createUserRegistryIndex.mockResolvedValueOnce({}); + mockUserRegistrySearchService.indexUserRegistry.mockResolvedValueOnce({}); + expect(await service.initUserRegistryIndex()).toBe(mulitpleUsers); + }); + + it('should findAllUserRegistry with page number 1', async () => { + mockUserRegistrySearchService.search.mockResolvedValueOnce([mulitpleUsers[0]]); + + expect(await service.searchByNameAndSurname('adm')).toStrictEqual([mulitpleUsers[0]]); + }); + }); +}); diff --git a/src/users/services/userRegistry.service.ts b/src/users/services/userRegistry.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a7f687b5404405f9107d4b60fc6a901b1e085a2 --- /dev/null +++ b/src/users/services/userRegistry.service.ts @@ -0,0 +1,147 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { IUser } from '../interfaces/user.interface'; +import { IUserRegistry, UserRegistryPaginatedResponse } from '../interfaces/userRegistry.interface'; +import { Employer } from '../schemas/employer.schema'; +import { Job } from '../schemas/job.schema'; +import { User } from '../schemas/user.schema'; +import { EmployerService } from './employer.service'; +import { JobsService } from './jobs.service'; +import { UserRegistrySearchService } from './userRegistry-search.service'; + +@Injectable() +export class UserRegistryService { + private readonly logger = new Logger(UserRegistryService.name); + constructor( + @InjectModel(User.name) private userModel: Model<IUser>, + private userRegistrySearchService: UserRegistrySearchService, + private jobsService: JobsService, + private employerService: EmployerService + ) {} + public maxPerPage = 20; + + public async findAllForIndexation(): Promise<IUserRegistry[]> { + return this.userModel + .find() + .select('name surname _id job employer ') + .populate('job employer') + .sort({ surname: 1 }) + .exec(); + } + + public async countAllUserRegistry(): Promise<number> { + return this.userModel + .find() + .populate('employer') + .populate('job') + .select('name surname employer job _id ') + .sort({ surname: 1 }) + .count() + .exec(); + } + + public async findAllUserRegistry(page: number): Promise<UserRegistryPaginatedResponse> { + const limit = this.maxPerPage * page; + const count = await this.countAllUserRegistry(); + const docs = await this.userModel + .find() + .populate('employer') + .populate('job') + .select('name surname employer job _id ') + .limit(limit) + .sort({ surname: 1 }) + .exec(); + return { count: count, docs: docs }; + } + + private callbackFilter(users: IUser[], employersList: Employer[], jobList: Job[]): IUser[] { + const jobNames: string[] = jobList.map((job) => job.name); + const employersNames: string[] = employersList.map((e) => e.name); + // For each filter list (job or employer), we'll filter the main user list in order to get only the user that have a job or employer contained in the filters array + // For this, we une findexIndex method on job/employer name + if (employersList?.length && jobList?.length) { + return users.filter( + (user) => + jobNames.findIndex((n) => user.job.name === n) > -1 && + employersNames.findIndex((n) => user.employer.name === n) > -1 + ); + } + + if (employersList?.length) { + return users.filter((user) => employersNames.findIndex((n) => user.employer.name === n) > -1); + } + if (jobList?.length) { + return users.filter((user) => jobNames.findIndex((n) => user.job.name === n) > -1); + } + } + + public async findUsersByNameEmployerOrJob( + searchParam: string, + page: number, + jobs?: string[], + employers?: string[] + ): Promise<UserRegistryPaginatedResponse> { + const results = await this.userRegistrySearchService.search(searchParam); + const limit = page * this.maxPerPage || this.maxPerPage; + if (jobs?.length || employers?.length) { + const jobList: Job[] = []; + const employersList: Employer[] = []; + if (jobs) { + for (const job of jobs) { + jobList.push(await this.jobsService.findByName(job)); + } + } + if (employers) { + for (const employer of employers) { + employersList.push(await this.employerService.findByName(employer)); + } + } + const resultsWithFilter = await this.userModel + .find({ + $or: [ + { + job: { $exists: true }, + }, + { + employer: { $exists: true }, + }, + ], + }) + .select('name surname employer job _id ') + .populate('employer job') + .sort({ surname: 1 }) + .exec() + .then((res) => { + return this.callbackFilter(res, employersList, jobList); + }); + const idsWithFilter = resultsWithFilter.map((user) => user._id.toString()); + const mergedResults = results.filter((user) => idsWithFilter.includes(user.id.toString())); + return { count: mergedResults.length, docs: mergedResults.splice(0, limit) }; + } + return { count: results.length, docs: results.splice(0, limit) }; + } + + // SEARCH + public async searchByNameAndSurname(searchString: string): Promise<any[]> { + this.logger.debug('searchByNameAndSurname'); + return this.userRegistrySearchService.search(searchString); + } + + public async initUserRegistryIndex(): Promise<IUserRegistry[]> { + Logger.log('Reset structures indexes'); + await this.userRegistrySearchService.dropIndex(); + await this.userRegistrySearchService.createUserRegistryIndex(); + return this.populateES(); + } + + private async populateES(): Promise<IUserRegistry[]> { + const users = await this.findAllForIndexation(); + await Promise.all( + users.map((user: IUserRegistry) => { + this.userRegistrySearchService.indexUserRegistry(user); + }) + ); + return users; + } +} diff --git a/src/users/services/users.service.spec.ts b/src/users/services/users.service.spec.ts index e9257eed319cbdd59b0a928d923cb0125d38140c..f88eeddc58c652836999571e1e7ac7aa02e3994a 100644 --- a/src/users/services/users.service.spec.ts +++ b/src/users/services/users.service.spec.ts @@ -17,6 +17,7 @@ import { User } from '../schemas/user.schema'; import { IUser } from '../interfaces/user.interface'; import { UpdateDetailsDto } from '../dto/update-details.dto'; import { DescriptionDto } from '../dto/description.dto'; +import { UserRegistrySearchService } from './userRegistry-search.service'; function hashPassword() { return bcrypt.hashSync(process.env.USER_PWD, process.env.SALT); @@ -24,14 +25,26 @@ function hashPassword() { const mockUserModel = { create: jest.fn(), - findOne: jest.fn(), + findOne: jest.fn(() => mockUserModel), findById: jest.fn(), deleteOne: jest.fn(), updateMany: jest.fn(), exec: jest.fn(), find: jest.fn(() => mockUserModel), sort: jest.fn(() => mockUserModel), - findByIdAndUpdate: jest.fn(), + populate: jest.fn(() => mockUserModel), + select: jest.fn(() => mockUserModel), + limit: jest.fn(() => mockUserModel), + findByIdAndUpdate: jest.fn(() => mockUserModel), + findPopulatedUserRegistryById: jest.fn(() => mockUserModel), +}; +const mockUserRegistrySearchService = { + indexUserRegistry: jest.fn(), + search: jest.fn(), + dropIndex: jest.fn(), + createindexUserRegistryIndex: jest.fn(), + deleteIndex: jest.fn(), + update: jest.fn(), }; describe('UsersService', () => { @@ -42,6 +55,11 @@ describe('UsersService', () => { imports: [MailerModule, ConfigurationModule], providers: [ UsersService, + UserRegistrySearchService, + { + provide: UserRegistrySearchService, + useValue: mockUserRegistrySearchService, + }, { provide: getModelToken('User'), useValue: mockUserModel, @@ -481,6 +499,7 @@ describe('UsersService', () => { const user = usersMockData[0]; const detailsDto: UpdateDetailsDto = { name: 'Michel', surname: 'Chelmi', phone: '0601020304' }; mockUserModel.findByIdAndUpdate.mockResolvedValueOnce({ ...user, ...detailsDto }); + mockUserModel.findPopulatedUserRegistryById.mockResolvedValueOnce({ ...user, ...detailsDto }); const updatedUser = await service.updateUserDetails('', { name: '', surname: '', phone: '' }); expect(updatedUser.name).toBe(detailsDto.name); expect(updatedUser.surname).toBe(detailsDto.surname); diff --git a/src/users/services/users.service.ts b/src/users/services/users.service.ts index ce13aedd541dee163004f11f63233a15ccc1cc5d..19c9d58b63c09a3e9eea3cf41f384c44b0b913fa 100644 --- a/src/users/services/users.service.ts +++ b/src/users/services/users.service.ts @@ -18,6 +18,8 @@ import { JobDocument } from '../schemas/job.schema'; import { PersonalOfferDocument } from '../../personal-offers/schemas/personal-offer.schema'; import { UpdateDetailsDto } from '../dto/update-details.dto'; import { DescriptionDto } from '../dto/description.dto'; +import { UserRegistrySearchService } from './userRegistry-search.service'; +import { IUserRegistry } from '../interfaces/userRegistry.interface'; @Injectable() export class UsersService { @@ -25,6 +27,7 @@ export class UsersService { constructor( @InjectModel(User.name) private userModel: Model<IUser>, private readonly mailerService: MailerService, + private userRegistrySearchService: UserRegistrySearchService, private configurationService: ConfigurationService ) {} @@ -55,6 +58,7 @@ export class UsersService { // Send verification email createUser = await this.verifyUserMail(createUser); createUser.save(); + this.userRegistrySearchService.indexUserRegistry(createUser); return this.findOne(createUserDto.email); } @@ -90,6 +94,15 @@ export class UsersService { public findAll(): Promise<User[]> { return this.userModel.find().populate('employer').populate('job').select('-password').exec(); } + public async findPopulatedUserRegistryById(userId: string): Promise<IUserRegistry> { + return this.userModel + .findOne({ _id: userId }) + .select('name surname _id job employer ') + .populate('job employer') + .sort({ surname: 1 }) + .limit(1) + .exec(); + } public findAllUnattached(): Promise<IUser[]> { return this.userModel @@ -641,6 +654,7 @@ export class UsersService { if (!user) { throw new HttpException('Invalid user email', HttpStatus.BAD_REQUEST); } + this.userRegistrySearchService.deleteIndex(user); return user.deleteOne(); } @@ -649,6 +663,7 @@ export class UsersService { if (!user) { throw new HttpException('Invalid user id', HttpStatus.BAD_REQUEST); } + this.userRegistrySearchService.deleteIndex(user); return user.deleteOne(); } @@ -697,7 +712,9 @@ export class UsersService { */ public async updateUserProfile(userId: Types.ObjectId, employer: EmployerDocument, job: JobDocument): Promise<any> { this.logger.debug(`updateUserProfile | ${userId}`); - return this.userModel.updateOne({ _id: userId }, { $set: { employer: employer._id, job: job._id } }); + const updated = await this.userModel.updateOne({ _id: userId }, { $set: { employer: employer._id, job: job._id } }); + if (updated) this.userRegistrySearchService.update(updated); + return updated; } /** @@ -707,7 +724,12 @@ export class UsersService { */ 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 } }); + const updated = await this.userModel.updateOne({ _id: userId }, { $set: { job: job._id } }); + if (updated) { + const populatedResult = await this.findPopulatedUserRegistryById(updated._id); + this.userRegistrySearchService.update(populatedResult); + } + return updated; } /** @@ -717,7 +739,12 @@ export class UsersService { */ 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 } }); + const updated = await this.userModel.updateOne({ _id: userId }, { $set: { employer: employer._id } }); + if (updated) { + const populatedResult = await this.findPopulatedUserRegistryById(updated._id); + this.userRegistrySearchService.update(populatedResult); + } + return updated; } /** @@ -731,6 +758,8 @@ export class UsersService { if (!result) { throw new HttpException('User not found', HttpStatus.BAD_REQUEST); } + const populatedResult = await this.findPopulatedUserRegistryById(userId); + this.userRegistrySearchService.update(populatedResult); return result; } @@ -745,6 +774,8 @@ export class UsersService { if (!result) { throw new HttpException('User not found', HttpStatus.BAD_REQUEST); } + const populatedResult = await this.findPopulatedUserRegistryById(userId); + this.userRegistrySearchService.update(populatedResult); return result; } } diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 6d25a2461aded667353ba4bf72b4866e9a5881af..d1e46c0c00ae4f03723f51bb17ef2e25f8d663ce 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -15,6 +15,9 @@ import { Job, JobSchema } from './schemas/job.schema'; import { Employer, EmployerSchema } from './schemas/employer.schema'; import { EmployerSearchService } from './services/employer-search.service'; import { SearchModule } from '../search/search.module'; +import { UsersRegistryController } from './controllers/userRegistry.controller'; +import { UserRegistryService } from './services/userRegistry.service'; +import { UserRegistrySearchService } from './services/userRegistry-search.service'; @Module({ imports: [ @@ -29,8 +32,15 @@ import { SearchModule } from '../search/search.module'; TempUserModule, SearchModule, ], - providers: [UsersService, JobsService, EmployerSearchService, EmployerService], - exports: [UsersService, JobsService, EmployerService], - controllers: [UsersController, JobsController, EmployerController], + providers: [ + UsersService, + JobsService, + EmployerSearchService, + EmployerService, + UserRegistryService, + UserRegistrySearchService, + ], + exports: [UsersService, JobsService, EmployerService, UserRegistryService, UserRegistrySearchService], + controllers: [UsersController, JobsController, EmployerController, UsersRegistryController], }) export class UsersModule {} diff --git a/test/mock/data/users.mock.data.ts b/test/mock/data/users.mock.data.ts index cbc762ac71307dd68daad1e76d8298b295c86b0c..cec7d5cd38cae2c1833ad321331199be45c5e272 100644 --- a/test/mock/data/users.mock.data.ts +++ b/test/mock/data/users.mock.data.ts @@ -74,3 +74,190 @@ export const userDetails: IUser[] = [ }, }, ] as IUser[]; + +export const mulitpleUsers: IUser[] = [ + { + _id: '627b85aea0466f0f132e1599', + surname: 'ADMIN', + name: 'Admin', + email: 'admin@admin.com', + employer: { + name: 'CAF', + validated: true, + }, + job: { + hasPersonalOffer: true, + name: 'CNFS', + validated: true, + }, + }, + { + _id: '627b85aea0466f0f132e1598', + surname: 'Guilhem', + name: 'CARRON', + email: 'admin@admin.com', + employer: { + name: 'Métropole', + validated: true, + }, + job: { + hasPersonalOffer: true, + name: 'CNFS', + validated: true, + }, + }, + { + _id: '627b85aea0466f0f132e1597', + surname: 'Jean-Paul', + name: 'DESCHAMPS', + email: 'admin@admin.com', + employer: { + name: 'CAF', + validated: true, + }, + job: { + hasPersonalOffer: true, + name: 'Conseiller', + validated: true, + }, + }, + { + _id: '627b85aea0466f0f132e1596', + surname: 'Paula', + name: 'Dubois', + email: 'admin@admin.com', + employer: { + name: 'Pimms', + validated: true, + }, + job: { + hasPersonalOffer: true, + name: 'Conseiller', + validated: true, + }, + }, + { + _id: '627b85aea0466f0f132e1595', + surname: 'Marine', + name: 'Ducal', + email: 'admin@admin.com', + employer: { + name: 'Médiateur', + validated: true, + }, + job: { + hasPersonalOffer: true, + name: 'CNFS', + validated: true, + }, + }, + { + _id: '627b85aea0466f0f132e1594', + surname: 'Bastien', + name: 'Dumont', + email: 'admin@admin.com', + employer: { + name: 'Pimms', + validated: true, + }, + job: { + hasPersonalOffer: true, + name: 'CNFS', + validated: true, + }, + }, +] as IUser[]; + +//Elastic search doesn't support _id +export const mulitpleUsersES: IUser[] = [ + { + id: '627b85aea0466f0f132e1599', + surname: 'ADMIN', + name: 'Admin', + email: 'admin@admin.com', + employer: { + name: 'CAF', + validated: true, + }, + job: { + hasPersonalOffer: true, + name: 'CNFS', + validated: true, + }, + }, + { + id: '627b85aea0466f0f132e1598', + surname: 'Guilhem', + name: 'CARRON', + email: 'admin@admin.com', + employer: { + name: 'Métropole', + validated: true, + }, + job: { + hasPersonalOffer: true, + name: 'CNFS', + validated: true, + }, + }, + { + id: '627b85aea0466f0f132e1597', + surname: 'Jean-Paul', + name: 'DESCHAMPS', + email: 'admin@admin.com', + employer: { + name: 'CAF', + validated: true, + }, + job: { + hasPersonalOffer: true, + name: 'Conseiller', + validated: true, + }, + }, + { + id: '627b85aea0466f0f132e1596', + surname: 'Paula', + name: 'Dubois', + email: 'admin@admin.com', + employer: { + name: 'Pimms', + validated: true, + }, + job: { + hasPersonalOffer: true, + name: 'Conseiller', + validated: true, + }, + }, + { + id: '627b85aea0466f0f132e1595', + surname: 'Marine', + name: 'Ducal', + email: 'admin@admin.com', + employer: { + name: 'Médiateur', + validated: true, + }, + job: { + hasPersonalOffer: true, + name: 'CNFS', + validated: true, + }, + }, + { + id: '627b85aea0466f0f132e1594', + surname: 'Bastien', + name: 'Dumont', + email: 'admin@admin.com', + employer: { + name: 'Pimms', + validated: true, + }, + job: { + hasPersonalOffer: true, + name: 'CNFS', + validated: true, + }, + }, +] as IUser[];