diff --git a/components/gem/actions/create-candidate/create-candidate.mjs b/components/gem/actions/create-candidate/create-candidate.mjs new file mode 100644 index 0000000000000..90fd90b743a8f --- /dev/null +++ b/components/gem/actions/create-candidate/create-candidate.mjs @@ -0,0 +1,296 @@ +import { SOURCED_FROM } from "../../common/constants.mjs"; +import { parseObject } from "../../common/utils.mjs"; +import gem from "../../gem.app.mjs"; + +export default { + key: "gem-create-candidate", + name: "Create Candidate", + description: "Creates a new candidate in Gem. [See the documentation](https://api.gem.com/v0/reference#tag/Candidates/paths/~1v0~1candidates/post)", + version: "0.0.1", + type: "action", + props: { + gem, + createdBy: { + propDefinition: [ + gem, + "createdBy", + ], + }, + firstName: { + type: "string", + label: "First Name", + description: "Candidate's first name", + optional: true, + }, + lastName: { + type: "string", + label: "Last Name", + description: "Candidate's last name", + optional: true, + }, + nickname: { + type: "string", + label: "Nickname", + description: "Candidate's nickname", + optional: true, + }, + primaryEmail: { + type: "string", + label: "Primary Email Address", + description: "Candidate's primary email address", + }, + additionalEmails: { + type: "string[]", + label: "Email Addresses", + description: "List of candidate's additional email addresses", + optional: true, + }, + linkedInHandle: { + type: "string", + label: "LinkedIn Handle", + description: "Enter your candidate's unique LinkedIn identifier (e.g., \"satyanadella\"). This helps the system check for duplicates before creating a new candidate entry.", + optional: true, + }, + title: { + type: "string", + label: "Title", + description: "Candidate's job title", + optional: true, + }, + company: { + type: "string", + label: "Company", + description: "Candidate's company name", + optional: true, + }, + location: { + type: "string", + label: "Location", + description: "Candidate's location", + optional: true, + }, + school: { + type: "string", + label: "School", + description: "Candidate's school", + optional: true, + }, + educationInfoNumber: { + type: "integer", + label: "Education Info Quantity", + description: "The number of education info objects to be created", + reloadProps: true, + optional: true, + }, + workInfoNumber: { + type: "integer", + label: "Work Info Quantity", + description: "The number of work info objects to be created", + reloadProps: true, + optional: true, + }, + profileUrls: { + type: "string[]", + label: "Profile URLs", + description: "If `Profile URLs` is provided with an array of urls, social `profiles` will be generated based on the provided urls and attached to the candidate", + optional: true, + }, + phoneNumber: { + type: "string", + label: "Phone Number", + description: "Candidate's phone number", + optional: true, + }, + projectIds: { + propDefinition: [ + gem, + "projectIds", + ], + optional: true, + }, + customFields: { + type: "object", + label: "Custom Fields", + description: "An object containing new custom field values. Only custom fields specified are updated. **Format: {\"custom_field_id\": \"value\"}**. [See the documentation](https://api.gem.com/v0/reference#tag/Candidates/paths/~1v0~1candidates/post) for further details.", + optional: true, + }, + sourcedFrom: { + type: "string", + label: "Sourced From", + description: "Where the candidate was sourced from", + options: SOURCED_FROM, + optional: true, + }, + autofill: { + type: "boolean", + label: "Autofill", + description: "Requires `Linked In Handle` to be non-null. Attempts to fill in any missing fields.", + optional: true, + }, + }, + async additionalProps() { + const props = {}; + if (this.educationInfoNumber) { + for (let i = 1; i <= this.educationInfoNumber; i++) { + props[`educationInfo${i}School`] = { + type: "string", + label: `Education Info ${i} - School`, + description: `Education info ${i} school of the candidate`, + optional: true, + }; + props[`educationInfo${i}University`] = { + type: "string", + label: `Education Info ${i} - University`, + description: `Education info ${i} university of the candidate`, + optional: true, + }; + props[`educationInfo${i}StartDate`] = { + type: "string", + label: `Education Info ${i} - Start Date`, + description: `Education info ${i} start date of the candidate`, + optional: true, + }; + props[`educationInfo${i}EndDate`] = { + type: "string", + label: `Education Info ${i} - End Date`, + description: `Education info ${i} end date of the candidate`, + optional: true, + }; + props[`educationInfo${i}FieldOfSchool`] = { + type: "string", + label: `Education Info ${i} - Field Of School`, + description: `Education info ${i} field of school of the candidate`, + optional: true, + }; + props[`educationInfo${i}Major1`] = { + type: "string", + label: `Education Info ${i} - Major 1`, + description: `Education info ${i} major 1 of the candidate`, + optional: true, + }; + props[`educationInfo${i}Major2`] = { + type: "string", + label: `Education Info ${i} - Major 2`, + description: `Education info ${i} major 2 of the candidate`, + optional: true, + }; + props[`educationInfo${i}Degree`] = { + type: "string", + label: `Education Info ${i} - Degree`, + description: `Education info ${i} degree of the candidate`, + optional: true, + }; + } + } + if (this.workInfoNumber) { + for (let i = 1; i <= this.workInfoNumber; i++) { + props[`WorkInfo${i}Company`] = { + type: "string", + label: `Work Info ${i} - Company`, + description: `Work info ${i} company of the candidate`, + optional: true, + }; + props[`WorkInfo${i}Title`] = { + type: "string", + label: `Work Info ${i} - Title`, + description: `Work info ${i} title of the candidate`, + optional: true, + }; + props[`WorkInfo${i}WorkStartDate`] = { + type: "string", + label: `Work Info ${i} - Work Start Date`, + description: `Work info ${i} work start date of the candidate`, + optional: true, + }; + props[`WorkInfo${i}WorkEndDate`] = { + type: "string", + label: `Work Info ${i} - Work End Date`, + description: `Work info ${i} work end date of the candidate`, + optional: true, + }; + props[`WorkInfo${i}IsCurrent`] = { + type: "boolean", + label: `Work Info ${i} - Is Current`, + description: `Work info ${i} is Current of the candidate`, + optional: true, + }; + } + } + + return props; + }, + async run({ $ }) { + const educationInfo = []; + const workInfo = []; + for (var i = 1; i <= this.educationInfoNumber; i++) { + educationInfo.push({ + school: this[`educationInfo${i}School`], + parsed_university: this[`educationInfo${i}University`], + start_date: this[`educationInfo${i}StartDate`], + end_date: this[`educationInfo${i}EndDate`], + field_of_study: this[`educationInfo${i}FieldOfSchool`], + parsed_major_1: this[`educationInfo${i}Major1`], + parsed_major_2: this[`educationInfo${i}Major2`], + degree: this[`educationInfo${i}Degree`], + }); + } + + for (var j = 1; j <= this.workInfoNumber; j++) { + workInfo.push({ + company: this[`WorkInfo${j}Company`], + title: this[`WorkInfo${j}Title`], + work_start_date: this[`WorkInfo${j}WorkStartDate`], + work_end_date: this[`WorkInfo${j}WorkEndDate`], + is_current: this[`WorkInfo${j}IsCurrent`], + }); + } + + const emails = [ + { + email_address: this.primaryEmail, + is_primary: true, + }, + ]; + if (this.additionalEmails) emails.push(...parseObject(this.additionalEmails).map((email) => ({ + email_address: email, + is_primary: false, + }))); + + if (emails.length === 0) { + throw new Error("Primary Email Address is required"); + } + const candidate = await this.gem.createCandidate({ + $, + data: { + created_by: this.createdBy, + first_name: this.firstName, + last_name: this.lastName, + nickname: this.nickname, + emails, + linked_in_handle: this.linkedInHandle, + title: this.title, + company: this.company, + location: this.location, + school: this.school, + education_info: educationInfo, + work_info: workInfo, + profile_urls: parseObject(this.profileUrls), + phone_number: this.phoneNumber, + project_ids: parseObject(this.projectIds), + custom_fields: Object.entries(parseObject(this.customFields))?.map(([ + key, + value, + ]) => ({ + custom_field_id: key, + value, + })), + sourced_from: this.sourcedFrom, + autofill: this.autofill, + }, + }); + $.export( + "$summary", `Created candidate ${candidate.first_name} ${candidate.last_name} with ID: ${candidate.id}`, + ); + return candidate; + }, +}; diff --git a/components/gem/common/constants.mjs b/components/gem/common/constants.mjs new file mode 100644 index 0000000000000..7859117aa62b4 --- /dev/null +++ b/components/gem/common/constants.mjs @@ -0,0 +1,9 @@ +export const LIMIT = 100; + +export const SOURCED_FROM = [ + "SeekOut", + "hireEZ", + "Starcircle", + "Censia", + "Consider", +]; diff --git a/components/gem/common/utils.mjs b/components/gem/common/utils.mjs new file mode 100644 index 0000000000000..889b5b5a3a625 --- /dev/null +++ b/components/gem/common/utils.mjs @@ -0,0 +1,24 @@ +export const parseObject = (obj) => { + if (!obj) return []; + + if (Array.isArray(obj)) { + return obj.map((item) => { + if (typeof item === "string") { + try { + return JSON.parse(item); + } catch (e) { + return item; + } + } + return item; + }); + } + if (typeof obj === "string") { + try { + return JSON.parse(obj); + } catch (e) { + return obj; + } + } + return obj; +}; diff --git a/components/gem/gem.app.mjs b/components/gem/gem.app.mjs index c48916e8ad5ef..12dcae9a1370b 100644 --- a/components/gem/gem.app.mjs +++ b/components/gem/gem.app.mjs @@ -1,11 +1,120 @@ +import { axios } from "@pipedream/platform"; +import { LIMIT } from "./common/constants.mjs"; + export default { type: "app", app: "gem", - propDefinitions: {}, + propDefinitions: { + createdBy: { + type: "string", + label: "Created By", + description: "Who the candidate was created by", + async options({ page }) { + const data = await this.listUsers({ + params: { + page: page + 1, + page_size: LIMIT, + }, + }); + + return data.map(({ + id: value, email: label, + }) => ({ + label, + value, + })); + }, + }, + projectIds: { + type: "string[]", + label: "Project IDs", + description: "If `Project IDs` is provided with an array of project ids, the candidate will be added into the projects once they are created.", + async options({ page }) { + const data = await this.listProjects({ + params: { + page: page + 1, + page_size: LIMIT, + }, + }); + + return data.map(({ + id: value, name: label, + }) => ({ + label, + value, + })); + }, + }, + }, methods: { - // this.$auth contains connected account data - authKeys() { - console.log(Object.keys(this.$auth)); + _baseUrl() { + return "https://api.gem.com/v0"; + }, + _headers() { + return { + "x-api-key": `${this.$auth.api_key}`, + "content-type": "application/json", + }; + }, + _makeRequest({ + $ = this, path, ...opts + }) { + return axios($, { + url: this._baseUrl() + path, + headers: this._headers(), + ...opts, + }); + }, + listCandidates(opts = {}) { + return this._makeRequest({ + path: "/candidates", + ...opts, + }); + }, + listProjects(opts = {}) { + return this._makeRequest({ + path: "/projects", + ...opts, + }); + }, + listUsers(opts = {}) { + return this._makeRequest({ + path: "/users", + ...opts, + }); + }, + createCandidate(opts = {}) { + return this._makeRequest({ + method: "POST", + path: "/candidates", + ...opts, + }); + }, + async *paginate({ + fn, params = {}, maxResults = null, ...opts + }) { + let hasMore = false; + let count = 0; + let page = 0; + + do { + params.page = ++page; + params.page_size = LIMIT; + const data = await fn({ + params, + ...opts, + }); + for (const d of data) { + yield d; + + if (maxResults && ++count === maxResults) { + return count; + } + } + + hasMore = data.length === LIMIT; + + } while (hasMore); }, }, -}; \ No newline at end of file +}; diff --git a/components/gem/package.json b/components/gem/package.json index 7a97537822dd4..3dc22cd7541f4 100644 --- a/components/gem/package.json +++ b/components/gem/package.json @@ -1,6 +1,6 @@ { "name": "@pipedream/gem", - "version": "0.0.1", + "version": "0.1.0", "description": "Pipedream Gem Components", "main": "gem.app.mjs", "keywords": [ @@ -11,5 +11,8 @@ "author": "Pipedream (https://pipedream.com/)", "publishConfig": { "access": "public" + }, + "dependencies": { + "@pipedream/platform": "^3.0.3" } -} \ No newline at end of file +} diff --git a/components/gem/sources/new-candidate/new-candidate.mjs b/components/gem/sources/new-candidate/new-candidate.mjs new file mode 100644 index 0000000000000..a65b0744b9323 --- /dev/null +++ b/components/gem/sources/new-candidate/new-candidate.mjs @@ -0,0 +1,69 @@ +import { DEFAULT_POLLING_SOURCE_TIMER_INTERVAL } from "@pipedream/platform"; +import gem from "../../gem.app.mjs"; +import sampleEmit from "./test-event.mjs"; + +export default { + key: "gem-new-candidate", + name: "New Candidate Added", + description: "Emit new event when a candidate is added in Gem. [See the documentation](https://api.gem.com/v0/reference#tag/Candidates/paths/~1v0~1candidates/get)", + version: "0.0.1", + type: "source", + dedupe: "unique", + props: { + gem, + db: "$.service.db", + timer: { + type: "$.interface.timer", + default: { + intervalSeconds: DEFAULT_POLLING_SOURCE_TIMER_INTERVAL, + }, + }, + }, + methods: { + _getLastDate() { + return this.db.get("lastDate") || 1; + }, + _setLastDate(lastDate) { + this.db.set("lastDate", lastDate); + }, + async emitEvent(maxResults = false) { + const lastDate = this._getLastDate(); + + const response = this.gem.paginate({ + fn: this.gem.listCandidates, + params: { + created_after: lastDate, + }, + }); + + let responseArray = []; + for await (const item of response) { + responseArray.push(item); + } + + if (responseArray.length) { + if (maxResults && (responseArray.length > maxResults)) { + responseArray.length = maxResults; + } + this._setLastDate(responseArray[0].created_at); + } + + for (const item of responseArray.reverse()) { + this.$emit(item, { + id: item.id, + summary: `New Candidate with ID: ${item.id}`, + ts: item.created_at, + }); + } + }, + }, + hooks: { + async deploy() { + await this.emitEvent(25); + }, + }, + async run() { + await this.emitEvent(); + }, + sampleEmit, +}; diff --git a/components/gem/sources/new-candidate/test-event.mjs b/components/gem/sources/new-candidate/test-event.mjs new file mode 100644 index 0000000000000..bf438d98a1a4c --- /dev/null +++ b/components/gem/sources/new-candidate/test-event.mjs @@ -0,0 +1,77 @@ +export default { + "id": "string", + "created_at": 1, + "created_by": "string", + "last_updated_at": 1, + "candidate_greenhouse_id": "string", + "first_name": "string", + "last_name": "string", + "nickname": "string", + "weblink": "string", + "emails": [ + { + "email_address": "user@example.com", + "is_primary": false + } + ], + "phone_number": "string", + "location": "string", + "linked_in_handle": "string", + "profiles": [ + { + "network": "string", + "url": "string", + "username": "string" + } + ], + "company": "string", + "title": "string", + "school": "string", + "education_info": [ + { + "school": "string", + "parsed_university": "string", + "parsed_school": "string", + "start_date": "2019-08-24", + "end_date": "2019-08-24", + "field_of_study": "string", + "parsed_major_1": "string", + "parsed_major_2": "string", + "degree": "string" + } + ], + "work_info": [ + { + "company": "string", + "title": "string", + "work_start_date": "2019-08-24", + "work_end_date": "2019-08-24", + "is_current": true + } + ], + "custom_fields": [ + { + "id": "string", + "name": "string", + "scope": "team", + "project_id": "string", + "value": null, + "value_type": "date", + "value_option_ids": [ + "string" + ], + "custom_field_category": "string", + "custom_field_value": null + } + ], + "due_date": { + "date": "2019-08-24", + "user_id": "string", + "note": "string" + }, + "project_ids": [ + "string" + ], + "sourced_from": "SeekOut", + "gem_source": "SeekOut" +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41247d58de224..1729e5711a987 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4851,7 +4851,11 @@ importers: components/geckoboard: {} - components/gem: {} + components/gem: + dependencies: + '@pipedream/platform': + specifier: ^3.0.3 + version: 3.0.3 components/gemini_public: dependencies: