diff --git a/src/__mocks__/mocks.ts b/src/__mocks__/mocks.ts index ff6245b50..2ded13571 100644 --- a/src/__mocks__/mocks.ts +++ b/src/__mocks__/mocks.ts @@ -4,4 +4,9 @@ jest.mock("@sendgrid/mail", () => ({ setApiKey: jest.fn(), send: jest.fn(), })); + +// Allow to use jest.useFakeTimers() in tests +Object.defineProperty(global, "performance", { + writable: true, +}); export {}; diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index 106436e10..83fbccff3 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -1220,10 +1220,23 @@ paths: type: string surname: type: string - experience: - type: integer - level: - type: string + gender: + $ref: '#/components/schemas/Gender' + age: + type: number + x-stoplight: + id: 435m5469ngl4b + levels: + type: object + x-stoplight: + id: 2djwp6agmx80i + required: + - bugHunting + properties: + bugHunting: + type: string + x-stoplight: + id: kjyr3hu082enj devices: type: array items: @@ -1258,8 +1271,9 @@ paths: - id - name - surname - - experience - - level + - gender + - age + - levels - devices - $ref: '#/components/schemas/PaginationData' examples: @@ -2859,6 +2873,10 @@ paths: in: query name: filterByExclude description: Key-value Array for item filtering + - schema: {} + in: query + name: filterByAge + description: Array with min and max '/campaigns/{campaign}/payouts': parameters: - $ref: '#/components/parameters/campaign' diff --git a/src/routes/campaigns/campaignId/candidates/_get/CandidateBhLevel.ts b/src/routes/campaigns/campaignId/candidates/_get/CandidateBhLevel.ts new file mode 100644 index 000000000..4d29f2623 --- /dev/null +++ b/src/routes/campaigns/campaignId/candidates/_get/CandidateBhLevel.ts @@ -0,0 +1,199 @@ +import { tryber } from "@src/features/database"; +import { CandidateData } from "./iCandidateData"; + +class CandidateBhLevel implements CandidateData { + private candidateIds: number[]; + private filters?: { bughunting?: string[] }; + + private courses: Record< + "levelone" | "leveltwo", + { career: string; level: string } + > = { + levelone: { career: "Functional", level: "1" }, + leveltwo: { career: "General", level: "2" }, + }; + + private _candidateData: + | { + id: number; + campaigns: number; + bugs: number; + levelOneCourse: boolean; + levelTwoCourse: boolean; + highBugs: number; + criticalBugs: number; + }[] + | undefined; + + constructor({ + candidateIds, + filters, + }: { + candidateIds: number[]; + filters?: { bughunting?: string[] }; + }) { + this.candidateIds = candidateIds; + this.filters = filters; + } + + get candidateData() { + if (this._candidateData === undefined) + throw new Error("CandidateProfile not initialized"); + return this._candidateData; + } + + async init() { + const result = await tryber.tables.WpAppqEvdBug.do() + .join( + "wp_appq_evd_profile", + "wp_appq_evd_bug.wp_user_id", + "wp_appq_evd_profile.wp_user_id" + ) + .select(tryber.ref("id").withSchema("wp_appq_evd_profile")) + .countDistinct({ campaigns: "campaign_id" }) + .count({ bug: tryber.ref("id").withSchema("wp_appq_evd_bug") }) + .where("status_id", 2) + .whereIn("wp_appq_evd_profile.id", this.candidateIds) + .groupBy("wp_appq_evd_profile.id"); + + const hasLevelOneCourse = await tryber.tables.WpAppqEvdProfile.do() + .join( + "wp_appq_course_tester_status", + "wp_appq_evd_profile.id", + "wp_appq_course_tester_status.tester_id" + ) + .join( + "wp_appq_course", + "wp_appq_course_tester_status.course_id", + "wp_appq_course.id" + ) + .where("wp_appq_course_tester_status.is_completed", 1) + .where("wp_appq_course.course_level", this.courses.levelone.level) + .where("wp_appq_course.career", this.courses.levelone.career) + .whereIn("wp_appq_evd_profile.id", this.candidateIds); + + const hasLevelTwoCourse = await tryber.tables.WpAppqEvdProfile.do() + .join( + "wp_appq_course_tester_status", + "wp_appq_evd_profile.id", + "wp_appq_course_tester_status.tester_id" + ) + .join( + "wp_appq_course", + "wp_appq_course_tester_status.course_id", + "wp_appq_course.id" + ) + .where("wp_appq_course_tester_status.is_completed", 1) + .where("wp_appq_course.course_level", this.courses.leveltwo.level) + .where("wp_appq_course.career", this.courses.leveltwo.career) + .whereIn("wp_appq_evd_profile.id", this.candidateIds); + + const criticalBugs = await tryber.tables.WpAppqEvdBug.do() + .join( + "wp_appq_evd_profile", + "wp_appq_evd_bug.wp_user_id", + "wp_appq_evd_profile.wp_user_id" + ) + .select( + tryber.ref("id").withSchema("wp_appq_evd_profile").as("tester_id") + ) + .count({ bug: tryber.ref("id").withSchema("wp_appq_evd_bug") }) + .where("status_id", 2) + .where("severity_id", 4) + .whereIn("wp_appq_evd_profile.id", this.candidateIds) + .groupBy("wp_appq_evd_bug.wp_user_id"); + const highBugs = await tryber.tables.WpAppqEvdBug.do() + .join( + "wp_appq_evd_profile", + "wp_appq_evd_bug.wp_user_id", + "wp_appq_evd_profile.wp_user_id" + ) + .select( + tryber.ref("id").withSchema("wp_appq_evd_profile").as("tester_id") + ) + .count({ bug: tryber.ref("id").withSchema("wp_appq_evd_bug") }) + .where("status_id", 2) + .where("severity_id", 3) + .whereIn("wp_appq_evd_profile.id", this.candidateIds) + .groupBy("wp_appq_evd_bug.wp_user_id"); + + this._candidateData = result.map((candidate) => { + const highBug = highBugs.find((bug) => bug.tester_id === candidate.id); + const criticalBug = criticalBugs.find( + (bug) => bug.tester_id === candidate.id + ); + return { + id: candidate.id, + campaigns: candidate.campaigns ? Number(candidate.campaigns) : 0, + bugs: candidate.bug ? Number(candidate.bug) : 0, + levelOneCourse: hasLevelOneCourse.some( + (course) => course.tester_id === candidate.id + ), + levelTwoCourse: hasLevelTwoCourse.some( + (course) => course.tester_id === candidate.id + ), + highBugs: highBug ? Number(highBug.bug) : 0, + criticalBugs: criticalBug ? Number(criticalBug.bug) : 0, + }; + }); + + return; + } + + getCandidateData(candidate: { id: number }) { + const candidateData = this.candidateData.find( + (data) => data.id === candidate.id + ); + + if (!candidateData) return "No Level"; + + if ( + candidateData.campaigns >= 50 && + candidateData.highBugs + candidateData.criticalBugs >= 50 && + candidateData.criticalBugs >= 10 && + candidateData.levelOneCourse && + candidateData.levelTwoCourse + ) + return "Champion"; + if ( + candidateData.campaigns >= 30 && + candidateData.highBugs + candidateData.criticalBugs >= 20 && + candidateData.criticalBugs >= 5 && + candidateData.levelOneCourse && + candidateData.levelTwoCourse + ) + return "Expert"; + if ( + candidateData.campaigns >= 10 && + candidateData.highBugs + candidateData.criticalBugs >= 10 && + candidateData.levelOneCourse && + candidateData.levelTwoCourse + ) + return "Veteran"; + if ( + candidateData.campaigns >= 5 && + candidateData.bugs >= 10 && + candidateData.highBugs + candidateData.criticalBugs >= 5 && + candidateData.levelOneCourse + ) + return "Advanced"; + if ( + candidateData.campaigns >= 1 && + candidateData.bugs >= 2 && + candidateData.levelOneCourse + ) + return "Rookie"; + if (candidateData.campaigns >= 1) return "Newbie"; + return "No Level"; + } + + isCandidateFiltered(candidate: { id: number }): boolean { + if (!this.filters?.bughunting) return true; + + const data = this.getCandidateData(candidate); + + return this.filters.bughunting.includes(data); + } +} + +export { CandidateBhLevel }; diff --git a/src/routes/campaigns/campaignId/candidates/_get/CandidateLevels.ts b/src/routes/campaigns/campaignId/candidates/_get/CandidateLevels.ts deleted file mode 100644 index c7d2ab8c7..000000000 --- a/src/routes/campaigns/campaignId/candidates/_get/CandidateLevels.ts +++ /dev/null @@ -1,59 +0,0 @@ -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/CandidateProfile.ts b/src/routes/campaigns/campaignId/candidates/_get/CandidateProfile.ts index 835649338..45fc77d7f 100644 --- a/src/routes/campaigns/campaignId/candidates/_get/CandidateProfile.ts +++ b/src/routes/campaigns/campaignId/candidates/_get/CandidateProfile.ts @@ -1,26 +1,75 @@ +import { tryber } from "@src/features/database"; import { CandidateData } from "./iCandidateData"; +type Filters = { + id?: { include?: string[]; exclude?: string[] }; + gender?: StoplightComponents["schemas"]["Gender"][]; + age?: { min?: number; max?: number }; +}; + class CandidateProfile implements CandidateData { private candidateIds: number[]; - private filters?: { id?: { include?: string[]; exclude?: string[] } }; + private filters?: Filters; + + private _candidateData: + | { + id: number; + gender: StoplightComponents["schemas"]["Gender"]; + age: number; + }[] + | undefined; + + get candidateData() { + if (this._candidateData === undefined) + throw new Error("CandidateProfile not initialized"); + return this._candidateData; + } constructor({ candidateIds, filters, }: { candidateIds: number[]; - filters?: { id?: { include?: string[]; exclude?: string[] } }; + filters?: Filters; }) { this.candidateIds = candidateIds; this.filters = filters; } async init() { + const result = await tryber.tables.WpAppqEvdProfile.do() + .select("id", "sex", tryber.fn.charDate("birth_date")) + .whereIn("id", this.candidateIds); + this._candidateData = result.map((candidate) => { + const gender = + candidate.sex === 2 + ? "other" + : candidate.sex === 0 + ? "female" + : candidate.sex === 1 + ? "male" + : "not-specified"; + + return { + id: candidate.id, + gender, + age: + new Date().getFullYear() - + new Date(candidate.birth_date).getFullYear(), + }; + }); return; } getCandidateData(candidate: { id: number }) { - return undefined; + const data = this.candidateData.find( + (candidateData) => candidateData.id === candidate.id + ); + if (!data) throw new Error("Candidate not found"); + return { + gender: data.gender, + age: data.age, + }; } isCandidateFiltered(candidate: { id: number }): boolean { @@ -30,6 +79,19 @@ class CandidateProfile implements CandidateData { if (this.filters?.id?.exclude) { return !this.filters.id.exclude.includes(candidate.id.toString()); } + if (this.filters?.gender) { + const data = this.getCandidateData(candidate); + if (!data.gender) return false; + return this.filters.gender.includes(data.gender); + } + if (this.filters?.age) { + const data = this.getCandidateData(candidate); + if (!data) return false; + return ( + (!this.filters.age.min || data.age >= this.filters.age.min) && + (!this.filters.age.max || data.age <= this.filters.age.max) + ); + } return true; } } diff --git a/src/routes/campaigns/campaignId/candidates/_get/CandidateQuestions.ts b/src/routes/campaigns/campaignId/candidates/_get/CandidateQuestions.ts index f43912383..d591d7c05 100644 --- a/src/routes/campaigns/campaignId/candidates/_get/CandidateQuestions.ts +++ b/src/routes/campaigns/campaignId/candidates/_get/CandidateQuestions.ts @@ -1,9 +1,12 @@ import { tryber } from "@src/features/database"; import { CandidateData } from "./iCandidateData"; +type Filters = Record; + class CandidateQuestions implements CandidateData { private candidateIds: number[]; private questionIds: number[]; + private campaignId: number; private _questions: { id: number; @@ -21,15 +24,23 @@ class CandidateQuestions implements CandidateData { }[] | undefined = []; + private filters: Filters | undefined; + constructor({ + campaignId, candidateIds, questionIds, + filters, }: { + campaignId: number; candidateIds: number[]; questionIds: number[]; + filters?: Filters; }) { this.candidateIds = candidateIds; this.questionIds = questionIds; + this.campaignId = campaignId; + this.filters = filters; } get candidateQuestions() { @@ -43,8 +54,6 @@ class CandidateQuestions implements CandidateData { } async init() { - if (this.questionIds.length === 0) return; - this._candidateQuestions = await tryber.tables.WpAppqCampaignPreselectionFormFields.do() .join( @@ -61,14 +70,16 @@ class CandidateQuestions implements CandidateData { "short_name", "value" ) - .whereIn("tester_id", this.candidateIds) - .whereIn( - "wp_appq_campaign_preselection_form_fields.id", - this.questionIds - ); + .where("campaign_id", this.campaignId) + .whereIn("tester_id", this.candidateIds); this._questions = await tryber.tables.WpAppqCampaignPreselectionFormFields.do() + .join( + "wp_appq_campaign_preselection_form", + "wp_appq_campaign_preselection_form.id", + "wp_appq_campaign_preselection_form_fields.form_id" + ) .select( tryber .ref("id") @@ -76,28 +87,48 @@ class CandidateQuestions implements CandidateData { "question", "short_name" ) - .whereIn("id", this.questionIds); + .where("campaign_id", this.campaignId); 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(", ") - : "-", - }; - }); + getCandidateData( + candidate: { id: number }, + options: { showAllQuestions: boolean } = { showAllQuestions: false } + ) { + return this.questions + .filter( + (question) => + options.showAllQuestions || this.questionIds.includes(question.id) + ) + .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 { + if (!this.filters) return true; + const data = this.getCandidateData(candidate, { showAllQuestions: true }); + + for (const [questionId, value] of Object.entries(this.filters)) { + const question = data.find( + (question) => question.id === Number(questionId) + ); + if (!question) return false; + + if (!value.includes(question.value)) return false; + } + return true; } } diff --git a/src/routes/campaigns/campaignId/candidates/_get/Candidates.ts b/src/routes/campaigns/campaignId/candidates/_get/Candidates.ts index 01ef38580..9912cf937 100644 --- a/src/routes/campaigns/campaignId/candidates/_get/Candidates.ts +++ b/src/routes/campaigns/campaignId/candidates/_get/Candidates.ts @@ -16,8 +16,7 @@ class Candidates { .select( tryber.ref("id").withSchema("wp_appq_evd_profile"), "name", - "surname", - "total_exp_pts" + "surname" ) .where("campaign_id", this.campaign_id) .where("accepted", 0) diff --git a/src/routes/campaigns/campaignId/candidates/_get/bhleveldata.spec.ts b/src/routes/campaigns/campaignId/candidates/_get/bhleveldata.spec.ts new file mode 100644 index 000000000..c2f86a601 --- /dev/null +++ b/src/routes/campaigns/campaignId/candidates/_get/bhleveldata.spec.ts @@ -0,0 +1,388 @@ +import app from "@src/app"; +import { tryber } from "@src/features/database"; +import request from "supertest"; + +const bug = { + reviewer: 1, + last_editor_id: 1, + status_id: 2, +}; +const addNewbieRequirements = async ({ + wp_user_id, +}: { + wp_user_id: number; +}) => { + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: 2, + }); +}; + +const addRookieRequirements = async ({ + tester_id, + wp_user_id, +}: { + tester_id: number; + wp_user_id: number; +}) => { + await tryber.tables.WpAppqCourseTesterStatus.do().insert({ + tester_id, + course_id: 1, + is_completed: 1, + }); + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: 2, + }); + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: 2, + }); +}; + +const addAdvancedRequirements = async ({ + tester_id, + wp_user_id, +}: { + tester_id: number; + wp_user_id: number; +}) => { + await tryber.tables.WpAppqCourseTesterStatus.do().insert({ + tester_id, + course_id: 1, + is_completed: 1, + }); + for (const i in [1, 2, 3, 4, 5]) { + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: Number(i) + 1, + }); + } + for (const i in [1, 2]) { + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: Number(i) + 1, + severity_id: 3, + }); + } + for (const i in [1, 2, 3]) { + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: Number(i) + 1, + severity_id: 4, + }); + } +}; + +const addVeteranRequirements = async ({ + tester_id, + wp_user_id, +}: { + tester_id: number; + wp_user_id: number; +}) => { + await tryber.tables.WpAppqCourseTesterStatus.do().insert({ + tester_id, + course_id: 1, + is_completed: 1, + }); + await tryber.tables.WpAppqCourseTesterStatus.do().insert({ + tester_id, + course_id: 2, + is_completed: 1, + }); + for (const i in [...Array(10).keys()]) { + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: Number(i) + 1, + }); + } + for (const i in [...Array(5).keys()]) { + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: Number(i) + 1, + severity_id: 3, + }); + } + for (const i in [...Array(5).keys()]) { + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: Number(i) + 1, + severity_id: 4, + }); + } +}; + +const addExpertRequirements = async ({ + tester_id, + wp_user_id, +}: { + tester_id: number; + wp_user_id: number; +}) => { + await tryber.tables.WpAppqCourseTesterStatus.do().insert({ + tester_id, + course_id: 1, + is_completed: 1, + }); + await tryber.tables.WpAppqCourseTesterStatus.do().insert({ + tester_id, + course_id: 2, + is_completed: 1, + }); + for (const i in [...Array(30).keys()]) { + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: Number(i) + 1, + }); + } + for (const i in [...Array(15).keys()]) { + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: Number(i) + 1, + severity_id: 3, + }); + } + for (const i in [...Array(5).keys()]) { + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: Number(i) + 1, + severity_id: 4, + }); + } +}; +const addChampionRequirements = async ({ + tester_id, + wp_user_id, +}: { + tester_id: number; + wp_user_id: number; +}) => { + await tryber.tables.WpAppqCourseTesterStatus.do().insert({ + tester_id, + course_id: 1, + is_completed: 1, + }); + await tryber.tables.WpAppqCourseTesterStatus.do().insert({ + tester_id, + course_id: 2, + is_completed: 1, + }); + for (const i in [...Array(50).keys()]) { + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: Number(i) + 1, + }); + } + for (const i in [...Array(40).keys()]) { + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: Number(i) + 1, + severity_id: 3, + }); + } + for (const i in [...Array(10).keys()]) { + await tryber.tables.WpAppqEvdBug.do().insert({ + ...bug, + wp_user_id, + campaign_id: Number(i) + 1, + severity_id: 4, + }); + } +}; + +describe("GET /campaigns/:campaignId/candidates - questions ", () => { + beforeAll(async () => { + for (const i in [...Array(51).keys()]) { + await tryber.tables.WpAppqEvdCampaign.do().insert({ + id: Number(i + 1), + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + close_date: "2020-01-01", + title: "Campaign", + customer_title: "Customer", + page_manual_id: 1, + page_preview_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + }); + } + + const profile = { + email: "", + name: "pippo", + surname: "pluto", + education_id: 1, + employment_id: 1, + birth_date: "2000-01-01", + sex: 1, + }; + const candidate = { + accepted: 0, + devices: "0", + campaign_id: 1, + }; + const device = { + form_factor: "Smartphone", + manufacturer: "Apple", + model: "iPhone 11", + platform_id: 1, + os_version_id: 1, + enabled: 1, + }; + for (const i in [1, 2, 3, 4, 5, 6]) { + await tryber.tables.WpAppqEvdProfile.do().insert({ + ...profile, + id: Number(i) + 1, + wp_user_id: Number(i) + 1, + }); + await tryber.tables.WpCrowdAppqHasCandidate.do().insert({ + ...candidate, + user_id: Number(i) + 1, + }); + await tryber.tables.WpCrowdAppqDevice.do().insert({ + ...device, + id: Number(i) + 1, + id_profile: Number(i) + 1, + }); + } + + const course = { + display_name: "Course", + excerpt: "", + preview_content: "", + completed_content: "", + failed_content: "", + point_prize: 0, + time_length: 0, + }; + await tryber.tables.WpAppqCourse.do().insert([ + { + ...course, + id: 1, + course_level: "1", + career: "Functional", + }, + { + ...course, + id: 2, + course_level: "2", + career: "General", + }, + ]); + + await addNewbieRequirements({ wp_user_id: 1 }); + await addRookieRequirements({ tester_id: 2, wp_user_id: 2 }); + await addAdvancedRequirements({ tester_id: 3, wp_user_id: 3 }); + await addVeteranRequirements({ tester_id: 4, wp_user_id: 4 }); + await addExpertRequirements({ tester_id: 5, wp_user_id: 5 }); + await addChampionRequirements({ tester_id: 6, wp_user_id: 6 }); + + await tryber.tables.WpAppqEvdPlatform.do().insert({ + id: 1, + name: "iOS", + architecture: 1, + }); + await tryber.tables.WpAppqOs.do().insert({ + id: 1, + display_name: "13.3.1", + platform_id: 1, + main_release: 1, + version_family: 1, + version_number: "1", + }); + }); + afterAll(async () => { + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + await tryber.tables.WpCrowdAppqHasCandidate.do().delete(); + await tryber.tables.WpCrowdAppqDevice.do().delete(); + await tryber.tables.WpAppqEvdPlatform.do().delete(); + await tryber.tables.WpAppqOs.do().delete(); + await tryber.tables.WpAppqCampaignPreselectionForm.do().delete(); + await tryber.tables.WpAppqCampaignPreselectionFormFields.do().delete(); + }); + + it("Should return bug hunting level", 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).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 1, + levels: { + bugHunting: "Newbie", + }, + }), + expect.objectContaining({ + id: 2, + levels: { + bugHunting: "Rookie", + }, + }), + expect.objectContaining({ + id: 3, + levels: { + bugHunting: "Advanced", + }, + }), + expect.objectContaining({ + id: 4, + levels: { + bugHunting: "Veteran", + }, + }), + expect.objectContaining({ + id: 5, + levels: { + bugHunting: "Expert", + }, + }), + expect.objectContaining({ + id: 6, + levels: { + bugHunting: "Champion", + }, + }), + ]) + ); + }); + + it("Should allow filtering by bug hunting level", async () => { + const response = await request(app) + .get("/campaigns/1/candidates?filterByInclude[bughunting]=Newbie") + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(response.body).toHaveProperty("results"); + expect(response.body.results).toHaveLength(1); + expect(response.body.results[0].id).toBe(1); + }); + it("Should allow filtering by multiple bug hunting level", async () => { + const response = await request(app) + .get( + "/campaigns/1/candidates?filterByInclude[bughunting][]=Newbie&filterByInclude[bughunting][]=Rookie" + ) + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(response.body).toHaveProperty("results"); + expect(response.body.results).toHaveLength(2); + expect(response.body.results[0].id).toBe(2); + expect(response.body.results[1].id).toBe(1); + }); +}); diff --git a/src/routes/campaigns/campaignId/candidates/_get/index.spec.ts b/src/routes/campaigns/campaignId/candidates/_get/index.spec.ts index 10b5c2585..f0e107a4b 100644 --- a/src/routes/campaigns/campaignId/candidates/_get/index.spec.ts +++ b/src/routes/campaigns/campaignId/candidates/_get/index.spec.ts @@ -284,42 +284,49 @@ describe("GET /campaigns/:campaignId/candidates ", () => { tester_id: users[2].testerId, field_id: 1, value: "Value 1", + campaign_id: 1, }); await preselectionFormData.insert({ id: 2, tester_id: users[3].testerId, field_id: 1, value: "Value 2", + campaign_id: 1, }); await preselectionFormData.insert({ id: 3, tester_id: users[4].testerId, field_id: 1, value: "Value 3", + campaign_id: 1, }); await preselectionFormData.insert({ id: 4, tester_id: users[2].testerId, field_id: 2, value: "Value 4", + campaign_id: 1, }); await preselectionFormData.insert({ id: 5, tester_id: users[3].testerId, field_id: 2, value: "Value 5", + campaign_id: 1, }); await preselectionFormData.insert({ id: 6, tester_id: users[4].testerId, field_id: 2, value: "Value 6", + campaign_id: 1, }); await preselectionFormData.insert({ id: 7, tester_id: users[4].testerId, field_id: 3, value: "Value Invalid", + campaign_id: 5, }); await preselectionFormData.insert({ @@ -327,6 +334,7 @@ describe("GET /campaigns/:campaignId/candidates ", () => { tester_id: users[2].testerId, field_id: 1, value: "Value 8", + campaign_id: 1, }); }); afterAll(async () => { @@ -407,47 +415,6 @@ describe("GET /campaigns/:campaignId/candidates ", () => { ]) ); }); - it("should answer a list of experience points ", 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).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - experience: 100, - }), - expect.objectContaining({ - experience: 1000, - }), - expect.objectContaining({ - experience: 2, - }), - ]) - ); - }); - - it("should answer a list of levels ", 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).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - level: "Bronze", - }), - expect.objectContaining({ - level: "Silver", - }), - expect.objectContaining({ - level: "Gold", - }), - ]) - ); - }); it("should answer a list of devices ", async () => { const response = await request(app) diff --git a/src/routes/campaigns/campaignId/candidates/_get/index.ts b/src/routes/campaigns/campaignId/candidates/_get/index.ts index 0efc1be57..184a77d0c 100644 --- a/src/routes/campaigns/campaignId/candidates/_get/index.ts +++ b/src/routes/campaigns/campaignId/candidates/_get/index.ts @@ -3,15 +3,18 @@ import OpenapiError from "@src/features/OpenapiError"; import { tryber } from "@src/features/database"; import UserRoute from "@src/features/routes/UserRoute"; +import { CandidateBhLevel } from "./CandidateBhLevel"; 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; testerIds?: string[] | string } - | undefined; +type filterByItem = string | string[]; +type filterBy = Record< + "os" | "testerIds" | "gender" | "bughunting", + filterByItem | 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"]; @@ -29,6 +32,10 @@ export default class RouteItem extends UserRoute<{ include?: number[]; exclude?: number[]; }; + gender?: StoplightComponents["schemas"]["Gender"][]; + age?: { min?: number; max?: number }; + questions?: Record; + bughunting?: string[]; } | undefined; @@ -54,6 +61,10 @@ export default class RouteItem extends UserRoute<{ } this.filters = { ...this.filters, ...this.getOsFilter() }; + this.filters = { ...this.filters, ...this.getBughuntingFilter() }; + this.filters = { ...this.filters, ...this.getQuestionsFilter() }; + this.filters = { ...this.filters, ...this.getGenderFilter() }; + this.filters = { ...this.filters, ...this.getAgeFilters() }; this.filters = { ...this.filters, ids: { ...this?.filters.ids, exclude: this.getExcludeIds() }, @@ -113,6 +124,73 @@ export default class RouteItem extends UserRoute<{ }; } + private getBughuntingFilter() { + const query = this.getQuery(); + const filterByInclude = query.filterByInclude as filterBy; + + if (!filterByInclude) return {}; + if ("bughunting" in filterByInclude === false) return {}; + if (filterByInclude.bughunting === undefined) return {}; + + return { + bughunting: Array.isArray(filterByInclude.bughunting) + ? filterByInclude.bughunting + : [filterByInclude.bughunting], + }; + } + + private getQuestionsFilter() { + const query = this.getQuery(); + const filterByInclude = query.filterByInclude as filterBy; + + if (!filterByInclude) return {}; + + const questionFilters = Object.entries(filterByInclude).filter(([key]) => + key.startsWith("question_") + ); + if (questionFilters.length === 0) return {}; + + const filters = questionFilters.reduce((acc, [key, value]) => { + const questionId = parseInt(key.replace("question_", "")); + return { ...acc, [questionId]: value }; + }, {}); + + return { questions: filters }; + } + + private getAgeFilters() { + const query = this.getQuery(); + const filterByAge = query.filterByAge as { min?: string; max?: string }; + + if (!filterByAge) return {}; + if (filterByAge.min === undefined && filterByAge.max === undefined) + return {}; + + return { + age: { + min: filterByAge.min ? parseInt(filterByAge.min) : undefined, + max: filterByAge.max ? parseInt(filterByAge.max) : undefined, + }, + }; + } + + private getGenderFilter() { + const query = this.getQuery(); + const filterByInclude = query.filterByInclude as filterBy; + + if (!filterByInclude) return {}; + if ("gender" in filterByInclude === false) return {}; + if (filterByInclude.gender === undefined) return {}; + + const gender = Array.isArray(filterByInclude.gender) + ? filterByInclude.gender + : [filterByInclude.gender]; + + return { + gender: gender as StoplightComponents["schemas"]["Gender"][], + }; + } + protected async filter() { if (this.hasAccessTesterSelection(this.campaign_id) === false) { this.setError(403, new OpenapiError("You are not authorized.")); @@ -177,10 +255,11 @@ export default class RouteItem extends UserRoute<{ id: candidate.id, name: candidate.name, surname: candidate.surname, - experience: candidate.total_exp_pts, - level: candidate.level, devices: candidate.devices, + gender: candidate.gender, + age: candidate.age, questions: candidate.questions, + levels: candidate.levels, }; }), size: candidates.length, @@ -202,14 +281,11 @@ export default class RouteItem extends UserRoute<{ }); await deviceGetter.init(); - const levelGetter = new CandidateLevels({ - candidateIds: candidates.map((candidate) => candidate.id), - }); - await levelGetter.init(); - const questionGetter = new CandidateQuestions({ + campaignId: this.campaign_id, candidateIds: candidates.map((candidate) => candidate.id), questionIds: this.fields.map((field) => field.id), + ...(this.filters?.questions && { filters: this.filters?.questions }), }); await questionGetter.init(); @@ -220,25 +296,38 @@ export default class RouteItem extends UserRoute<{ include: this.filters?.ids?.include?.map((id) => id.toString()), exclude: this.filters?.ids?.exclude?.map((id) => id.toString()), }, + gender: this.filters?.gender, + age: this.filters?.age, }, }); await profileGetter.init(); + const bhLevelGetter = new CandidateBhLevel({ + candidateIds: candidates.map((candidate) => candidate.id), + ...(this.filters?.bughunting && { + filters: { bughunting: this.filters?.bughunting }, + }), + }); + await bhLevelGetter.init(); + const result = candidates .map((candidate) => { return { ...candidate, - level: levelGetter.getCandidateData(candidate), devices: deviceGetter.getCandidateData(candidate), questions: questionGetter.getCandidateData(candidate), + ...profileGetter.getCandidateData(candidate), + levels: { + bugHunting: bhLevelGetter.getCandidateData(candidate), + }, }; }) .filter( (candidate) => deviceGetter.isCandidateFiltered(candidate) && questionGetter.isCandidateFiltered(candidate) && - levelGetter.isCandidateFiltered(candidate) && - profileGetter.isCandidateFiltered(candidate) + profileGetter.isCandidateFiltered(candidate) && + bhLevelGetter.isCandidateFiltered(candidate) ); return { diff --git a/src/routes/campaigns/campaignId/candidates/_get/profiledata.spec.ts b/src/routes/campaigns/campaignId/candidates/_get/profiledata.spec.ts new file mode 100644 index 000000000..069ca6120 --- /dev/null +++ b/src/routes/campaigns/campaignId/candidates/_get/profiledata.spec.ts @@ -0,0 +1,257 @@ +import app from "@src/app"; +import { tryber } from "@src/features/database"; +import request from "supertest"; + +describe("GET /campaigns/:campaignId/candidates - profile ", () => { + beforeAll(async () => { + jest.useFakeTimers().setSystemTime(new Date("2020-01-01")); + await tryber.tables.WpAppqEvdCampaign.do().insert({ + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + close_date: "2020-01-01", + title: "Campaign", + customer_title: "Customer", + page_manual_id: 1, + page_preview_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + }); + const profile = { + email: "", + name: "pippo", + surname: "pluto", + education_id: 1, + employment_id: 1, + }; + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + ...profile, + id: 1, + wp_user_id: 1, + birth_date: "2000-01-01", + sex: 1, + }, + { + ...profile, + id: 2, + wp_user_id: 2, + birth_date: "1999-01-01", + sex: 0, + }, + { + ...profile, + id: 3, + wp_user_id: 3, + birth_date: "1998-01-01", + sex: -1, + }, + { + ...profile, + id: 4, + wp_user_id: 4, + birth_date: "1997-01-01", + sex: 2, + }, + ]); + + const candidate = { + accepted: 0, + devices: "0", + campaign_id: 1, + }; + await tryber.tables.WpCrowdAppqHasCandidate.do().insert([ + { + ...candidate, + user_id: 1, + }, + { + ...candidate, + user_id: 2, + }, + { + ...candidate, + user_id: 3, + }, + { + ...candidate, + user_id: 4, + }, + ]); + + const device = { + form_factor: "Smartphone", + manufacturer: "Apple", + model: "iPhone 11", + platform_id: 1, + os_version_id: 1, + enabled: 1, + }; + await tryber.tables.WpCrowdAppqDevice.do().insert([ + { + ...device, + id: 1, + id_profile: 1, + }, + { + ...device, + id: 2, + id_profile: 2, + }, + { + ...device, + id: 3, + id_profile: 3, + }, + { + ...device, + id: 4, + id_profile: 4, + }, + ]); + + await tryber.tables.WpAppqEvdPlatform.do().insert({ + id: 1, + name: "iOS", + architecture: 1, + }); + await tryber.tables.WpAppqOs.do().insert({ + id: 1, + display_name: "13.3.1", + platform_id: 1, + main_release: 1, + version_family: 1, + version_number: "1", + }); + }); + afterAll(async () => { + jest.useRealTimers(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + await tryber.tables.WpCrowdAppqHasCandidate.do().delete(); + await tryber.tables.WpCrowdAppqDevice.do().delete(); + await tryber.tables.WpAppqEvdPlatform.do().delete(); + await tryber.tables.WpAppqOs.do().delete(); + }); + + it("Should aswer with the genders", 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).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 1, + gender: "male", + }), + expect.objectContaining({ + id: 2, + gender: "female", + }), + expect.objectContaining({ + id: 3, + gender: "not-specified", + }), + expect.objectContaining({ + id: 4, + gender: "other", + }), + ]) + ); + }); + + it("Should allow filtering by gender", async () => { + const responseMale = await request(app) + .get("/campaigns/1/candidates?filterByInclude[gender]=male") + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(responseMale.body).toHaveProperty("results"); + expect(responseMale.body.results).toHaveLength(1); + expect(responseMale.body.results[0]).toHaveProperty("id", 1); + const responseFemale = await request(app) + .get("/campaigns/1/candidates?filterByInclude[gender]=female") + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(responseFemale.body).toHaveProperty("results"); + expect(responseFemale.body.results).toHaveLength(1); + expect(responseFemale.body.results[0]).toHaveProperty("id", 2); + const responseOther = await request(app) + .get("/campaigns/1/candidates?filterByInclude[gender]=other") + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(responseOther.body).toHaveProperty("results"); + expect(responseOther.body.results).toHaveLength(1); + expect(responseOther.body.results[0]).toHaveProperty("id", 4); + const responseNotspec = await request(app) + .get("/campaigns/1/candidates?filterByInclude[gender]=not-specified") + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(responseNotspec.body).toHaveProperty("results"); + expect(responseNotspec.body.results).toHaveLength(1); + expect(responseNotspec.body.results[0]).toHaveProperty("id", 3); + }); + + it("Should allow filtering by multiple genders", async () => { + const responseMale = await request(app) + .get( + "/campaigns/1/candidates?filterByInclude[gender][]=male&filterByInclude[gender][]=female" + ) + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(responseMale.body).toHaveProperty("results"); + expect(responseMale.body.results).toHaveLength(2); + expect(responseMale.body.results[0]).toHaveProperty("id", 2); + expect(responseMale.body.results[1]).toHaveProperty("id", 1); + }); + + it("Should answer with the ages", 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).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 1, + age: 20, + }), + expect.objectContaining({ + id: 2, + age: 21, + }), + expect.objectContaining({ + id: 3, + age: 22, + }), + expect.objectContaining({ + id: 4, + age: 23, + }), + ]) + ); + }); + + it("Should allow filtering by minimum age", async () => { + const response = await request(app) + .get("/campaigns/1/candidates?filterByAge[min]=23") + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(response.body).toHaveProperty("results"); + expect(response.body.results).toHaveLength(1); + expect(response.body.results[0]).toHaveProperty("id", 4); + }); + it("Should allow filtering by maximum age", async () => { + const response = await request(app) + .get("/campaigns/1/candidates?filterByAge[max]=20") + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(response.body).toHaveProperty("results"); + expect(response.body.results).toHaveLength(1); + expect(response.body.results[0]).toHaveProperty("id", 1); + }); + it("Should allow filtering by age range", async () => { + const response = await request(app) + .get("/campaigns/1/candidates?filterByAge[max]=22&filterByAge[min]=21") + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(response.body).toHaveProperty("results"); + expect(response.body.results).toHaveLength(2); + expect(response.body.results[0]).toHaveProperty("id", 3); + expect(response.body.results[1]).toHaveProperty("id", 2); + }); +}); diff --git a/src/routes/campaigns/campaignId/candidates/_get/questiondata.spec.ts b/src/routes/campaigns/campaignId/candidates/_get/questiondata.spec.ts new file mode 100644 index 000000000..86ef80ad0 --- /dev/null +++ b/src/routes/campaigns/campaignId/candidates/_get/questiondata.spec.ts @@ -0,0 +1,177 @@ +import app from "@src/app"; +import { tryber } from "@src/features/database"; +import request from "supertest"; + +describe("GET /campaigns/:campaignId/candidates - questions ", () => { + beforeAll(async () => { + await tryber.tables.WpAppqEvdCampaign.do().insert({ + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + close_date: "2020-01-01", + title: "Campaign", + customer_title: "Customer", + page_manual_id: 1, + page_preview_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + }); + const profile = { + email: "", + name: "pippo", + surname: "pluto", + education_id: 1, + employment_id: 1, + birth_date: "2000-01-01", + }; + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + ...profile, + id: 1, + wp_user_id: 1, + sex: 1, + }, + { + ...profile, + id: 2, + wp_user_id: 2, + sex: 0, + }, + { + ...profile, + id: 3, + wp_user_id: 3, + sex: 0, + }, + ]); + + const candidate = { + accepted: 0, + devices: "0", + campaign_id: 1, + }; + await tryber.tables.WpCrowdAppqHasCandidate.do().insert([ + { + ...candidate, + user_id: 1, + }, + { + ...candidate, + user_id: 2, + }, + { + ...candidate, + user_id: 3, + }, + ]); + + const device = { + form_factor: "Smartphone", + manufacturer: "Apple", + model: "iPhone 11", + platform_id: 1, + os_version_id: 1, + enabled: 1, + }; + await tryber.tables.WpCrowdAppqDevice.do().insert([ + { + ...device, + id: 1, + id_profile: 1, + }, + { + ...device, + id: 2, + id_profile: 2, + }, + { + ...device, + id: 3, + id_profile: 3, + }, + ]); + + await tryber.tables.WpAppqEvdPlatform.do().insert({ + id: 1, + name: "iOS", + architecture: 1, + }); + await tryber.tables.WpAppqOs.do().insert({ + id: 1, + display_name: "13.3.1", + platform_id: 1, + main_release: 1, + version_family: 1, + version_number: "1", + }); + + await tryber.tables.WpAppqCampaignPreselectionForm.do().insert({ + id: 1, + campaign_id: 1, + }); + await tryber.tables.WpAppqCampaignPreselectionFormFields.do().insert({ + id: 1, + form_id: 1, + question: "Text question", + type: "text", + }); + + await tryber.tables.WpAppqCampaignPreselectionFormData.do().insert([ + { + id: 1, + tester_id: 1, + value: "value1", + field_id: 1, + campaign_id: 1, + }, + { + id: 2, + tester_id: 2, + value: "value2", + field_id: 1, + campaign_id: 1, + }, + { + id: 3, + tester_id: 3, + value: "another", + field_id: 1, + campaign_id: 1, + }, + ]); + }); + afterAll(async () => { + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + await tryber.tables.WpCrowdAppqHasCandidate.do().delete(); + await tryber.tables.WpCrowdAppqDevice.do().delete(); + await tryber.tables.WpAppqEvdPlatform.do().delete(); + await tryber.tables.WpAppqOs.do().delete(); + await tryber.tables.WpAppqCampaignPreselectionForm.do().delete(); + await tryber.tables.WpAppqCampaignPreselectionFormFields.do().delete(); + }); + + it("Should allow filtering by question", async () => { + const response = await request(app) + .get("/campaigns/1/candidates?filterByInclude[question_1]=value1") + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(response.body).toHaveProperty("results"); + expect(response.body.results).toHaveLength(1); + expect(response.body.results[0]).toMatchObject({ + id: 1, + }); + }); + it("Should allow filtering by multiple question", async () => { + const response = await request(app) + .get( + "/campaigns/1/candidates?filterByInclude[question_1][]=value1&filterByInclude[question_1][]=value2" + ) + .set("authorization", `Bearer tester olp {"appq_tester_selection":true}`); + expect(response.body).toHaveProperty("results"); + expect(response.body.results).toHaveLength(2); + expect(response.body.results[0].id).toBe(2); + expect(response.body.results[1].id).toBe(1); + }); +}); diff --git a/src/schema.ts b/src/schema.ts index ffb241998..a674218f5 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1327,6 +1327,8 @@ export interface operations { filterByInclude?: unknown; /** Key-value Array for item filtering */ filterByExclude?: unknown; + /** Array with min and max */ + filterByAge?: unknown; }; }; responses: { @@ -1338,8 +1340,11 @@ export interface operations { id: number; name: string; surname: string; - experience: number; - level: string; + gender: components["schemas"]["Gender"]; + age: number; + levels: { + bugHunting: string; + }; devices: { manufacturer?: string; model?: string;