From 8e7d153d5a9e1a02ca0029cd93ae8346f1e7c8d8 Mon Sep 17 00:00:00 2001 From: Jorge Cortes Date: Tue, 4 Nov 2025 09:48:36 -0500 Subject: [PATCH] [Components] Ashby - new components --- .../create-application/create-application.mjs | 99 +++++ .../create-candidate/create-candidate.mjs | 141 +++++++ .../create-interview-schedule.mjs | 71 ++++ .../actions/create-offer/create-offer.mjs | 78 ++++ .../list-applications/list-applications.mjs | 97 +++++ .../start-offer-process.mjs | 40 ++ .../ashby/actions/start-offer/start-offer.mjs | 40 ++ components/ashby/ashby.app.mjs | 385 +++++++++++++++++- components/ashby/common/utils.mjs | 44 ++ components/ashby/package.json | 7 +- pnpm-lock.yaml | 6 +- 11 files changed, 1001 insertions(+), 7 deletions(-) create mode 100644 components/ashby/actions/create-application/create-application.mjs create mode 100644 components/ashby/actions/create-candidate/create-candidate.mjs create mode 100644 components/ashby/actions/create-interview-schedule/create-interview-schedule.mjs create mode 100644 components/ashby/actions/create-offer/create-offer.mjs create mode 100644 components/ashby/actions/list-applications/list-applications.mjs create mode 100644 components/ashby/actions/start-offer-process/start-offer-process.mjs create mode 100644 components/ashby/actions/start-offer/start-offer.mjs create mode 100644 components/ashby/common/utils.mjs diff --git a/components/ashby/actions/create-application/create-application.mjs b/components/ashby/actions/create-application/create-application.mjs new file mode 100644 index 0000000000000..f0501244de1f7 --- /dev/null +++ b/components/ashby/actions/create-application/create-application.mjs @@ -0,0 +1,99 @@ +import app from "../../ashby.app.mjs"; + +export default { + key: "ashby-create-application", + name: "Create Application", + description: "Considers a candidate for a job (e.g., when sourcing a candidate for a job posting). [See the documentation](https://developers.ashbyhq.com/reference/applicationcreate)", + version: "0.0.1", + type: "action", + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true, + }, + props: { + app, + candidateId: { + propDefinition: [ + app, + "candidateId", + ], + description: "The ID of the candidate to create an application for", + }, + jobId: { + propDefinition: [ + app, + "jobId", + ], + description: "The ID of the job to apply for", + }, + interviewPlanId: { + optional: true, + propDefinition: [ + app, + "interviewPlanId", + ], + }, + interviewStageId: { + optional: true, + propDefinition: [ + app, + "interviewStageId", + ({ interviewPlanId }) => ({ + interviewPlanId, + }), + ], + }, + sourceId: { + optional: true, + propDefinition: [ + app, + "sourceId", + ], + }, + creditedToUserId: { + label: "Credited To User ID", + description: "The ID of the user the application will be credited to", + optional: true, + propDefinition: [ + app, + "userId", + ], + }, + createdAt: { + type: "string", + label: "Created At", + description: "An ISO date string to set the application's createdAt timestamp (e.g., `2024-01-15T10:30:00Z`). Defaults to the current time if not provided.", + optional: true, + }, + }, + async run({ $ }) { + const { + app, + candidateId, + jobId, + interviewPlanId, + interviewStageId, + sourceId, + creditedToUserId, + createdAt, + } = this; + + const response = await app.createApplication({ + $, + data: { + candidateId, + jobId, + interviewPlanId, + interviewStageId, + sourceId, + creditedToUserId, + createdAt, + }, + }); + + $.export("$summary", `Successfully created application with ID \`${response.results?.id}\``); + + return response; + }, +}; diff --git a/components/ashby/actions/create-candidate/create-candidate.mjs b/components/ashby/actions/create-candidate/create-candidate.mjs new file mode 100644 index 0000000000000..5e171de7cf18a --- /dev/null +++ b/components/ashby/actions/create-candidate/create-candidate.mjs @@ -0,0 +1,141 @@ +import app from "../../ashby.app.mjs"; + +export default { + key: "ashby-create-candidate", + name: "Create Candidate", + description: "Creates a new candidate in Ashby. [See the documentation](https://developers.ashbyhq.com/reference/candidatecreate)", + version: "0.0.1", + type: "action", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + props: { + app, + name: { + propDefinition: [ + app, + "name", + ], + }, + email: { + propDefinition: [ + app, + "email", + ], + description: "Primary, personal email of the candidate to be created", + }, + phoneNumber: { + type: "string", + label: "Phone Number", + description: "Primary, personal phone number of the candidate to be created", + optional: true, + }, + linkedInUrl: { + type: "string", + label: "LinkedIn URL", + description: "URL to the candidate's LinkedIn profile. Must be a valid URL.", + optional: true, + }, + githubUrl: { + type: "string", + label: "GitHub URL", + description: "URL to the candidate's GitHub profile. Must be a valid URL.", + optional: true, + }, + website: { + type: "string", + label: "Website", + description: "URL of the candidate's website. Must be a valid URL.", + optional: true, + }, + alternateEmailAddresses: { + type: "string[]", + label: "Alternate Email Addresses", + description: "Array of alternate email addresses to add to the candidate's profile", + optional: true, + }, + sourceId: { + propDefinition: [ + app, + "sourceId", + ], + description: "The source to set on the candidate being created", + optional: true, + }, + creditedToUserId: { + propDefinition: [ + app, + "userId", + ], + label: "Credited To User ID", + description: "The ID of the user the candidate will be credited to", + optional: true, + }, + city: { + type: "string", + label: "City", + description: "The city of the candidate's location", + optional: true, + }, + region: { + type: "string", + label: "Region", + description: "The region (state, province, etc.) of the candidate's location", + optional: true, + }, + country: { + type: "string", + label: "Country", + description: "The country of the candidate's location", + optional: true, + }, + }, + async run({ $ }) { + const { + app, + email, + name, + phoneNumber, + linkedInUrl, + githubUrl, + website, + alternateEmailAddresses, + sourceId, + creditedToUserId, + city, + region, + country, + } = this; + + const response = await app.createCandidate({ + $, + data: { + name, + email, + phoneNumber, + linkedInUrl, + githubUrl, + website, + alternateEmailAddresses, + sourceId, + creditedToUserId, + ...(city || region || country + ? { + location: { + city, + region, + country, + }, + } + : undefined + ), + }, + }); + + $.export("$summary", "Successfully created candidate"); + + return response; + }, +}; diff --git a/components/ashby/actions/create-interview-schedule/create-interview-schedule.mjs b/components/ashby/actions/create-interview-schedule/create-interview-schedule.mjs new file mode 100644 index 0000000000000..1bd090a791219 --- /dev/null +++ b/components/ashby/actions/create-interview-schedule/create-interview-schedule.mjs @@ -0,0 +1,71 @@ +import app from "../../ashby.app.mjs"; +import utils from "../../common/utils.mjs"; + +export default { + key: "ashby-create-interview-schedule", + name: "Create Interview Schedule", + description: "Creates a scheduled interview. [See the documentation](https://developers.ashbyhq.com/reference/interviewschedulecreate)", + version: "0.0.1", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + type: "action", + props: { + app, + applicationId: { + propDefinition: [ + app, + "applicationId", + ], + description: "The ID of the application to schedule an interview for", + }, + interviewEvents: { + type: "string[]", + label: "Interview Events", + description: `Array of interview events. Each event should contain: +- **startTime** (string, required): Interview start time in ISO 8601 format (e.g., 2023-01-30T15:00:00.000Z) +- **endTime** (string, required): Interview end time in ISO 8601 format (e.g., 2023-01-30T16:00:00.000Z) +- **interviewers** (array, required): Array of interviewer objects with: + - **email** (string, required): Email address of the interviewer + - **feedbackRequired** (boolean, optional): Whether feedback from this interviewer is required + +Example: +\`\`\`json +[ + { + "startTime": "2023-01-30T15:00:00.000Z", + "endTime": "2023-01-30T16:00:00.000Z", + "interviewers": [ + { + "email": "interview@example.com", + "feedbackRequired": true + } + ] + } +] +\`\`\` +`, + }, + }, + async run({ $ }) { + const { + app, + applicationId, + interviewEvents, + } = this; + + const response = await app.createInterviewSchedule({ + $, + data: { + applicationId, + interviewEvents: utils.parseJson(interviewEvents), + }, + }); + + $.export("$summary", "Successfully created interview schedule"); + + return response; + }, +}; diff --git a/components/ashby/actions/create-offer/create-offer.mjs b/components/ashby/actions/create-offer/create-offer.mjs new file mode 100644 index 0000000000000..1d35e649353d9 --- /dev/null +++ b/components/ashby/actions/create-offer/create-offer.mjs @@ -0,0 +1,78 @@ +import app from "../../ashby.app.mjs"; +import utils from "../../common/utils.mjs"; + +export default { + key: "ashby-create-offer", + name: "Create Offer", + description: "Creates a new offer. [See the documentation](https://developers.ashbyhq.com/reference/offercreate)", + version: "0.0.1", + type: "action", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + props: { + app, + offerProcessId: { + propDefinition: [ + app, + "offerProcessId", + ], + }, + offerFormId: { + type: "string", + label: "Offer Form ID", + description: "The ID of the form associated with the offer. The ID is included in the response of the [offer.start API](https://developers.ashbyhq.com/reference/offerstart).", + }, + fieldSubmissions: { + type: "string[]", + label: "Field Submissions", + description: `Array of field submission objects. Each object should contain: +- **path** (string, required): The form field's "path" value +- **value** (string, required): The field value (can be a primitive or complex type depending on field type) + +You can find these referebce values in the response of the [offer.start API](https://developers.ashbyhq.com/reference/offerstart). + +Each item can be a JSON string or object with the structure: +\`\`\`json +[ + { + "path": "96377e55-cd34-49e2-aff0-5870ec102360", + "value": "2025-11-07" + }, + { + "path": "ded2358d-443f-484f-91fa-ec7a13de842b", + "value": { + "value": 10000, + "currencyCode": "USD" + } + } +] +\`\`\``, + }, + }, + async run({ $ }) { + const { + app, + offerProcessId, + offerFormId, + fieldSubmissions, + } = this; + + const response = await app.createOffer({ + $, + data: { + offerProcessId, + offerFormId, + offerForm: { + fieldSubmissions: utils.parseJson(fieldSubmissions), + }, + }, + }); + + $.export("$summary", `Successfully created offer for process ${offerProcessId}`); + + return response; + }, +}; diff --git a/components/ashby/actions/list-applications/list-applications.mjs b/components/ashby/actions/list-applications/list-applications.mjs new file mode 100644 index 0000000000000..dfb8d94ca1516 --- /dev/null +++ b/components/ashby/actions/list-applications/list-applications.mjs @@ -0,0 +1,97 @@ +import app from "../../ashby.app.mjs"; + +export default { + key: "ashby-list-applications", + name: "List Applications", + description: "Retrieves a list of applications within an organization. [See the documentation](https://developers.ashbyhq.com/reference/applicationlist)", + version: "0.0.1", + type: "action", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: true, + }, + props: { + app, + expand: { + type: "string[]", + label: "Expand", + description: "Array of fields to expand in the response", + optional: true, + options: [ + "openings", + ], + }, + status: { + type: "string", + label: "Status", + description: "Filter by application status", + optional: true, + options: [ + "Hired", + "Archived", + "Active", + "Lead", + ], + }, + jobId: { + propDefinition: [ + app, + "jobId", + ], + description: "Filter by job ID to get applications for a specific job", + optional: true, + }, + createdAfter: { + type: "string", + label: "Created After", + description: "Filter for applications created after this date and time (e.g., `2024-01-01T00:00:00Z`)", + optional: true, + }, + syncToken: { + type: "string", + label: "Sync Token", + description: "Token for syncing changes since the last request", + optional: true, + }, + maxResults: { + propDefinition: [ + app, + "maxResults", + ], + }, + }, + async run({ $ }) { + const { + app, + expand, + status, + jobId, + createdAfter, + syncToken, + maxResults, + } = this; + + const response = await app.paginate({ + fn: app.listApplications, + fnArgs: { + $, + data: { + expand, + status, + jobId, + syncToken, + createdAfter: createdAfter + ? new Date(createdAfter).getTime() + : undefined, + }, + }, + keyField: "results", + max: maxResults, + }); + + $.export("$summary", `Successfully retrieved \`${response.length}\` application(s)`); + + return response; + }, +}; diff --git a/components/ashby/actions/start-offer-process/start-offer-process.mjs b/components/ashby/actions/start-offer-process/start-offer-process.mjs new file mode 100644 index 0000000000000..b94266335acd6 --- /dev/null +++ b/components/ashby/actions/start-offer-process/start-offer-process.mjs @@ -0,0 +1,40 @@ +import app from "../../ashby.app.mjs"; + +export default { + key: "ashby-start-offer-process", + name: "Start Offer Process", + description: "Starts an offer process for a candidate. [See the documentation](https://developers.ashbyhq.com/reference/offerprocessstart)", + version: "0.0.1", + type: "action", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + props: { + app, + applicationId: { + propDefinition: [ + app, + "applicationId", + ], + }, + }, + async run({ $ }) { + const { + app, + applicationId, + } = this; + + const response = await app.startOfferProcess({ + $, + data: { + applicationId, + }, + }); + + $.export("$summary", `Successfully started offer process with ID \`${response.results?.id}\``); + + return response; + }, +}; diff --git a/components/ashby/actions/start-offer/start-offer.mjs b/components/ashby/actions/start-offer/start-offer.mjs new file mode 100644 index 0000000000000..2961e8c5177ab --- /dev/null +++ b/components/ashby/actions/start-offer/start-offer.mjs @@ -0,0 +1,40 @@ +import app from "../../ashby.app.mjs"; + +export default { + key: "ashby-start-offer", + name: "Start Offer", + description: "Starts an offer and returns an offer form instance that can be filled out and submitted. [See the documentation](https://developers.ashbyhq.com/reference/offerstart)", + version: "0.0.1", + type: "action", + annotations: { + destructiveHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + props: { + app, + offerProcessId: { + propDefinition: [ + app, + "offerProcessId", + ], + }, + }, + async run({ $ }) { + const { + app, + offerProcessId, + } = this; + + const response = await app.startOffer({ + $, + data: { + offerProcessId, + }, + }); + + $.export("$summary", `Successfully started offer with ID \`${response.results?.id}\``); + + return response; + }, +}; diff --git a/components/ashby/ashby.app.mjs b/components/ashby/ashby.app.mjs index 8548986d11480..1a3d8efe5391e 100644 --- a/components/ashby/ashby.app.mjs +++ b/components/ashby/ashby.app.mjs @@ -1,11 +1,388 @@ +import { axios } from "@pipedream/platform"; + export default { type: "app", app: "ashby", - propDefinitions: {}, + propDefinitions: { + candidateId: { + type: "string", + label: "Candidate ID", + description: "The ID of the candidate", + async options({ prevContext: { cursor } }) { + if (cursor === null) { + return []; + } + const { + results, + nextCursor, + } = await this.listCandidates({ + data: { + cursor, + }, + }); + return { + options: results.map(({ + id: value, + name: label, + }) => ({ + label, + value, + })), + context: { + cursor: nextCursor || null, + }, + }; + }, + }, + applicationId: { + type: "string", + label: "Application ID", + description: "The ID of the application", + async options({ prevContext: { cursor } }) { + if (cursor === null) { + return []; + } + const { + results, + nextCursor, + } = await this.listApplications({ + data: { + cursor, + }, + }); + return { + options: results.map(({ + id: value, + candidate, + job, + }) => ({ + label: candidate?.name && job?.title + ? `${candidate.name} - ${job.title}` + : value, + value, + })), + context: { + cursor: nextCursor || null, + }, + }; + }, + }, + jobId: { + type: "string", + label: "Job ID", + description: "The ID of the job", + async options({ prevContext: { cursor } }) { + if (cursor === null) { + return []; + } + const { + results, + nextCursor, + } = await this.listJobs({ + data: { + cursor, + }, + }); + return { + options: results.map(({ + id: value, + title: label, + }) => ({ + label, + value, + })), + context: { + cursor: nextCursor || null, + }, + }; + }, + }, + email: { + type: "string", + label: "Email", + description: "Email address", + optional: true, + }, + name: { + type: "string", + label: "Name", + description: "The first and last name of the candidate to be created.", + }, + maxResults: { + type: "integer", + label: "Max Results", + description: "Number of results to return", + optional: true, + }, + interviewPlanId: { + type: "string", + label: "Interview Plan ID", + description: "The ID of the interview plan to place the application in. If none is provided, the default interview plan is used.", + async options({ prevContext: { cursor } }) { + if (cursor === null) { + return []; + } + const { + results, + nextCursor, + } = await this.listInterviewPlans({ + data: { + cursor, + }, + }); + return { + options: results.map(({ + id: value, + title: label, + }) => ({ + label, + value, + })), + context: { + cursor: nextCursor || null, + }, + }; + }, + }, + interviewStageId: { + type: "string", + label: "Interview Stage ID", + description: "The interview stage of the interview plan to place the application in. If none is provided, the application is placed in the first 'Lead' stage. You can also use the special string 'FirstPreInterviewScreen' to choose the first pre-interview-screen stage.", + async options({ interviewPlanId }) { + if (!interviewPlanId) { + return []; + } + const { results } = await this.listInterviewStages({ + data: { + interviewPlanId, + }, + }); + return results.map(({ + id: value, + title: label, + }) => ({ + label, + value, + })); + }, + }, + sourceId: { + type: "string", + label: "Source ID", + description: "The source to set on the application being created", + async options({ prevContext: { cursor } }) { + if (cursor === null) { + return []; + } + const { + results, + nextCursor, + } = await this.listSources({ + data: { + cursor, + }, + }); + return { + options: results.map(({ + id: value, + title: label, + }) => ({ + label, + value, + })), + context: { + cursor: nextCursor || null, + }, + }; + }, + }, + userId: { + type: "string", + label: "User ID", + description: "The ID of the user", + async options({ prevContext: { cursor } }) { + if (cursor === null) { + return []; + } + const { + results, + nextCursor, + } = await this.listUsers({ + data: { + cursor, + }, + }); + return { + options: results.map(({ + id: value, + firstName, + lastName, + email, + }) => ({ + label: `${firstName} ${lastName} (${email})`, + value, + })), + context: { + cursor: nextCursor || null, + }, + }; + }, + }, + offerProcessId: { + type: "string", + label: "Offer Process ID", + description: "The ID of the offer process associated with the offer you're creating. This ID is included in the response of the [offerProcess.start API](https://developers.ashbyhq.com/reference/offerprocessstart).", + }, + }, methods: { - // this.$auth contains connected account data - authKeys() { - console.log(Object.keys(this.$auth)); + getAuth() { + const { api_key: apiKey } = this.$auth; + return { + username: apiKey, + password: "", + }; + }, + getHeaders() { + return { + "accept": "application/json; version=1", + "content-type": "application/json", + }; + }, + async makeRequest({ + $ = this, path = "", ...args + } = {}) { + try { + const response = await axios($, { + url: `https://api.ashbyhq.com${path}`, + headers: this.getHeaders(), + auth: this.getAuth(), + ...args, + }); + + if (!response.success) { + throw new Error(JSON.stringify(response, null, 2)); + } + + return response; + } catch (error) { + throw error.response?.data?.message || error; + } + }, + post(args = {}) { + return this.makeRequest({ + method: "POST", + ...args, + }); + }, + createCandidate(args = {}) { + return this.post({ + path: "/candidate.create", + ...args, + }); + }, + listApplications(args = {}) { + return this.post({ + path: "/application.list", + ...args, + }); + }, + listCandidates(args = {}) { + return this.post({ + path: "/candidate.list", + ...args, + }); + }, + listJobs(args = {}) { + return this.post({ + path: "/job.list", + ...args, + }); + }, + createInterviewSchedule(args = {}) { + return this.post({ + path: "/interviewSchedule.create", + ...args, + }); + }, + createOffer(args = {}) { + return this.post({ + path: "/offer.create", + ...args, + }); + }, + startOffer(args = {}) { + return this.post({ + path: "/offer.start", + ...args, + }); + }, + startOfferProcess(args = {}) { + return this.post({ + path: "/offerProcess.start", + ...args, + }); + }, + createApplication(args = {}) { + return this.post({ + path: "/application.create", + ...args, + }); + }, + listInterviewPlans(args = {}) { + return this.post({ + path: "/interviewPlan.list", + ...args, + }); + }, + listInterviewStages(args = {}) { + return this.post({ + path: "/interviewStage.list", + ...args, + }); + }, + listSources(args = {}) { + return this.post({ + path: "/source.list", + ...args, + }); + }, + listUsers(args = {}) { + return this.post({ + path: "/user.list", + ...args, + }); + }, + async paginate({ + max = 600, fn, fnArgs, keyField = "results", + } = {}) { + const results = []; + let cursor; + let collected = 0; + + while (collected < max) { + const remainingToFetch = Math.min(max - collected, 100); + + const response = await fn({ + ...fnArgs, + data: { + limit: remainingToFetch, + cursor, + ...fnArgs?.data, + }, + }); + + const items = response[keyField] || []; + results.push(...items); + collected += items.length; + + // Check if there are more results + cursor = response.nextCursor; + if (!cursor || items.length === 0) { + break; + } + } + + return results; }, }, }; diff --git a/components/ashby/common/utils.mjs b/components/ashby/common/utils.mjs new file mode 100644 index 0000000000000..2adf04343104f --- /dev/null +++ b/components/ashby/common/utils.mjs @@ -0,0 +1,44 @@ +const parseJson = (input, maxDepth = 100) => { + const seen = new WeakSet(); + const parse = (value) => { + if (maxDepth <= 0) { + return value; + } + if (typeof(value) === "string") { + // Only parse if the string looks like a JSON object or array + const trimmed = value.trim(); + if ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + try { + return parseJson(JSON.parse(value), maxDepth - 1); + } catch (e) { + return value; + } + } + return value; + } else if (typeof(value) === "object" && value !== null && !Array.isArray(value)) { + if (seen.has(value)) { + return value; + } + seen.add(value); + return Object.entries(value) + .reduce((acc, [ + key, + val, + ]) => Object.assign(acc, { + [key]: parse(val), + }), {}); + } else if (Array.isArray(value)) { + return value.map((item) => parse(item)); + } + return value; + }; + + return parse(input); +}; + +export default { + parseJson, +}; diff --git a/components/ashby/package.json b/components/ashby/package.json index 68dbd0d1dfcfe..8bb1c1e7474c7 100644 --- a/components/ashby/package.json +++ b/components/ashby/package.json @@ -1,6 +1,6 @@ { "name": "@pipedream/ashby", - "version": "0.0.1", + "version": "0.1.0", "description": "Pipedream Ashby Components", "main": "ashby.app.mjs", "keywords": [ @@ -11,5 +11,8 @@ "author": "Pipedream (https://pipedream.com/)", "publishConfig": { "access": "public" + }, + "dependencies": { + "@pipedream/platform": "^3.1.0" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 745727ed787e5..c0db4d57c90fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1134,7 +1134,11 @@ importers: components/ascora: {} - components/ashby: {} + components/ashby: + dependencies: + '@pipedream/platform': + specifier: ^3.1.0 + version: 3.1.0 components/ashby_job_postings_api: {}