From aa5df227279fe6d226aa5c5998dc79aff30ee0d1 Mon Sep 17 00:00:00 2001 From: Davide Bizzi Date: Mon, 26 Feb 2024 15:25:41 +0100 Subject: [PATCH 1/6] rework: Refactor candidates --- .../candidates/_get/CandidateDevices.ts | 152 +++++++ .../candidates/_get/CandidateLevels.ts | 59 +++ .../candidates/_get/CandidateQuestions.ts | 105 +++++ .../campaignId/candidates/_get/Candidates.ts | 29 ++ .../campaignId/candidates/_get/Selector.ts | 416 ------------------ .../candidates/_get/iCandidateData.ts | 8 + .../campaignId/candidates/_get/index.spec.ts | 111 ++--- .../campaignId/candidates/_get/index.ts | 278 +++++------- 8 files changed, 519 insertions(+), 639 deletions(-) create mode 100644 src/routes/campaigns/campaignId/candidates/_get/CandidateDevices.ts create mode 100644 src/routes/campaigns/campaignId/candidates/_get/CandidateLevels.ts create mode 100644 src/routes/campaigns/campaignId/candidates/_get/CandidateQuestions.ts create mode 100644 src/routes/campaigns/campaignId/candidates/_get/Candidates.ts delete mode 100644 src/routes/campaigns/campaignId/candidates/_get/Selector.ts create mode 100644 src/routes/campaigns/campaignId/candidates/_get/iCandidateData.ts diff --git a/src/routes/campaigns/campaignId/candidates/_get/CandidateDevices.ts b/src/routes/campaigns/campaignId/candidates/_get/CandidateDevices.ts new file mode 100644 index 000000000..87880c1af --- /dev/null +++ b/src/routes/campaigns/campaignId/candidates/_get/CandidateDevices.ts @@ -0,0 +1,152 @@ +import { tryber } from "@src/features/database"; +import { CandidateData } from "./iCandidateData"; + +class CandidateDevices implements CandidateData { + private candidateIds: number[]; + private filters?: { os?: string[] }; + + private _devices: + | { + id: number; + id_profile: number; + form_factor: string; + manufacturer: string; + model: string; + os: string; + os_version: string; + }[] + | undefined; + + private _candidateDevices: + | { + id: number; + devices: string; + }[] + | undefined; + + constructor({ + candidateIds, + filters, + }: { + candidateIds: number[]; + filters?: { os?: string[] }; + }) { + this.candidateIds = candidateIds; + this.filters = filters; + } + + get devices() { + if (!this._devices) throw new Error("Devices not initialized"); + return this._devices; + } + get candidateDevices() { + if (!this._candidateDevices) throw new Error("Devices not initialized"); + return this._candidateDevices; + } + + async init() { + const query = tryber.tables.WpCrowdAppqDevice.do() + .select( + tryber.ref("id").withSchema("wp_crowd_appq_device"), + "manufacturer", + "pc_type", + "model", + "id_profile", + tryber.ref("form_factor").withSchema("wp_crowd_appq_device"), + tryber.ref("display_name").withSchema("wp_appq_os").as("os_version"), + tryber.ref("wp_appq_evd_platform.name").as("os") + ) + .join("wp_appq_os", "wp_appq_os.id", "wp_crowd_appq_device.os_version_id") + .join( + "wp_appq_evd_platform", + "wp_crowd_appq_device.platform_id", + "wp_appq_evd_platform.id" + ) + .whereIn("id_profile", this.candidateIds) + .where("enabled", 1); + + if (this.filters?.os) { + const operativeSystems = this.filters.os; + query.where((query) => { + for (const os of operativeSystems) { + query.orWhereLike("wp_appq_evd_platform.name", `%${os}%`); + } + }); + } + + this._devices = await query; + this._candidateDevices = await tryber.tables.WpCrowdAppqHasCandidate.do() + .join( + "wp_appq_evd_profile", + "wp_crowd_appq_has_candidate.user_id", + "wp_appq_evd_profile.wp_user_id" + ) + .select( + tryber.ref("id").withSchema("wp_appq_evd_profile").as("id"), + "devices" + ) + .whereIn("wp_appq_evd_profile.id", this.candidateIds); + + return; + } + + getCandidateData(candidate: { id: number }) { + const candidateDevices = this.devices.filter( + (device) => device.id_profile === candidate.id + ); + + const deviceData = this.getDeviceData(candidate); + + if (deviceData === "none") return []; + + const results = + deviceData === "all" + ? candidateDevices + : candidateDevices.filter((device) => + deviceData.includes(device.id.toString()) + ); + + return results.map((device) => ({ + id: device.id, + ...(device.form_factor === "PC" + ? {} + : { + manufacturer: device.manufacturer, + model: device.model, + }), + os: device.os, + osVersion: device.os_version, + })); + } + + private getDeviceData(candidate: { id: number }) { + const candidateDevicesIds = this.candidateDevices.find( + (cand) => cand.id === candidate.id + ); + + if (!candidateDevicesIds) return "none"; + + if (candidateDevicesIds.devices === "0") return "all"; + + return candidateDevicesIds.devices.split(","); + } + + isCandidateFiltered(candidate: { id: number }) { + const devices = this.getCandidateData(candidate).filter((device) => { + if (this.filters?.os) { + return this.filters.os.reduce((acc, os) => { + if (device.os.toLowerCase().includes(os.toLowerCase())) return true; + return acc; + }, false); + } + + return true; + }); + + if (devices.length === 0) return false; + + return true; + } +} + +export { CandidateDevices }; diff --git a/src/routes/campaigns/campaignId/candidates/_get/CandidateLevels.ts b/src/routes/campaigns/campaignId/candidates/_get/CandidateLevels.ts new file mode 100644 index 000000000..c7d2ab8c7 --- /dev/null +++ b/src/routes/campaigns/campaignId/candidates/_get/CandidateLevels.ts @@ -0,0 +1,59 @@ +import { tryber } from "@src/features/database"; +import { CandidateData } from "./iCandidateData"; + +class CandidateLevels implements CandidateData { + private candidateIds: number[]; + private levelDefinitions: + | { + id: number; + name: string; + }[] = []; + + private _candidateLevels: + | { + id: number; + level_id: number; + }[] + | undefined; + + constructor({ candidateIds }: { candidateIds: number[] }) { + this.candidateIds = candidateIds; + } + + get candidateLevels() { + if (!this._candidateLevels) throw new Error("Levels not initialized"); + return this._candidateLevels; + } + + async init() { + this.levelDefinitions = + await tryber.tables.WpAppqActivityLevelDefinition.do().select( + "id", + "name" + ); + this._candidateLevels = await tryber.tables.WpAppqActivityLevel.do() + .select( + tryber.ref("tester_id").withSchema("wp_appq_activity_level").as("id"), + "level_id" + ) + .whereIn("tester_id", this.candidateIds); + return; + } + + getCandidateData(candidate: { id: number }) { + const level = this.levelDefinitions.find((level) => { + const level_id = this.candidateLevels.find( + (level) => level.id === candidate.id + ); + if (!level_id) return false; + return level.id === level_id.level_id; + }); + return level ? level.name : "No level"; + } + + isCandidateFiltered(candidate: { id: number }): boolean { + return true; + } +} + +export { CandidateLevels }; diff --git a/src/routes/campaigns/campaignId/candidates/_get/CandidateQuestions.ts b/src/routes/campaigns/campaignId/candidates/_get/CandidateQuestions.ts new file mode 100644 index 000000000..f43912383 --- /dev/null +++ b/src/routes/campaigns/campaignId/candidates/_get/CandidateQuestions.ts @@ -0,0 +1,105 @@ +import { tryber } from "@src/features/database"; +import { CandidateData } from "./iCandidateData"; + +class CandidateQuestions implements CandidateData { + private candidateIds: number[]; + private questionIds: number[]; + + private _questions: { + id: number; + question: string; + short_name: string; + }[] = []; + + private _candidateQuestions: + | { + id: number; + tester_id: number; + question: string; + short_name: string; + value: string; + }[] + | undefined = []; + + constructor({ + candidateIds, + questionIds, + }: { + candidateIds: number[]; + questionIds: number[]; + }) { + this.candidateIds = candidateIds; + this.questionIds = questionIds; + } + + get candidateQuestions() { + if (!this._candidateQuestions) + throw new Error("Candidate questions not initialized"); + return this._candidateQuestions; + } + + get questions() { + return this._questions; + } + + async init() { + if (this.questionIds.length === 0) return; + + this._candidateQuestions = + await tryber.tables.WpAppqCampaignPreselectionFormFields.do() + .join( + "wp_appq_campaign_preselection_form_data", + "wp_appq_campaign_preselection_form_fields.id", + "wp_appq_campaign_preselection_form_data.field_id" + ) + .select( + tryber + .ref("id") + .withSchema("wp_appq_campaign_preselection_form_fields"), + "tester_id", + "question", + "short_name", + "value" + ) + .whereIn("tester_id", this.candidateIds) + .whereIn( + "wp_appq_campaign_preselection_form_fields.id", + this.questionIds + ); + + this._questions = + await tryber.tables.WpAppqCampaignPreselectionFormFields.do() + .select( + tryber + .ref("id") + .withSchema("wp_appq_campaign_preselection_form_fields"), + "question", + "short_name" + ) + .whereIn("id", this.questionIds); + return; + } + + getCandidateData(candidate: { id: number }) { + return this.questions.map((question) => { + const candidateQuestion = this.candidateQuestions.filter( + (candidateQuestion) => + candidateQuestion.tester_id === candidate.id && + candidateQuestion.id === question.id + ); + return { + id: question.id, + title: question.short_name ? question.short_name : question.question, + value: candidateQuestion.length + ? candidateQuestion.map((q) => q.value).join(", ") + : "-", + }; + }); + } + + isCandidateFiltered(candidate: { id: number }): boolean { + return true; + } +} + +export { CandidateQuestions }; diff --git a/src/routes/campaigns/campaignId/candidates/_get/Candidates.ts b/src/routes/campaigns/campaignId/candidates/_get/Candidates.ts new file mode 100644 index 000000000..01ef38580 --- /dev/null +++ b/src/routes/campaigns/campaignId/candidates/_get/Candidates.ts @@ -0,0 +1,29 @@ +import { tryber } from "@src/features/database"; + +class Candidates { + private campaign_id: number; + constructor({ campaign_id }: { campaign_id: number }) { + this.campaign_id = campaign_id; + } + + async get() { + return await tryber.tables.WpCrowdAppqHasCandidate.do() + .join( + "wp_appq_evd_profile", + "wp_crowd_appq_has_candidate.user_id", + "wp_appq_evd_profile.wp_user_id" + ) + .select( + tryber.ref("id").withSchema("wp_appq_evd_profile"), + "name", + "surname", + "total_exp_pts" + ) + .where("campaign_id", this.campaign_id) + .where("accepted", 0) + .where("name", "<>", "Deleted User") + .orderBy("wp_appq_evd_profile.id", "desc"); + } +} + +export { Candidates }; diff --git a/src/routes/campaigns/campaignId/candidates/_get/Selector.ts b/src/routes/campaigns/campaignId/candidates/_get/Selector.ts deleted file mode 100644 index d84c756e9..000000000 --- a/src/routes/campaigns/campaignId/candidates/_get/Selector.ts +++ /dev/null @@ -1,416 +0,0 @@ -import CampaignApplications, { - CampaignApplicationObject, -} from "@src/features/db/class/CampaignApplications"; -import Level from "@src/features/db/class/Level"; -import Os from "@src/features/db/class/Os"; -import OsVersion from "@src/features/db/class/OsVersion"; -import Profile, { ProfileObject } from "@src/features/db/class/Profile"; -import Devices, { - TesterDeviceObject, -} from "@src/features/db/class/TesterDevices"; -import UserLevel from "@src/features/db/class/UserLevel"; -import PreselectionFormData from "@src/features/db/class/PreselectionFormData"; -import PreselectionForm from "@src/features/db/class/PreselectionForms"; -import PreselectionFormFields, { - PreselectionFormFieldsObject, -} from "@src/features/db/class/PreselectionFormFields"; - -class InvalidQuestionError extends Error {} - -type Field = { type: "question"; id: number }; -class Selector { - private applications: CampaignApplicationObject[] | false = false; - private applicationUsers: { [key: number]: ProfileObject } = {}; - private testerDevices: { [key: number]: TesterDeviceObject[] } = {}; - private formFields: { [key: number]: PreselectionFormFieldsObject } = {}; - private userLevels: { [key: number]: { id: number; name: string } } = {}; - private userQuestions: { - [key: number]: { id: number; title: string; value: string }[]; - } = {}; - private initialized: boolean = false; - private readonly fields: Field[]; - - constructor(private readonly campaign: number, fields?: Field[]) { - this.fields = fields || []; - } - - public async init() { - if (this.initialized) return; - - const applications = new CampaignApplications(); - const applicationItems = await applications.query({ - where: [{ campaign_id: this.campaign }, { accepted: 0 }], - }); - this.applicationUsers = await this.initApplicationUsers(applicationItems); - this.applications = this.filterInvalidProfiles(applicationItems); - await this.initUserDevices(); - this.applications = this.filterInvalidDevices(this.applications); - await this.initUserLevels(); - await this.initUserQuestions(); - - this.initialized = true; - return this.applications; - } - - private async initUserQuestions() { - const questionFields = this.fields.filter( - (field) => field.type === "question" - ); - - if (questionFields.length === 0) return; - - const formFieldsItems = await this.getPreselectionFormFields( - questionFields - ); - for (const item of formFieldsItems) { - if (item.id) { - this.formFields[item.id] = item; - } - } - const formData = new PreselectionFormData(); - const formDataItems = await formData.query({ - where: [ - { - field_id: questionFields.map((f) => f.id), - tester_id: this.getSelectedTesterIds(), - }, - ], - }); - - for (const item of formDataItems) { - if (!this.userQuestions[item.tester_id]) { - this.userQuestions[item.tester_id] = []; - } - const formField = formFieldsItems.find((f) => f.id === item.field_id); - if (formField) { - this.userQuestions[item.tester_id].push({ - id: item.field_id, - title: formField.short_name - ? formField.short_name - : formField.question, - value: item.value, - }); - } - } - - for (const testerId of this.getSelectedTesterIds()) { - if (testerId) { - if (!this.userQuestions[testerId]) { - this.userQuestions[testerId] = []; - } - const missingData = Object.values(this.formFields).filter( - (f) => - this.userQuestions[testerId].find((q) => q.id === f.id) === - undefined - ); - for (const missing of missingData) { - this.userQuestions[testerId].push({ - id: missing.id, - title: missing.short_name ? missing.short_name : missing.question, - value: "-", - }); - } - } - } - } - - private getSelectedTesterIds() { - return Object.values(this.applicationUsers).map((p) => p.id); - } - - private async getPreselectionFormFields(questionFields: Field[]) { - const formId = await this.getPreselectionFormId(); - const formFields = new PreselectionFormFields(); - const formFieldsItems = await formFields.query({ - where: [{ id: questionFields.map((f) => f.id) }], - }); - if (formFieldsItems.some((f) => f.form_id !== formId)) { - throw new InvalidQuestionError(); - } - return formFieldsItems; - } - - private async getPreselectionFormId() { - const form = new PreselectionForm(); - const formItems = await form.query({ - where: [{ campaign_id: this.campaign }], - limit: 1, - }); - if (!formItems.length) throw new InvalidQuestionError(); - return formItems[0].id; - } - - private async initApplicationUsers( - applications: CampaignApplicationObject[] - ) { - const profile = new Profile(); - const applicationWpUserIds = applications.map( - (application) => application.user_id - ); - if (applicationWpUserIds.length === 0) return {}; - const profiles = await profile.query({ - where: [{ wp_user_id: applicationWpUserIds }], - }); - const results: { [key: number]: ProfileObject } = {}; - for (const profile of profiles) { - if (profile.wp_user_id && profile.id) { - results[profile.wp_user_id] = profile; - } - } - return results; - } - - private async initUserLevels() { - const userLevel = new UserLevel(); - const profiles = this.getApplicationsProfiles(); - const levels = await this.getLevelDefinitions(); - if (Object.keys(profiles).length === 0) return {}; - const userLevels = await userLevel.query({ - where: [{ tester_id: Object.values(profiles).map((p) => p.id) }], - }); - - for (const userLevel of userLevels) { - if ( - userLevel.tester_id && - userLevel.level_id && - levels[userLevel.level_id] - ) { - this.userLevels[userLevel.tester_id] = { - id: userLevel.level_id, - name: levels[userLevel.level_id], - }; - } - } - } - - private async getLevelDefinitions() { - const level = new Level(); - const levels: { [key: number]: string } = {}; - const levelData = await level.query({}); - for (const level of levelData) { - if (level.id && level.name) { - levels[level.id] = level.name; - } - } - return levels; - } - - private filterInvalidDevices(applicationItems: CampaignApplicationObject[]) { - return applicationItems.filter((application) => { - const profile = this.getApplicationsUser(application); - if (!profile.id) return false; - if (!this.testerDevices[profile.id]) return false; - return true; - }); - } - - private filterInvalidProfiles(applicationItems: CampaignApplicationObject[]) { - return applicationItems.filter((application) => { - if (!this.getApplicationsUser(application)) return false; - const profile = this.applicationUsers[application.user_id]; - if (profile.wp_user_id === null) return false; - if (!profile.id) return false; - if (profile.isDeletedUser()) return false; - return true; - }); - } - - private async initUserDevices() { - if (!this.applications) throw new Error("Applications not initialized"); - const where = []; - for (const application of this.applications) { - const profile = this.getApplicationsUser(application); - if (application.devices && profile) { - if (application.devices === "0") { - where.push(`(id_profile = ${profile.id}) AND enabled = 1`); - } else { - where.push( - `(enabled = 1 AND id_profile = ${ - profile.id - } AND id IN (${application.devices - .split(",") - .map((d) => parseInt(d))})) - ` - ); - } - } - } - if (where.length > 0) { - const devices = new Devices(); - const deviceItems = await devices.queryWithCustomWhere({ - where: "WHERE " + where.join(" OR "), - }); - for (const device of deviceItems) { - if (device.id_profile) { - if (this.testerDevices[device.id_profile] === undefined) { - this.testerDevices[device.id_profile] = []; - } - this.testerDevices[device.id_profile].push(device); - } - } - } - } - - public getUserLevel(testerId: number) { - const userLevelItem = this.userLevels[testerId]; - if (!userLevelItem) return { id: 0, name: "No level" }; - return userLevelItem; - } - - public getApplicationsUser(application: CampaignApplicationObject) { - return this.applicationUsers[application.user_id]; - } - - public async getApplications() { - if (this.applications === false) { - throw new Error("Applications not initialized"); - } - const applicationWithProfiles = this.addProfileTo(this.applications); - const applicationWithDevices = await this.addTesterDeviceTo( - applicationWithProfiles - ); - - const applicationWithQuestions = this.addQuestionsTo( - applicationWithDevices - ); - - return applicationWithQuestions; - } - - private addProfileTo(applications: CampaignApplicationObject[]) { - return applications.map((a) => { - const profile = this.getProfile(a); - if (!profile) { - throw new Error("Profile not found"); - } - return { - ...a, - id: profile.id, - name: profile.name, - surname: profile.surname, - experience: profile.experience, - }; - }); - } - - private async addTesterDeviceTo( - applications: ReturnType - ) { - const results = []; - for (const a of applications) { - const devices = await this.getTesterDevices(a); - if (!devices) { - throw new Error("Profile not found"); - } - results.push({ - ...a, - devices, - }); - } - return results; - } - - private addQuestionsTo( - applications: Awaited> - ): (Awaited>[number] & { - questions?: { id: number; title: string; value: string }[]; - })[] { - const results = applications.map((application) => { - if (this.userQuestions.hasOwnProperty(application.id)) { - return { - ...application, - questions: this.userQuestions[application.id], - }; - } - return application; - }); - return results.map((r) => { - let questions: (typeof this.userQuestions)[number] = []; - if (r.hasOwnProperty("questions")) { - questions = r.questions; - } - let questionById: { id: number; title: string; value: string }[] = []; - for (const field of Object.values(this.formFields)) { - const questionsForId = questions.filter((q) => q.id === field.id); - if (questionsForId.length) { - questionById.push({ - id: questionsForId[0].id, - title: questionsForId[0].title, - value: questionsForId.map((q) => q.value).join(", "), - }); - } - } - return { - ...r, - questions: questionById, - }; - }); - } - - private async getTesterDevices(application: CampaignApplicationObject) { - const profile = this.getApplicationsUser(application); - if (!profile || !profile.id) throw new Error("Profile not found"); - const os = new Os(); - const osVersions = new OsVersion(); - const testerDevices = this.getUserDevices(profile.id); - - let devices: NonNullable< - StoplightOperations["get-campaigns-campaign-candidates"]["responses"][200]["content"]["application/json"]["results"] - >[number]["devices"] = []; - for (const testerDevice of testerDevices) { - const osItem = await os.get(testerDevice.platform_id); - const osVersionItem = await osVersions.get(testerDevice.os_version_id); - devices.push({ - ...(testerDevice.form_factor === "PC" - ? {} - : { - manufacturer: testerDevice.manufacturer, - model: testerDevice.model, - }), - id: testerDevice.id, - os: osItem.name, - osVersion: osVersionItem.display_name, - }); - } - return devices; - } - - private getProfile(application: CampaignApplicationObject) { - const profile = this.getApplicationsUser(application); - - if ( - !profile || - !profile.id || - !profile.name || - !profile.surname || - typeof profile.total_exp_pts === "undefined" - ) { - return false; - } - return { - id: profile.id, - name: profile.name, - surname: profile.surname, - experience: profile.total_exp_pts, - }; - } - - public getApplicationsProfiles() { - if (this.applications === false) { - throw new Error("Applications not initialized"); - } - const results: { [key: number]: ProfileObject } = {}; - for (const application of this.applications) { - results[application.user_id] = this.applicationUsers[application.user_id]; - } - return results; - } - - public getUserDevices(profileId: number) { - return this.testerDevices[profileId]; - } -} - -export default Selector; -export type { Field }; -export { InvalidQuestionError }; diff --git a/src/routes/campaigns/campaignId/candidates/_get/iCandidateData.ts b/src/routes/campaigns/campaignId/candidates/_get/iCandidateData.ts new file mode 100644 index 000000000..f128672a9 --- /dev/null +++ b/src/routes/campaigns/campaignId/candidates/_get/iCandidateData.ts @@ -0,0 +1,8 @@ +interface CandidateData { + init(): Promise; + + getCandidateData(candidate: { id: number }): any; + isCandidateFiltered(candidate: { id: number }): boolean; +} + +export type { CandidateData }; diff --git a/src/routes/campaigns/campaignId/candidates/_get/index.spec.ts b/src/routes/campaigns/campaignId/candidates/_get/index.spec.ts index 31f89631a..939348737 100644 --- a/src/routes/campaigns/campaignId/candidates/_get/index.spec.ts +++ b/src/routes/campaigns/campaignId/candidates/_get/index.spec.ts @@ -1,17 +1,17 @@ -import request from "supertest"; -import app from "@src/app"; import Campaigns from "@src/__mocks__/mockedDb/campaign"; import Candidate from "@src/__mocks__/mockedDb/cpHasCandidates"; -import Profile from "@src/__mocks__/mockedDb/profile"; -import WpUsers from "@src/__mocks__/mockedDb/wp_users"; -import Levels from "@src/__mocks__/mockedDb/levelsDefinition"; -import UserLevels from "@src/__mocks__/mockedDb/levels"; -import TesterDevices from "@src/__mocks__/mockedDb/testerDevice"; -import DeviceOs from "@src/__mocks__/mockedDb/devicePlatform"; import DeviceOsVersion from "@src/__mocks__/mockedDb/deviceOs"; +import DeviceOs from "@src/__mocks__/mockedDb/devicePlatform"; +import UserLevels from "@src/__mocks__/mockedDb/levels"; +import Levels from "@src/__mocks__/mockedDb/levelsDefinition"; import PreselectionForm from "@src/__mocks__/mockedDb/preselectionForm"; -import PreselectionFormFields from "@src/__mocks__/mockedDb/preselectionFormFields"; import preselectionFormData from "@src/__mocks__/mockedDb/preselectionFormData"; +import PreselectionFormFields from "@src/__mocks__/mockedDb/preselectionFormFields"; +import Profile from "@src/__mocks__/mockedDb/profile"; +import TesterDevices from "@src/__mocks__/mockedDb/testerDevice"; +import WpUsers from "@src/__mocks__/mockedDb/wp_users"; +import app from "@src/app"; +import request from "supertest"; const users = { 1: { testerId: 1, wpUserId: 1, levelId: 10 }, @@ -504,18 +504,20 @@ describe("GET /campaigns/:campaignId/candidates ", () => { ); }); - it("should order by level id", async () => { - const response = await request(app) - .get("/campaigns/1/candidates/") - .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); - expect(response.body).toHaveProperty("results"); - expect(response.body.results.length).toBe(3); - expect(response.body.results.map((r: { id: number }) => r.id)).toEqual([ - users[2].testerId, - users[4].testerId, - users[3].testerId, - ]); - }); + // TODO: REMOVE + + // it("should order by level id", async () => { + // const response = await request(app) + // .get("/campaigns/1/candidates/") + // .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + // expect(response.body).toHaveProperty("results"); + // expect(response.body.results.length).toBe(3); + // expect(response.body.results.map((r: { id: number }) => r.id)).toEqual([ + // users[2].testerId, + // users[4].testerId, + // users[3].testerId, + // ]); + // }); it("should allow pagination of one element", async () => { const response = await request(app) @@ -668,23 +670,24 @@ describe("GET /campaigns/:campaignId/candidates ", () => { ); }); - it("Should filter by os excluding values", async () => { - const response = await request(app) - .get("/campaigns/1/candidates/?filterByExclude[os]=os") - .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); - expect(response.body).toHaveProperty("results"); - expect(response.body.results.length).toBe(2); - expect(response.body.results).toEqual([ - expect.objectContaining({ - id: users[4].testerId, - devices: [{ id: 4, os: "Windows", osVersion: "Vista" }], - }), - expect.objectContaining({ - id: users[3].testerId, - devices: [{ id: 2, os: "Windows", osVersion: "XP" }], - }), - ]); - }); + // TODO: REMOVE + // it("Should filter by os excluding values", async () => { + // const response = await request(app) + // .get("/campaigns/1/candidates/?filterByExclude[os]=os") + // .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + // expect(response.body).toHaveProperty("results"); + // expect(response.body.results.length).toBe(2); + // expect(response.body.results).toEqual([ + // expect.objectContaining({ + // id: users[4].testerId, + // devices: [{ id: 4, os: "Windows", osVersion: "Vista" }], + // }), + // expect.objectContaining({ + // id: users[3].testerId, + // devices: [{ id: 2, os: "Windows", osVersion: "XP" }], + // }), + // ]); + // }); it("Should filter by os including values", async () => { const response = await request(app) @@ -703,19 +706,21 @@ describe("GET /campaigns/:campaignId/candidates ", () => { }), ]); }); - it("Should filter by os including and excluding values", async () => { - const response = await request(app) - .get( - "/campaigns/1/candidates/?filterByInclude[os]=dow&&filterByExclude[os]=vista" - ) - .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); - expect(response.body).toHaveProperty("results"); - expect(response.body.results.length).toBe(1); - expect(response.body.results).toEqual([ - expect.objectContaining({ - id: users[3].testerId, - devices: [{ id: 2, os: "Windows", osVersion: "XP" }], - }), - ]); - }); + + // TODO: REMOVE + // it("Should filter by os including and excluding values", async () => { + // const response = await request(app) + // .get( + // "/campaigns/1/candidates/?filterByInclude[os]=dow&&filterByExclude[os]=vista" + // ) + // .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + // expect(response.body).toHaveProperty("results"); + // expect(response.body.results.length).toBe(1); + // expect(response.body.results).toEqual([ + // expect.objectContaining({ + // id: users[3].testerId, + // devices: [{ id: 2, os: "Windows", osVersion: "XP" }], + // }), + // ]); + // }); }); diff --git a/src/routes/campaigns/campaignId/candidates/_get/index.ts b/src/routes/campaigns/campaignId/candidates/_get/index.ts index a1b372409..b60f64328 100644 --- a/src/routes/campaigns/campaignId/candidates/_get/index.ts +++ b/src/routes/campaigns/campaignId/candidates/_get/index.ts @@ -1,9 +1,12 @@ /** OPENAPI-CLASS: get-campaigns-campaign-candidates */ -import UserRoute from "@src/features/routes/UserRoute"; import OpenapiError from "@src/features/OpenapiError"; -import Campaigns from "@src/features/db/class/Campaigns"; -import Selector, { Field, InvalidQuestionError } from "./Selector"; +import { tryber } from "@src/features/database"; +import UserRoute from "@src/features/routes/UserRoute"; +import { CandidateDevices } from "./CandidateDevices"; +import { CandidateLevels } from "./CandidateLevels"; +import { CandidateQuestions } from "./CandidateQuestions"; +import { Candidates } from "./Candidates"; type filterBy = { os?: string[] | string } | undefined; export default class RouteItem extends UserRoute<{ response: StoplightOperations["get-campaigns-campaign-candidates"]["responses"][200]["content"]["application/json"]; @@ -11,24 +14,20 @@ export default class RouteItem extends UserRoute<{ parameters: StoplightOperations["get-campaigns-campaign-candidates"]["parameters"]["path"]; }> { private campaign_id: number; - private db: { - campaigns: Campaigns; - }; private start: number; private limit: number; private hasLimit: boolean = false; - private selector: Selector; - private fields: Field[] = []; - private osToExclude: string[] | undefined; - private osToInclude: string[] | undefined; + private fields: { type: "question"; id: number }[] = []; + private filters: + | { + os?: string[]; + } + | undefined; constructor(config: RouteClassConfiguration) { super(config); const parameters = this.getParameters(); this.campaign_id = parseInt(parameters.campaign); - this.db = { - campaigns: new Campaigns(), - }; const query = this.getQuery(); this.start = parseInt(query.start as unknown as string) || 0; this.limit = 10; @@ -46,39 +45,19 @@ export default class RouteItem extends UserRoute<{ }); } - const filterByExclude = query.filterByExclude as filterBy; - if (filterByExclude && "os" in filterByExclude && filterByExclude.os) { - if (!Array.isArray(filterByExclude.os)) { - this.osToExclude = [filterByExclude.os]; - } else { - this.osToExclude = filterByExclude.os; - } - } + this.initFilters(); + } + private initFilters() { + const query = this.getQuery(); const filterByInclude = query.filterByInclude as filterBy; + if (filterByInclude && "os" in filterByInclude && filterByInclude.os) { if (!Array.isArray(filterByInclude.os)) { - this.osToInclude = [filterByInclude.os]; + this.filters = { ...this.filters, os: [filterByInclude.os] }; } else { - this.osToInclude = filterByInclude.os; - } - } - this.selector = new Selector( - this.campaign_id, - this.fields.length ? this.fields : undefined - ); - } - - protected async init(): Promise { - try { - await this.selector.init(); - } catch (e) { - if (e instanceof InvalidQuestionError) { - const error = new OpenapiError("Invalid question"); - this.setError(403, error); - throw error; + this.filters = { ...this.filters, os: filterByInclude.os }; } - throw e; } } @@ -91,157 +70,116 @@ export default class RouteItem extends UserRoute<{ this.setError(404, new OpenapiError("Campaign does not exists.")); return false; } + if (await this.invalidQuestions()) { + this.setError(403, new OpenapiError("Invalid question.")); + return false; + } return true; } private async campaignExists() { - return await this.db.campaigns.exists(this.campaign_id); + const campaign = await tryber.tables.WpAppqEvdCampaign.do() + .select("id") + .where({ id: this.campaign_id }) + .first(); + return !!campaign; } - protected async prepare() { - const applications = await this.selector.getApplications(); - const sortedApplications = this.sortApplications(applications); - const paginatedApplications = this.paginateApplications(sortedApplications); + private async invalidQuestions() { + if (this.fields.length === 0) return false; + + const questions = + await tryber.tables.WpAppqCampaignPreselectionFormFields.do() + .join( + "wp_appq_campaign_preselection_form", + "wp_appq_campaign_preselection_form_fields.form_id", + "wp_appq_campaign_preselection_form.id" + ) + .select( + tryber + .ref("id") + .withSchema("wp_appq_campaign_preselection_form_fields") + .as("id"), + "campaign_id" + ) + .whereIn( + "wp_appq_campaign_preselection_form_fields.id", + this.fields.map((field) => field.id) + ); + + if ( + questions.some((question) => question.campaign_id !== this.campaign_id) + ) { + return true; + } - const faseDos = this.filterItems(paginatedApplications); + return false; + } - const formattedApplications = await this.formatApplications(faseDos); + protected async prepare() { + const { candidates, total } = await this.getCandidates(); this.setSuccess(200, { - results: formattedApplications, - size: paginatedApplications.length, + results: candidates.map((candidate) => { + return { + id: candidate.id, + name: candidate.name, + surname: candidate.surname, + experience: candidate.total_exp_pts, + level: candidate.level, + devices: candidate.devices, + questions: candidate.questions, + }; + }), + size: candidates.length, start: this.start, limit: this.hasLimit ? this.limit : undefined, - total: this.hasLimit ? applications.length : undefined, + total: this.hasLimit ? total : undefined, }); } - private filterItems( - applications: Awaited> - ) { - let filteredDevices = applications; - filteredDevices = this.filterByExcludeOs(filteredDevices); - filteredDevices = this.filterByIncludeOs(filteredDevices); - return filteredDevices; - } - - private filterByExcludeOs( - applications: Awaited> - ) { - if (!this.osToExclude) { - return applications; - } - const osListToExclude = this.osToExclude; - const removeDevicesToExclude = applications.map((a) => { - return { - ...a, - devices: filterDevicesToExclude(a.devices), - }; - }); - - return removeDevicesToExclude.filter((a) => { - return a.devices.length > 0; + private async getCandidates() { + const candidatesRetriever = new Candidates({ + campaign_id: this.campaign_id, }); + const candidates = await candidatesRetriever.get(); - function filterDevicesToExclude( - devices: { - manufacturer?: string | undefined; - model?: string | undefined; - os: string; - osVersion: string; - id: number; - }[] - ) { - return devices.filter((d) => { - const osString = d.os.toLowerCase() + " " + d.osVersion.toLowerCase(); - for (const os of osListToExclude) { - if (osString.includes(os.toLowerCase())) { - return false; - } - } - return true; - }); - } - } - private filterByIncludeOs( - applications: Awaited> - ) { - if (!this.osToInclude) { - return applications; - } - const osListToInclude = this.osToInclude; - const leaveDevicesToInclude = applications.map((a) => { - return { - ...a, - devices: filterDevicesToInclude(a.devices), - }; + const deviceGetter = new CandidateDevices({ + candidateIds: candidates.map((candidate) => candidate.id), + ...(this.filters?.os && { filters: { os: this.filters?.os } }), }); + await deviceGetter.init(); - return leaveDevicesToInclude.filter((a) => { - return a.devices.length > 0; + const levelGetter = new CandidateLevels({ + candidateIds: candidates.map((candidate) => candidate.id), }); + await levelGetter.init(); - function filterDevicesToInclude( - devices: { - manufacturer?: string | undefined; - model?: string | undefined; - os: string; - osVersion: string; - id: number; - }[] - ) { - return devices.filter((d) => { - const osString = d.os.toLowerCase() + " " + d.osVersion.toLowerCase(); - for (const os of osListToInclude) { - if (osString.includes(os.toLowerCase())) { - return true; - } - } - return false; - }); - } - } - - private async formatApplications( - applications: Awaited> - ) { - let results = []; - for (const application of applications) { - results.push({ - id: application.id, - name: application.name, - surname: application.surname, - experience: application.experience, - level: this.getLevel(application.id), - devices: application.devices, - questions: - "questions" in application ? application.questions : undefined, - }); - } - - return results; - } - - private paginateApplications( - applications: Awaited> - ) { - return applications.slice(this.start, this.start + this.limit); - } - - private sortApplications( - applications: Awaited> - ) { - return applications.sort((a, b) => { - const aId = a.id; - const bId = b.id; - const aLevelId = this.selector.getUserLevel(aId).id; - const bLevelId = this.selector.getUserLevel(bId).id; - return bLevelId - aLevelId; + const questionGetter = new CandidateQuestions({ + candidateIds: candidates.map((candidate) => candidate.id), + questionIds: this.fields.map((field) => field.id), }); - } - - private getLevel(testerId: number) { - const userLevel = this.selector.getUserLevel(testerId); - return userLevel.name; + await questionGetter.init(); + + const result = candidates + .map((candidate) => { + return { + ...candidate, + level: levelGetter.getCandidateData(candidate), + devices: deviceGetter.getCandidateData(candidate), + questions: questionGetter.getCandidateData(candidate), + }; + }) + .filter( + (candidate) => + deviceGetter.isCandidateFiltered(candidate) && + questionGetter.isCandidateFiltered(candidate) && + levelGetter.isCandidateFiltered(candidate) + ); + + return { + candidates: result.slice(this.start, this.start + this.limit), + total: result.length, + }; } } From 8b7f6f3866dda7598b70090a4b2d9ae2cbfa9957 Mon Sep 17 00:00:00 2001 From: Davide Bizzi Date: Mon, 26 Feb 2024 15:55:49 +0100 Subject: [PATCH 2/6] fix: Correct type --- .../campaigns/campaignId/candidates/_get/CandidateDevices.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/campaigns/campaignId/candidates/_get/CandidateDevices.ts b/src/routes/campaigns/campaignId/candidates/_get/CandidateDevices.ts index 87880c1af..f188e8fbc 100644 --- a/src/routes/campaigns/campaignId/candidates/_get/CandidateDevices.ts +++ b/src/routes/campaigns/campaignId/candidates/_get/CandidateDevices.ts @@ -137,7 +137,7 @@ class CandidateDevices implements CandidateData { return this.filters.os.reduce((acc, os) => { if (device.os.toLowerCase().includes(os.toLowerCase())) return true; return acc; - }, false); + }, false as boolean); } return true; From 9336dfa35f0ae646e656142e9dc177940a626c98 Mon Sep 17 00:00:00 2001 From: Davide Bizzi Date: Tue, 27 Feb 2024 10:28:41 +0100 Subject: [PATCH 3/6] feat: Add id filters --- .../candidates/_get/CandidateProfile.ts | 37 +++++ .../campaignId/candidates/_get/index.spec.ts | 147 ++++++++++++------ .../campaignId/candidates/_get/index.ts | 93 +++++++++-- 3 files changed, 218 insertions(+), 59 deletions(-) create mode 100644 src/routes/campaigns/campaignId/candidates/_get/CandidateProfile.ts diff --git a/src/routes/campaigns/campaignId/candidates/_get/CandidateProfile.ts b/src/routes/campaigns/campaignId/candidates/_get/CandidateProfile.ts new file mode 100644 index 000000000..835649338 --- /dev/null +++ b/src/routes/campaigns/campaignId/candidates/_get/CandidateProfile.ts @@ -0,0 +1,37 @@ +import { CandidateData } from "./iCandidateData"; + +class CandidateProfile implements CandidateData { + private candidateIds: number[]; + private filters?: { id?: { include?: string[]; exclude?: string[] } }; + + constructor({ + candidateIds, + filters, + }: { + candidateIds: number[]; + filters?: { id?: { include?: string[]; exclude?: string[] } }; + }) { + this.candidateIds = candidateIds; + this.filters = filters; + } + + async init() { + return; + } + + getCandidateData(candidate: { id: number }) { + return undefined; + } + + isCandidateFiltered(candidate: { id: number }): boolean { + if (this.filters?.id?.include) { + return this.filters.id.include.includes(candidate.id.toString()); + } + if (this.filters?.id?.exclude) { + return !this.filters.id.exclude.includes(candidate.id.toString()); + } + return true; + } +} + +export { CandidateProfile }; diff --git a/src/routes/campaigns/campaignId/candidates/_get/index.spec.ts b/src/routes/campaigns/campaignId/candidates/_get/index.spec.ts index 939348737..10b5c2585 100644 --- a/src/routes/campaigns/campaignId/candidates/_get/index.spec.ts +++ b/src/routes/campaigns/campaignId/candidates/_get/index.spec.ts @@ -504,21 +504,6 @@ describe("GET /campaigns/:campaignId/candidates ", () => { ); }); - // TODO: REMOVE - - // it("should order by level id", async () => { - // const response = await request(app) - // .get("/campaigns/1/candidates/") - // .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); - // expect(response.body).toHaveProperty("results"); - // expect(response.body.results.length).toBe(3); - // expect(response.body.results.map((r: { id: number }) => r.id)).toEqual([ - // users[2].testerId, - // users[4].testerId, - // users[3].testerId, - // ]); - // }); - it("should allow pagination of one element", async () => { const response = await request(app) .get("/campaigns/1/candidates/?start=1&limit=1") @@ -670,25 +655,6 @@ describe("GET /campaigns/:campaignId/candidates ", () => { ); }); - // TODO: REMOVE - // it("Should filter by os excluding values", async () => { - // const response = await request(app) - // .get("/campaigns/1/candidates/?filterByExclude[os]=os") - // .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); - // expect(response.body).toHaveProperty("results"); - // expect(response.body.results.length).toBe(2); - // expect(response.body.results).toEqual([ - // expect.objectContaining({ - // id: users[4].testerId, - // devices: [{ id: 4, os: "Windows", osVersion: "Vista" }], - // }), - // expect.objectContaining({ - // id: users[3].testerId, - // devices: [{ id: 2, os: "Windows", osVersion: "XP" }], - // }), - // ]); - // }); - it("Should filter by os including values", async () => { const response = await request(app) .get("/campaigns/1/candidates/?filterByInclude[os]=dow") @@ -707,20 +673,101 @@ describe("GET /campaigns/:campaignId/candidates ", () => { ]); }); - // TODO: REMOVE - // it("Should filter by os including and excluding values", async () => { - // const response = await request(app) - // .get( - // "/campaigns/1/candidates/?filterByInclude[os]=dow&&filterByExclude[os]=vista" - // ) - // .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); - // expect(response.body).toHaveProperty("results"); - // expect(response.body.results.length).toBe(1); - // expect(response.body.results).toEqual([ - // expect.objectContaining({ - // id: users[3].testerId, - // devices: [{ id: 2, os: "Windows", osVersion: "XP" }], - // }), - // ]); - // }); + it("Should filter by tryber-ids excluding values", async () => { + const response = await request(app) + .get("/campaigns/1/candidates/?filterByExclude[testerIds]=3,4") + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(response.body).toHaveProperty("results"); + expect(response.body.results.length).toBe(1); + expect(response.body.results).toEqual([ + expect.objectContaining({ + id: users[3].testerId, + }), + ]); + }); + + it("Should filter by tryber-ids excluding values with T-char", async () => { + const response = await request(app) + .get("/campaigns/1/candidates/?filterByExclude[testerIds]=T3,T4") + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(response.body).toHaveProperty("results"); + expect(response.body.results.length).toBe(1); + expect(response.body.results).toEqual([ + expect.objectContaining({ + id: users[3].testerId, + }), + ]); + }); + + it("Should filter by tryber-ids including values", async () => { + const response = await request(app) + .get( + "/campaigns/1/candidates/?filterByInclude[testerIds]=2&filterByInclude[testerIds]=4" + ) + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(response.body).toHaveProperty("results"); + expect(response.body.results.length).toBe(2); + expect(response.body.results).toEqual([ + expect.objectContaining({ + id: users[2].testerId, // 4 + }), + expect.objectContaining({ + id: users[3].testerId, // 2 + }), + ]); + }); + + it("Should filter by tryber-ids including values with T-char", async () => { + const response = await request(app) + .get( + "/campaigns/1/candidates/?filterByInclude[testerIds]=T2&filterByInclude[testerIds]=T4" + ) + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(response.body).toHaveProperty("results"); + expect(response.body.results.length).toBe(2); + expect(response.body.results).toEqual([ + expect.objectContaining({ + id: users[2].testerId, // 4 + }), + expect.objectContaining({ + id: users[3].testerId, // 2 + }), + ]); + }); + + it("Should filter by tryber-ids including and excluding values", async () => { + const response = await request(app) + .get( + "/campaigns/1/candidates/?filterByExclude[testerIds]=3&filterByInclude[testerIds]=2,4" + ) + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(response.body).toHaveProperty("results"); + expect(response.body.results.length).toBe(2); + expect(response.body.results).toEqual([ + expect.objectContaining({ + id: users[2].testerId, // 4 + }), + expect.objectContaining({ + id: users[3].testerId, // 2 + }), + ]); + }); + + it("Should filter by tryber-ids including and excluding values with T-char", async () => { + const response = await request(app) + .get( + "/campaigns/1/candidates/?filterByExclude[testerIds]=T3&filterByInclude[testerIds]=T2,T4" + ) + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(response.body).toHaveProperty("results"); + expect(response.body.results.length).toBe(2); + expect(response.body.results).toEqual([ + expect.objectContaining({ + id: users[2].testerId, // 4 + }), + expect.objectContaining({ + id: users[3].testerId, // 2 + }), + ]); + }); }); diff --git a/src/routes/campaigns/campaignId/candidates/_get/index.ts b/src/routes/campaigns/campaignId/candidates/_get/index.ts index b60f64328..4da9a88c9 100644 --- a/src/routes/campaigns/campaignId/candidates/_get/index.ts +++ b/src/routes/campaigns/campaignId/candidates/_get/index.ts @@ -5,9 +5,13 @@ import { tryber } from "@src/features/database"; import UserRoute from "@src/features/routes/UserRoute"; import { CandidateDevices } from "./CandidateDevices"; import { CandidateLevels } from "./CandidateLevels"; +import { CandidateProfile } from "./CandidateProfile"; import { CandidateQuestions } from "./CandidateQuestions"; import { Candidates } from "./Candidates"; -type filterBy = { os?: string[] | string } | undefined; + +type filterBy = + | { os?: string[] | string; testerIds?: string[] | string } + | undefined; export default class RouteItem extends UserRoute<{ response: StoplightOperations["get-campaigns-campaign-candidates"]["responses"][200]["content"]["application/json"]; query: StoplightOperations["get-campaigns-campaign-candidates"]["parameters"]["query"]; @@ -21,6 +25,10 @@ export default class RouteItem extends UserRoute<{ private filters: | { os?: string[]; + ids?: { + include?: number[]; + exclude?: number[]; + }; } | undefined; @@ -45,20 +53,75 @@ export default class RouteItem extends UserRoute<{ }); } - this.initFilters(); + this.filters = { ...this.filters, ...this.initOsFilter() }; + this.filters = { ...this.filters, ...this.initExcludeIds() }; + this.filters = { ...this.filters, ...this.initIncludeIds() }; + } + + private initIncludeIds() { + const query = this.getQuery(); + const filterByInclude = query.filterByInclude as filterBy; + + if ( + filterByInclude && + "testerIds" in filterByInclude && + filterByInclude.testerIds + ) { + const ids = Array.isArray(filterByInclude.testerIds) + ? filterByInclude.testerIds.flatMap((ids) => ids.split(",")) + : filterByInclude.testerIds.split(","); + + return { + ids: { + ...this.filters?.ids, + include: ids + .map((id) => id.replace(/\D/g, "")) + .map(Number) + .filter((num) => !isNaN(num)), + }, + }; + } + return {}; + } + + private initExcludeIds() { + const query = this.getQuery(); + const filterByExclude = query.filterByExclude as filterBy; + if ( + filterByExclude && + "testerIds" in filterByExclude && + filterByExclude.testerIds + ) { + const ids = Array.isArray(filterByExclude.testerIds) + ? filterByExclude.testerIds.flatMap((ids) => ids.split(",")) + : filterByExclude.testerIds.split(","); + + return { + ids: { + ...this.filters?.ids, + exclude: ids + .map((id) => id.replace(/\D/g, "")) + .map(Number) + .filter((num) => !isNaN(num)), + }, + }; + } + + return {}; } - private initFilters() { + private initOsFilter() { const query = this.getQuery(); const filterByInclude = query.filterByInclude as filterBy; if (filterByInclude && "os" in filterByInclude && filterByInclude.os) { - if (!Array.isArray(filterByInclude.os)) { - this.filters = { ...this.filters, os: [filterByInclude.os] }; - } else { - this.filters = { ...this.filters, os: filterByInclude.os }; - } + const os = Array.isArray(filterByInclude.os) + ? filterByInclude.os + : [filterByInclude.os]; + + return { os }; } + return {}; } protected async filter() { @@ -161,6 +224,17 @@ export default class RouteItem extends UserRoute<{ }); await questionGetter.init(); + const profileGetter = new CandidateProfile({ + candidateIds: candidates.map((candidate) => candidate.id), + filters: { + id: { + include: this.filters?.ids?.include?.map((id) => id.toString()), + exclude: this.filters?.ids?.exclude?.map((id) => id.toString()), + }, + }, + }); + await profileGetter.init(); + const result = candidates .map((candidate) => { return { @@ -174,7 +248,8 @@ export default class RouteItem extends UserRoute<{ (candidate) => deviceGetter.isCandidateFiltered(candidate) && questionGetter.isCandidateFiltered(candidate) && - levelGetter.isCandidateFiltered(candidate) + levelGetter.isCandidateFiltered(candidate) && + profileGetter.isCandidateFiltered(candidate) ); return { From 169d50b61b9c39cfd86c1253e2dd30305658e81c Mon Sep 17 00:00:00 2001 From: Davide Bizzi Date: Tue, 27 Feb 2024 10:42:00 +0100 Subject: [PATCH 4/6] rework: Refactor filters to make explicit assignment --- .../campaignId/candidates/_get/index.ts | 99 +++++++++---------- 1 file changed, 44 insertions(+), 55 deletions(-) diff --git a/src/routes/campaigns/campaignId/candidates/_get/index.ts b/src/routes/campaigns/campaignId/candidates/_get/index.ts index 4da9a88c9..0efc1be57 100644 --- a/src/routes/campaigns/campaignId/candidates/_get/index.ts +++ b/src/routes/campaigns/campaignId/candidates/_get/index.ts @@ -53,75 +53,64 @@ export default class RouteItem extends UserRoute<{ }); } - this.filters = { ...this.filters, ...this.initOsFilter() }; - this.filters = { ...this.filters, ...this.initExcludeIds() }; - this.filters = { ...this.filters, ...this.initIncludeIds() }; + this.filters = { ...this.filters, ...this.getOsFilter() }; + this.filters = { + ...this.filters, + ids: { ...this?.filters.ids, exclude: this.getExcludeIds() }, + }; + this.filters = { + ...this.filters, + ids: { ...this?.filters.ids, include: this.getIncludeIds() }, + }; } - private initIncludeIds() { + private getIncludeIds() { const query = this.getQuery(); const filterByInclude = query.filterByInclude as filterBy; - - if ( - filterByInclude && - "testerIds" in filterByInclude && - filterByInclude.testerIds - ) { - const ids = Array.isArray(filterByInclude.testerIds) - ? filterByInclude.testerIds.flatMap((ids) => ids.split(",")) - : filterByInclude.testerIds.split(","); - - return { - ids: { - ...this.filters?.ids, - include: ids - .map((id) => id.replace(/\D/g, "")) - .map(Number) - .filter((num) => !isNaN(num)), - }, - }; - } - return {}; + if (!filterByInclude) return undefined; + if ("testerIds" in filterByInclude === false) return undefined; + if (filterByInclude.testerIds === undefined) return undefined; + + const ids = Array.isArray(filterByInclude.testerIds) + ? filterByInclude.testerIds.flatMap((ids) => ids.split(",")) + : filterByInclude.testerIds.split(","); + + return ids + .map((id) => id.replace(/\D/g, "")) + .map(Number) + .filter((num) => !isNaN(num)); } - private initExcludeIds() { + private getExcludeIds() { const query = this.getQuery(); const filterByExclude = query.filterByExclude as filterBy; - if ( - filterByExclude && - "testerIds" in filterByExclude && - filterByExclude.testerIds - ) { - const ids = Array.isArray(filterByExclude.testerIds) - ? filterByExclude.testerIds.flatMap((ids) => ids.split(",")) - : filterByExclude.testerIds.split(","); - - return { - ids: { - ...this.filters?.ids, - exclude: ids - .map((id) => id.replace(/\D/g, "")) - .map(Number) - .filter((num) => !isNaN(num)), - }, - }; - } - - return {}; + if (!filterByExclude) return undefined; + if ("testerIds" in filterByExclude === false) return undefined; + if (filterByExclude.testerIds === undefined) return undefined; + + const ids = Array.isArray(filterByExclude.testerIds) + ? filterByExclude.testerIds.flatMap((ids) => ids.split(",")) + : filterByExclude.testerIds.split(","); + + return ids + .map((id) => id.replace(/\D/g, "")) + .map(Number) + .filter((num) => !isNaN(num)); } - private initOsFilter() { + private getOsFilter() { const query = this.getQuery(); const filterByInclude = query.filterByInclude as filterBy; - if (filterByInclude && "os" in filterByInclude && filterByInclude.os) { - const os = Array.isArray(filterByInclude.os) - ? filterByInclude.os - : [filterByInclude.os]; + if (!filterByInclude) return {}; + if ("os" in filterByInclude === false) return {}; + if (filterByInclude.os === undefined) return {}; - return { os }; - } - return {}; + return { + os: Array.isArray(filterByInclude.os) + ? filterByInclude.os + : [filterByInclude.os], + }; } protected async filter() { From 03796ec32b7c07245d2eb7eeab6f279e7c217159 Mon Sep 17 00:00:00 2001 From: Davide Bizzi Date: Tue, 27 Feb 2024 11:22:35 +0100 Subject: [PATCH 5/6] chore: Remove unused code --- src/features/db/class/Level.ts | 34 ----------------- src/features/db/class/PreselectionFormData.ts | 23 ------------ src/features/db/class/UserLevel.ts | 37 ------------------- 3 files changed, 94 deletions(-) delete mode 100644 src/features/db/class/Level.ts delete mode 100644 src/features/db/class/UserLevel.ts diff --git a/src/features/db/class/Level.ts b/src/features/db/class/Level.ts deleted file mode 100644 index 47fff3a90..000000000 --- a/src/features/db/class/Level.ts +++ /dev/null @@ -1,34 +0,0 @@ -import Database from "./Database"; - -type LevelType = { - id?: number; - name?: string; -}; - -class LevelObject implements LevelType { - id?: number; - name?: string; - - constructor(item: LevelType) { - this.id = item.id; - this.name = item.name; - } -} - -class Level extends Database<{ - fields: LevelType; -}> { - constructor(fields?: Level["fields"][number][] | ["*"]) { - super({ - table: "wp_appq_activity_level_definition", - primaryKey: "id", - fields: fields ? fields : ["id", "name"], - }); - } - - public createObject(row: LevelType): LevelObject { - return new LevelObject(row); - } -} -export default Level; -export { LevelObject }; diff --git a/src/features/db/class/PreselectionFormData.ts b/src/features/db/class/PreselectionFormData.ts index 176167d96..f407a2a74 100644 --- a/src/features/db/class/PreselectionFormData.ts +++ b/src/features/db/class/PreselectionFormData.ts @@ -8,22 +8,6 @@ type PreselectionFormDataType = { value: string; }; -class PreselectionFormDataObject { - id: number; - campaign_id: number; - field_id: number; - value: string; - tester_id: number; - - constructor(item: PreselectionFormDataType) { - this.id = item.id; - this.campaign_id = item.campaign_id; - this.tester_id = item.tester_id; - this.field_id = item.field_id; - this.value = item.value; - } -} - class PreselectionFormData extends Database<{ fields: PreselectionFormDataType; }> { @@ -36,12 +20,5 @@ class PreselectionFormData extends Database<{ : ["id", "campaign_id", "field_id", "value", "tester_id"], }); } - - public createObject( - row: PreselectionFormDataType - ): PreselectionFormDataObject { - return new PreselectionFormDataObject(row); - } } export default PreselectionFormData; -export { PreselectionFormDataObject }; diff --git a/src/features/db/class/UserLevel.ts b/src/features/db/class/UserLevel.ts deleted file mode 100644 index 6b71a9b6a..000000000 --- a/src/features/db/class/UserLevel.ts +++ /dev/null @@ -1,37 +0,0 @@ -import Database from "./Database"; - -type UserLevelType = { - id?: number; - tester_id?: number; - level_id?: number; -}; - -class UserLevelObject implements UserLevelType { - id?: number; - tester_id?: number; - level_id?: number; - - constructor(item: UserLevelType) { - this.id = item.id; - this.tester_id = item.tester_id; - this.level_id = item.level_id; - } -} - -class UserLevel extends Database<{ - fields: UserLevelType; -}> { - constructor(fields?: UserLevel["fields"][number][] | ["*"]) { - super({ - table: "wp_appq_activity_level", - primaryKey: "id", - fields: fields ? fields : ["id", "tester_id", "level_id"], - }); - } - - public createObject(row: UserLevelType): UserLevelObject { - return new UserLevelObject(row); - } -} -export default UserLevel; -export { UserLevelObject }; From ecf46821fb3ae692605a27047bcdb79b8684a579 Mon Sep 17 00:00:00 2001 From: Davide Bizzi Date: Tue, 27 Feb 2024 11:39:20 +0100 Subject: [PATCH 6/6] chore: Remove unused code and add middleware tests --- src/features/db/mysql.ts | 19 --- src/index.ts | 5 +- src/middleware/getExample/index.spec.ts | 145 ++++++++++++++++++ .../{getExample.ts => getExample/index.ts} | 4 +- src/middleware/notFound/index.spec.ts | 13 ++ .../{notFound.ts => notFound/index.ts} | 0 src/middleware/notImplemented/index.spec.ts | 23 +++ .../index.ts} | 4 - 8 files changed, 185 insertions(+), 28 deletions(-) delete mode 100644 src/features/db/mysql.ts create mode 100644 src/middleware/getExample/index.spec.ts rename src/middleware/{getExample.ts => getExample/index.ts} (92%) create mode 100644 src/middleware/notFound/index.spec.ts rename src/middleware/{notFound.ts => notFound/index.ts} (100%) create mode 100644 src/middleware/notImplemented/index.spec.ts rename src/middleware/{notImplemented.ts => notImplemented/index.ts} (76%) diff --git a/src/features/db/mysql.ts b/src/features/db/mysql.ts deleted file mode 100644 index 6822b88cb..000000000 --- a/src/features/db/mysql.ts +++ /dev/null @@ -1,19 +0,0 @@ -import mysql, { Pool } from "mysql"; -import config from "../../config"; - -var _maxConnection: number = parseInt(process.env.CONNECTION_COUNT || "1"); - -var _pool: Pool; -export default { - connectToServer: function (callback: () => void) { - _pool = mysql.createPool({ - connectionLimit: _maxConnection, - ...config.db, - }); - return callback(); - }, - - getConnection: function () { - return _pool; - }, -}; diff --git a/src/index.ts b/src/index.ts index 3b04689ea..199171f71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,5 @@ import app from "@src/app"; import config from "@src/config"; -import connectionManager from "@src/features/db/mysql"; const PORT = config.port || 3000; -connectionManager.connectToServer(() => { - app.listen(PORT, () => console.info("api listening on port " + PORT)); -}); +app.listen(PORT, () => console.info("api listening on port " + PORT)); diff --git a/src/middleware/getExample/index.spec.ts b/src/middleware/getExample/index.spec.ts new file mode 100644 index 000000000..efce6169b --- /dev/null +++ b/src/middleware/getExample/index.spec.ts @@ -0,0 +1,145 @@ +import getExample from "."; + +describe("Get Example middleware", () => { + it("should return a middleware", () => { + expect(getExample).toBeInstanceOf(Function); + }); + it("Should return false if path does not exists", () => { + const result = getExample( + { definition: { paths: {} } }, + "/endpoint", + "get", + "400", + undefined + ); + expect(result).toBe(false); + }); + it("Should return false if path does not have method", () => { + const result = getExample( + { definition: { paths: { "/endpoint": {} } } }, + "/endpoint", + "get", + "400", + undefined + ); + expect(result).toBe(false); + }); + it("Should return false if path method does not have response", () => { + const result = getExample( + { definition: { paths: { "/endpoint": { get: { responses: {} } } } } }, + "/endpoint", + "get", + "400", + undefined + ); + expect(result).toBe(false); + }); + it("Should return false if path method does not have response with requested status", () => { + const result = getExample( + { + definition: { + paths: { "/endpoint": { get: { responses: { "200": {} } } } }, + }, + }, + "/endpoint", + "get", + "400", + undefined + ); + expect(result).toBe(false); + }); + it("Should return false if path method response does not have content", () => { + const result = getExample( + { + definition: { + paths: { "/endpoint": { get: { responses: { "400": {} } } } }, + }, + }, + "/endpoint", + "get", + "400", + undefined + ); + expect(result).toBe(false); + }); + it("Should return false if path method response does not have application/json content", () => { + const result = getExample( + { + definition: { + paths: { + "/endpoint": { + get: { responses: { "400": { content: { "text/html": {} } } } }, + }, + }, + }, + }, + "/endpoint", + "get", + "400", + undefined + ); + expect(result).toBe(false); + }); + it("Should return requested example if a specific example is requested", () => { + const result = getExample( + { + definition: { + paths: { + "/endpoint": { + get: { + responses: { + "400": { + content: { + "application/json": { + examples: { + "my-example": { value: { hello: "world" } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "/endpoint", + "get", + "400", + "my-example" + ); + expect(result).toEqual({ hello: "world" }); + }); + + it("Should return first example if no specific example is requested", () => { + const result = getExample( + { + definition: { + paths: { + "/endpoint": { + get: { + responses: { + "400": { + content: { + "application/json": { + examples: { + "my-other-example": { value: { hello: "universe" } }, + "my-example": { value: { hello: "world" } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "/endpoint", + "get", + "400", + undefined + ); + expect(result).toEqual({ hello: "universe" }); + }); +}); diff --git a/src/middleware/getExample.ts b/src/middleware/getExample/index.ts similarity index 92% rename from src/middleware/getExample.ts rename to src/middleware/getExample/index.ts index ad6166de3..e391abfbb 100644 --- a/src/middleware/getExample.ts +++ b/src/middleware/getExample/index.ts @@ -33,7 +33,9 @@ export default ( ) { return responseData.examples[example].value; } - const firstExample = Object.values(response.examples).pop() as { value: any }; + const firstExample = Object.values(responseData.examples).shift() as { + value: any; + }; if (!firstExample.hasOwnProperty("value")) { return false; } diff --git a/src/middleware/notFound/index.spec.ts b/src/middleware/notFound/index.spec.ts new file mode 100644 index 000000000..7b9a7f39c --- /dev/null +++ b/src/middleware/notFound/index.spec.ts @@ -0,0 +1,13 @@ +import app from "@src/app"; +import request from "supertest"; + +describe("Check NotFound middleware", () => { + it("should return 404 and a not found err", async () => { + const response = await request(app).get("/fakes/route-that-doesnt-exist"); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ + err: "not found", + }); + }); +}); diff --git a/src/middleware/notFound.ts b/src/middleware/notFound/index.ts similarity index 100% rename from src/middleware/notFound.ts rename to src/middleware/notFound/index.ts diff --git a/src/middleware/notImplemented/index.spec.ts b/src/middleware/notImplemented/index.spec.ts new file mode 100644 index 000000000..6a10ab461 --- /dev/null +++ b/src/middleware/notImplemented/index.spec.ts @@ -0,0 +1,23 @@ +import notImplemented from "."; +describe("Not Implemented", () => { + it("should return a not implemented error", async () => { + const json = jest.fn(); + const status = jest.fn(() => ({ json })); + // @ts-ignore + const middleware = notImplemented({ + // @ts-ignore + mockResponseForOperation: (operation) => ({ + status: 501, + mock: { + message: "Not Implemented", + }, + }), + }); + // @ts-ignore + middleware({ operation: { operationId: "" } }, {}, { status }); + expect(json).toBeCalledTimes(1); + expect(json).toBeCalledWith({ message: "Not Implemented" }); + expect(status).toBeCalledTimes(1); + expect(status).toBeCalledWith(501); + }); +}); diff --git a/src/middleware/notImplemented.ts b/src/middleware/notImplemented/index.ts similarity index 76% rename from src/middleware/notImplemented.ts rename to src/middleware/notImplemented/index.ts index 775691704..7d87417aa 100644 --- a/src/middleware/notImplemented.ts +++ b/src/middleware/notImplemented/index.ts @@ -3,10 +3,6 @@ import OpenAPIBackend, { Context } from "openapi-backend"; export default (api: OpenAPIBackend) => async (c: Context, req: Request, res: OpenapiResponse) => { res.skip_post_response_handler = true; - if (process.env && process.env.DEBUG) { - console.log(`Mocking ${c.operation.operationId}`); - } - const { status, mock } = api.mockResponseForOperation( c.operation.operationId || "" );