From 7200ac51910ab12ef9a08049ebb47f85fca8ab48 Mon Sep 17 00:00:00 2001 From: Emile Bex Date: Thu, 2 Nov 2023 18:04:41 +0100 Subject: [PATCH] wip started converting to pure sql --- src/users/users.service.ts | 202 +++++++++++++++++-- src/users/users.utils.ts | 162 ++++++++++------ src/utils/misc/searchInColumnWhereOption.ts | 5 + tests/users/users.e2e-spec.ts | 205 ++++++++++++++++++-- 4 files changed, 485 insertions(+), 89 deletions(-) diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 2ec39e20..fd4e29d0 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,9 +1,10 @@ import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/sequelize'; import { Cache } from 'cache-manager'; -import { Op, QueryTypes, WhereOptions } from 'sequelize'; +import { literal, Op, QueryTypes, WhereOptions } from 'sequelize'; import { FindOptions, Order } from 'sequelize/types/model'; import { getPublishedCVQuery } from '../cvs/cvs.utils'; +import { getFiltersObjectsFromQueryParams } from '../utils/misc'; import { BusinessLine } from 'src/common/business-lines/models'; import { Department } from 'src/common/locations/locations.types'; import { CV } from 'src/cvs/models'; @@ -25,15 +26,19 @@ import { CandidateUserRoles, CoachUserRoles, CVStatuses, + MemberConstantType, MemberFilterKey, + MemberFilters, UserRole, UserRoles, } from './users.types'; import { getCommonMembersFilterOptions, - lastCVVersionWhereOptions, + getLastCVVersionWhereOptions, + getMemberOptions, userSearchQuery, + userSearchQueryRaw, } from './users.utils'; @Injectable() @@ -70,6 +75,132 @@ export class UsersService { include: UserCandidatInclude, }); } + + async findAllLastCVVersions(): Promise< + { candidateId: string; maxVersion: number }[] + > { + return this.userModel.sequelize.query( + `SELECT "CVs"."UserId" as "candidateId", MAX("CVs"."version") as "maxVersion" + FROM "CVs" + GROUP BY "CVs"."UserId"`, + { + type: QueryTypes.SELECT, + } + ); + } + + async findAllCandidateMembers( + params: { + limit: number; + offset: number; + search: string; + order: Order; + role: typeof CandidateUserRoles; + } & FilterParams + ): Promise { + const { limit, offset, search, order, ...restParams } = params; + + const filtersObj = getFiltersObjectsFromQueryParams< + MemberFilterKey, + MemberConstantType + >(restParams, MemberFilters); + + const filterOptions = getMemberOptions(filtersObj); + + const reducedFiltersObj = Object.keys(filtersObj).reduce((acc, curr) => { + return { + ...acc, + [curr]: filtersObj[curr as MemberFilterKey].map(({ value }) => value), + }; + }, {}); + + const lastCVVersions = await this.findAllLastCVVersions(); + + return this.userModel.sequelize.query( + `SELECT "User"."id", + "User"."OrganizationId", + "User"."firstName", + "User"."lastName", + "User"."email", + "User"."phone", + "User"."address", + "User"."role", + "User"."adminRole", + "User"."zone", + "User"."gender", + "User"."lastConnection", + "candidat"."coachId" AS "candidat.coachId", + "candidat"."candidatId" AS "candidat.candidatId", + "candidat"."employed" AS "candidat.employed", + "candidat"."hidden" AS "candidat.hidden", + "candidat"."note" AS "candidat.note", + "candidat"."url" AS "candidat.url", + "candidat"."contract" AS "candidat.contract", + "candidat"."endOfContract" AS "candidat.endOfContract", + "candidat"."lastModifiedBy" AS "candidat.lastModifiedBy", + "candidat->cvs"."id" AS "candidat.cvs.id", + "candidat->cvs"."version" AS "candidat.cvs.version", + "candidat->cvs"."status" AS "candidat.cvs.status", + "candidat->cvs"."urlImg" AS "candidat.cvs.urlImg", + "candidat->cvs->businessLines"."id" AS "candidat.cvs.businessLines.id", + "candidat->cvs->businessLines"."name" AS "candidat.cvs.businessLines.name", + "candidat->cvs->businessLines"."order" AS "candidat.cvs.businessLines.order", + "candidat->coach"."id" AS "candidat.coach.id", + "candidat->coach"."OrganizationId" AS "candidat.coach.OrganizationId", + "candidat->coach"."firstName" AS "candidat.coach.firstName", + "candidat->coach"."lastName" AS "candidat.coach.lastName", + "candidat->coach"."email" AS "candidat.coach.email", + "candidat->coach"."phone" AS "candidat.coach.phone", + "candidat->coach"."address" AS "candidat.coach.address", + "candidat->coach"."role" AS "candidat.coach.role", + "candidat->coach"."adminRole" AS "candidat.coach.adminRole", + "candidat->coach"."zone" AS "candidat.coach.zone", + "candidat->coach"."gender" AS "candidat.coach.gender", + "candidat->coach"."lastConnection" AS "candidat.coach.lastConnection", + "candidat->coach->organization"."name" AS "candidat.coach.organization.name", + "candidat->coach->organization"."address" AS "candidat.coach.organization.address", + "candidat->coach->organization"."zone" AS "candidat.coach.organization.zone", + "candidat->coach->organization"."id" AS "candidat.coach.organization.id", + "organization"."name" AS "organization.name", + "organization"."address" AS "organization.address", + "organization"."zone" AS "organization.zone", + "organization"."id" AS "organization.id" + FROM "Users" AS "User" + LEFT OUTER JOIN "User_Candidats" AS "candidat" ON "User"."id" = "candidat"."candidatId" + LEFT OUTER JOIN "CVs" AS "candidat->cvs" + ON "candidat"."candidatId" = "candidat->cvs"."UserId" AND + ("candidat->cvs"."deletedAt" IS NULL ${getLastCVVersionWhereOptions( + lastCVVersions + )}) + LEFT OUTER JOIN "CV_BusinessLines" AS "candidat->cvs->businessLines->CVBusinessLine" + ON "candidat->cvs"."id" = "candidat->cvs->businessLines->CVBusinessLine"."CVId" + LEFT OUTER JOIN "BusinessLines" AS "candidat->cvs->businessLines" + ON "candidat->cvs->businessLines"."id" = + "candidat->cvs->businessLines->CVBusinessLine"."BusinessLineId" + LEFT OUTER JOIN "Users" AS "candidat->coach" + ON "candidat"."coachId" = "candidat->coach"."id" AND + ("candidat->coach"."deletedAt" IS NULL) + LEFT OUTER JOIN "Organizations" AS "candidat->coach->organization" + ON "candidat->coach"."OrganizationId" = "candidat->coach->organization"."id" + LEFT OUTER JOIN "Organizations" AS "organization" ON "User"."OrganizationId" = "organization"."id" + WHERE "User"."deletedAt" IS NULL + AND ${filterOptions.join(' AND ')} ${ + search ? `AND ${userSearchQueryRaw(search, true)}` : '' + } + ORDER BY "User"."firstName" ASC + LIMIT ${limit} + OFFSET ${offset} + `, + { + type: QueryTypes.SELECT, + logging: console.log, + replacements: reducedFiltersObj, + nest: true, + } + ); + } + + /* async findAllCandidateMembers( params: { limit: number; @@ -83,14 +214,18 @@ export class UsersService { let userCandidatWhereOptions: FindOptions = {}; - if (filterOptions.associatedUser) { - userCandidatWhereOptions = { - where: { - ...userCandidatWhereOptions.where, - ...filterOptions.associatedUser.candidat, - }, - }; - } + const associatedUserWhereOptions = filterOptions.associatedUser + ? filterOptions.associatedUser.candidat + : {}; + + /!* if (filterOptions.associatedUser) { + userCandidatWhereOptions = { + where: { + ...userCandidatWhereOptions.where, + ...filterOptions.associatedUser.candidat, + }, + }; + }*!/ if (filterOptions.hidden || filterOptions.employed) { if (filterOptions.hidden) { @@ -111,8 +246,19 @@ export class UsersService { } } + const lastCVVersions = await this.findAllLastCVVersions(); + + const cvWhereOptions = { + ...getLastCVVersionWhereOptions(lastCVVersions), + ...(filterOptions.cvStatus ? { status: filterOptions.cvStatus } : {}), + }; + return this.userModel.findAll({ ...options, + where: { + ...options.where, + ...associatedUserWhereOptions, + }, include: [ { model: UserCandidat, @@ -126,12 +272,11 @@ export class UsersService { attributes: ['version', 'status', 'urlImg'], required: !!filterOptions.cvStatus || !!filterOptions.businessLines, - where: { - ...lastCVVersionWhereOptions, - ...(filterOptions.cvStatus - ? { status: filterOptions.cvStatus } - : {}), - }, + ...(!_.isEmpty(cvWhereOptions) + ? { + where: cvWhereOptions, + } + : {}), include: [ { model: BusinessLine, @@ -168,6 +313,7 @@ export class UsersService { ], }); } +*/ async findAllCoachMembers( params: { @@ -184,18 +330,30 @@ export class UsersService { ? filterOptions.associatedUser.coach : {}; + /* let userCandidatWhereOptions: FindOptions = {}; + + if (filterOptions.associatedUser) { + userCandidatWhereOptions = { + where: { + ...userCandidatWhereOptions.where, + ...filterOptions.associatedUser.coach, + }, + }; + } +*/ return this.userModel.findAll({ ...options, - where: { + /*where: { ...options.where, - ...associatedUserWhereOptions, - }, + /!* ...associatedUserWhereOptions,*!/ + [Op.and]: [literal(`coaches.candidatId is null`)], + },*/ + where: literal(`"coaches"."candidatId" is null`), include: [ { model: UserCandidat, as: 'coaches', attributes: ['coachId', 'candidatId', ...UserCandidatAttributes], - duplicating: false, include: [ { model: User, @@ -319,6 +477,8 @@ export class UsersService { ? ({ zone } as WhereOptions) : {}; + const lastCVVersions = await this.findAllLastCVVersions(); + const options: FindOptions = { where: { ...whereOptions, @@ -337,7 +497,7 @@ export class UsersService { as: 'cvs', attributes: [], where: { - ...lastCVVersionWhereOptions, + /* ...getLastCVVersionWhereOptions(lastCVVersions),*/ status: CVStatuses.PENDING.value, }, }, diff --git a/src/users/users.utils.ts b/src/users/users.utils.ts index 685d7a1b..248cfeb8 100644 --- a/src/users/users.utils.ts +++ b/src/users/users.utils.ts @@ -1,15 +1,16 @@ import { BadRequestException } from '@nestjs/common'; import _ from 'lodash'; -import { col, FindOptions, literal, Op, where, WhereOptions } from 'sequelize'; +import { FindOptions } from 'sequelize'; import { Order } from 'sequelize/types/model'; import { PayloadUser } from 'src/auth/auth.types'; import { BusinessLineValue } from 'src/common/business-lines/business-lines.types'; import { getFiltersObjectsFromQueryParams, searchInColumnWhereOption, + searchInColumnWhereOptionRaw, } from 'src/utils/misc'; import { FilterObject, FilterParams } from 'src/utils/types'; -import { User, UserAttributes, UserCandidat } from './models'; +import { User, UserAttributes } from './models'; import { CandidateUserRoles, CoachUserRoles, @@ -119,10 +120,21 @@ export function getCandidateIdFromCoachOrCandidate(member: User | PayloadUser) { return null; } -export function getMemberOptions( - filtersObj: FilterObject -): MemberOptions { - let whereOptions = {} as MemberOptions; +export const formatAssociatedUserMemberOptions = ( + key: string, + filterValues: FilterObject['associatedUser'] +) => { + return `( + ${filterValues + .map((currentFilterValue) => { + return `${key} ${currentFilterValue.value ? 'IS NOT NULL' : 'IS NULL'}`; + }) + .join(' OR ')} + )`; +}; + +export function getMemberOptions(filtersObj: FilterObject) { + let whereOptions: string[] = []; if (filtersObj) { const keys: MemberFilterKey[] = Object.keys( @@ -137,39 +149,59 @@ export function getMemberOptions( if (totalFilters > 0) { for (let i = 0; i < keys.length; i += 1) { if (filtersObj[keys[i]].length > 0) { - if (keys[i] === 'associatedUser') { - whereOptions = { - ...whereOptions, - [keys[i]]: { - coach: { - [Op.or]: filtersObj[keys[i]].map((currentFilter) => { - return { - '$coaches.candidatId$': { - [currentFilter.value ? Op.not : Op.is]: null, - }, - }; - }), - }, - candidat: { - [Op.or]: filtersObj[keys[i]].map((currentFilter) => { - return where( - col(`candidat.coachId`), - currentFilter.value ? Op.not : Op.is, - null - ); - }), - }, - }, - }; - } else { - whereOptions = { - ...whereOptions, - [keys[i]]: { - [Op.or]: filtersObj[keys[i]].map((currentFilter) => { - return currentFilter.value; - }), - }, - }; + switch (keys[i]) { + case 'role': + whereOptions = [ + ...whereOptions, + `"User"."role" IN (:${keys[i]})`, + //formatMemberOptions('"User".role', filtersObj[keys[i]]), + ]; + break; + case 'zone': + whereOptions = [ + ...whereOptions, + `"User"."zone" IN (:${keys[i]})`, + // formatMemberOptions('"User".zone', filtersObj[keys[i]]), + ]; + break; + case 'businessLines': + whereOptions = [ + ...whereOptions, + `"candidat->cvs->businessLines"."name" IN (:${keys[i]})`, + // formatMemberOptions('"candidat->cvs->bussinessLines"."name"', filtersObj[keys[i]]), + ]; + break; + case 'hidden': + whereOptions = [ + ...whereOptions, + `"candidat"."hidden" IN (:${keys[i]})`, + // formatMemberOptions('"candidat".hidden', filtersObj[keys[i]]), + ]; + break; + case 'employed': + whereOptions = [ + ...whereOptions, + `"candidat"."employed" IN (:${keys[i]})`, + // formatMemberOptions('"candidat".employed',filtersObj[keys[i]]), + ]; + break; + case 'cvStatus': + whereOptions = [ + ...whereOptions, + `"candidat->cvs"."status" IN (:${keys[i]})`, + // formatMemberOptions('"candidat->cvs"."status"',filtersObj[keys[i]]), + ]; + break; + case 'associatedUser': + whereOptions = [ + ...whereOptions, + `${formatAssociatedUserMemberOptions( + '"candidat"."coachId"', + filtersObj[keys[i]] + )}`, + // formatMemberOptions('"candidat"."coachId"',filtersObj[keys[i]]), + ]; + break; } } } @@ -260,18 +292,39 @@ export function userSearchQuery(query = '', withOrganizationName = false) { ]; } -export const lastCVVersionWhereOptions: WhereOptions = { - version: { - [Op.in]: [ - literal(` - SELECT MAX("CVs"."version") - FROM "CVs" - WHERE "User".id = "CVs"."UserId" - GROUP BY "CVs"."UserId" - `), - ], - }, -}; +export function userSearchQueryRaw(query = '', withOrganizationName = false) { + const organizationSearchOption = withOrganizationName + ? [searchInColumnWhereOptionRaw('"organization"."name"', query)] + : []; + + return [ + searchInColumnWhereOptionRaw('"User"."email"', query), + searchInColumnWhereOptionRaw('"User"."firstName"', query), + searchInColumnWhereOptionRaw('"User"."lastName"', query), + ...organizationSearchOption, + ].join(' OR '); +} + +export function getLastCVVersionWhereOptions( + maxVersions: { + candidateId: string; + maxVersion: number; + }[] +) { + if (maxVersions && maxVersions.length > 0) { + return ` + AND ("UserId","version") IN ( + ${maxVersions + .map(({ candidateId, maxVersion }) => { + return `('${candidateId}','${maxVersion}')`; + }) + .join(',')} + ) + `; + } + + return ''; +} export function generateImageNamesToDelete(prefix: string) { const imageNames = Object.keys(CVStatuses).map((status) => { @@ -342,13 +395,14 @@ export function getCommonMembersFilterOptions( order, offset, limit, - where: { + logging: console.log, + /* where: { role: filterOptions.role, ...(search ? { [Op.or]: userSearchQuery(search, true) } : {}), ...(filterOptions.zone ? { zone: filterOptions.zone } : {}), - }, + },*/ attributes: [...UserAttributes], }, - filterOptions, + filterOptions: {} as MemberOptions, }; } diff --git a/src/utils/misc/searchInColumnWhereOption.ts b/src/utils/misc/searchInColumnWhereOption.ts index ea156d78..f218287b 100644 --- a/src/utils/misc/searchInColumnWhereOption.ts +++ b/src/utils/misc/searchInColumnWhereOption.ts @@ -26,3 +26,8 @@ export function searchInColumnWhereOption(column: string, query: string) { [Op.like]: `%${escapedQuery}%`, }); } + +export function searchInColumnWhereOptionRaw(column: string, query: string) { + const escapedQuery = escapeQuery(query); + return `${escapeColumnRaw(column)} like '%${escapedQuery}%'`; +} diff --git a/tests/users/users.e2e-spec.ts b/tests/users/users.e2e-spec.ts index 11198cc8..a2377b77 100644 --- a/tests/users/users.e2e-spec.ts +++ b/tests/users/users.e2e-spec.ts @@ -2257,6 +2257,79 @@ describe('Users', () => { expect(response.body[1].firstName).toMatch('D'); }); }); + describe('/members?query= - Read all members with search query', () => { + let loggedInAdmin: LoggedUser; + + beforeEach(async () => { + loggedInAdmin = await usersHelper.createLoggedInUser({ + role: UserRoles.ADMIN, + }); + }); + + it('Should return 200 and candidates matching search query', async () => { + const candidate1 = await userFactory.create({ + role: UserRoles.CANDIDATE, + firstName: 'XXX', + }); + await userFactory.create({ + role: UserRoles.CANDIDATE, + firstName: 'YYYY', + }); + const candidate2 = await userFactory.create({ + role: UserRoles.CANDIDATE, + firstName: 'XXX', + }); + await userFactory.create({ + role: UserRoles.CANDIDATE, + firstName: 'YYY', + }); + + const expectedCandidates = [candidate1, candidate2]; + + const response: APIResponse = + await request(app.getHttpServer()) + .get( + `${route}/members?limit=50&offset=0&role[]=${UserRoles.CANDIDATE}&query=XXX` + ) + .set('authorization', `Token ${loggedInAdmin.token}`); + expect(response.status).toBe(200); + expect(response.body.length).toBe(2); + expect(expectedCandidates.map(({ id }) => id)).toEqual( + expect.arrayContaining(response.body.map(({ id }) => id)) + ); + }); + it('Should return 200 and coaches matching search query', async () => { + const coaches1 = await userFactory.create({ + role: UserRoles.COACH, + firstName: 'XXX', + }); + await userFactory.create({ + role: UserRoles.COACH, + firstName: 'YYY', + }); + const coaches2 = await userFactory.create({ + role: UserRoles.COACH, + firstName: 'XXX', + }); + await userFactory.create({ + role: UserRoles.COACH, + firstName: 'YYY', + }); + const expectedCoaches = [coaches1, coaches2]; + + const response: APIResponse = + await request(app.getHttpServer()) + .get( + `${route}/members?limit=50&offset=0&role[]=${UserRoles.COACH}&query=XXX` + ) + .set('authorization', `Token ${loggedInAdmin.token}`); + expect(response.status).toBe(200); + expect(response.body.length).toBe(3); + expect(expectedCoaches.map(({ id }) => id)).toEqual( + expect.arrayContaining(response.body.map(({ id }) => id)) + ); + }); + }); describe('/members?zone[]=&employed[]=&hidden[]=&businessLines[]=&associatedUser[]=&cvStatus[]= - Read all members as admin with filters', () => { let loggedInAdmin: LoggedUser; beforeEach(async () => { @@ -2514,26 +2587,22 @@ describe('Users', () => { ...pendingLatestCVs.map((cv) => expect.objectContaining({ candidat: expect.objectContaining({ - cvs: [ - expect.objectContaining({ - status: cv.status, - urlImg: cv.urlImg, - version: cv.version, - }), - ], + cvs: expect.objectContaining({ + status: cv.status, + urlImg: cv.urlImg, + version: cv.version, + }), }), }) ), ...publishedLatestCVs.map((cv) => expect.objectContaining({ candidat: expect.objectContaining({ - cvs: [ - expect.objectContaining({ - status: cv.status, - urlImg: cv.urlImg, - version: cv.version, - }), - ], + cvs: expect.objectContaining({ + status: cv.status, + urlImg: cv.urlImg, + version: cv.version, + }), }), }) ), @@ -2774,6 +2843,114 @@ describe('Users', () => { ); }); }); + describe('/members - Read all members as admin with all filters', () => { + let loggedInAdmin: LoggedUser; + beforeEach(async () => { + loggedInAdmin = await usersHelper.createLoggedInUser({ + role: UserRoles.ADMIN, + }); + }); + it('Should return 200, and all the candidates that match all the filters', async () => { + const organization = await organizationFactory.create({}, {}, true); + + const lyonAssociatedExternalCoaches = + await databaseHelper.createEntities(userFactory, 2, { + firstName: 'XXX', + role: UserRoles.COACH_EXTERNAL, + zone: AdminZones.LYON, + OrganizationId: organization.id, + }); + + const lyonAssociatedExternalCandidates = + await databaseHelper.createEntities(userFactory, 2, { + firstName: 'XXX', + role: UserRoles.CANDIDATE, + zone: AdminZones.LYON, + }); + + await Promise.all( + lyonAssociatedExternalCandidates.map(async ({ id }) => { + return cvFactory.create( + { + UserId: id, + version: 4, + status: CVStatuses.PUBLISHED.value, + }, + { businessLines: ['rh', 'aa'] } + ); + }) + ); + + await Promise.all( + lyonAssociatedExternalCandidates.map(async (candidate, index) => { + return userCandidatsHelper.associateCoachAndCandidate( + lyonAssociatedExternalCoaches[index], + candidate + ); + }) + ); + + const expectedCandidatesIds = [ + ...lyonAssociatedExternalCandidates.map(({ id }) => id), + ]; + + const response: APIResponse = + await request(app.getHttpServer()) + .get( + `${route}/members?limit=50&offset=0&role[]=${UserRoles.CANDIDATE}&role[]=${UserRoles.CANDIDATE_EXTERNAL}&hidden[]=false&employed[]=false&query=XXX&zone[]=${AdminZones.LYON}&cvStatus[]=${CVStatuses.PUBLISHED.value}&businessLines[]=rh&associatedUser[]=true` + ) + .set('authorization', `Token ${loggedInAdmin.token}`); + expect(response.status).toBe(200); + expect(response.body.length).toBe(2); + expect(expectedCandidatesIds).toEqual( + expect.arrayContaining(response.body.map(({ id }) => id)) + ); + }); + it('Should return 200, and all the coaches that match all the filters', async () => { + const organization = await organizationFactory.create({}, {}, true); + + const lyonAssociatedExternalCoaches = + await databaseHelper.createEntities(userFactory, 2, { + firstName: 'XXX', + role: UserRoles.COACH_EXTERNAL, + zone: AdminZones.LYON, + OrganizationId: organization.id, + }); + + const associatedExternalCandidates = + await databaseHelper.createEntities(userFactory, 2, { + firstName: 'XXX', + role: UserRoles.CANDIDATE, + zone: AdminZones.LYON, + OrganizationId: organization.id, + }); + + await Promise.all( + associatedExternalCandidates.map(async (candidate, index) => { + return userCandidatsHelper.associateCoachAndCandidate( + lyonAssociatedExternalCoaches[index], + candidate + ); + }) + ); + + const expectedCoachesIds = [ + ...lyonAssociatedExternalCoaches.map(({ id }) => id), + ]; + + const response: APIResponse = + await request(app.getHttpServer()) + .get( + `${route}/members?limit=50&offset=0&role[]=${UserRoles.COACH}&role[]=${UserRoles.COACH_EXTERNAL}&query=XXX&zone[]=${AdminZones.LYON}&associatedUser[]=true` + ) + .set('authorization', `Token ${loggedInAdmin.token}`); + expect(response.status).toBe(200); + expect(response.body.length).toBe(2); + expect(expectedCoachesIds).toEqual( + expect.arrayContaining(response.body.map(({ id }) => id)) + ); + }); + }); }); describe('/members/count - Count all pending members', () => {