diff --git a/fern/definition/ats/candidate.yml b/fern/definition/ats/candidate.yml new file mode 100644 index 000000000..9487b0330 --- /dev/null +++ b/fern/definition/ats/candidate.yml @@ -0,0 +1,121 @@ +imports: + errors: ../common/errors.yml + types: ../common/types.yml + unified: ../common/unified.yml + +types: + GetCandidateResponse: + properties: + status: types.ResponseStatus + result: unknown + GetCandidatesResponse: + properties: + status: types.ResponseStatus + next: optional + previous: optional + results: unknown + CreateOrUpdateCandidateRequest: unknown + CreateOrUpdateCandidateResponse: + properties: + status: types.ResponseStatus + message: string + result: unknown + + DeleteCandidateResponse: + properties: + status: types.ResponseStatus + message: string + +service: + base-path: /ats/candidates + auth: false + headers: + x-revert-api-token: + type: string + docs: Your official API key for accessing revert apis. + x-revert-t-id: + type: string + docs: The unique customer id used when the customer linked their account. + x-api-version: + type: optional + docs: Optional Revert API version you're using. If missing we default to the latest version of the API. + audiences: + - external + endpoints: + getCandidate: + docs: Get details of a candidate. + method: GET + path: /{id} + path-parameters: + id: + type: string + docs: The unique `id` of the candidate. + request: + name: GetCandidateRequest + query-parameters: + fields: optional + response: GetCandidateResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + getCandidates: + docs: Get all the Candidates. + method: GET + path: '' + request: + name: GetCandidatesRequest + query-parameters: + fields: optional + pageSize: optional + cursor: optional + response: GetCandidatesResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + createCandidate: + docs: Create a new Candidate + method: POST + path: '' + request: + name: CreateCandidateRequest + body: CreateOrUpdateCandidateRequest + query-parameters: + fields: optional + response: CreateOrUpdateCandidateResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + updateCandidate: + docs: Update a Candidate + method: PATCH + path: /{id} + path-parameters: + id: string + request: + name: UpdateCandidateRequest + body: CreateOrUpdateCandidateRequest + query-parameters: + fields: optional + response: CreateOrUpdateCandidateResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + deleteCandidate: + docs: Delete details of an Candidate + method: DELETE + path: /{id} + path-parameters: + id: string + request: + name: DeleteCandidateRequest + query-parameters: + fields: optional + response: DeleteCandidateResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError diff --git a/fern/definition/ats/department.yml b/fern/definition/ats/department.yml new file mode 100644 index 000000000..b3f23042f --- /dev/null +++ b/fern/definition/ats/department.yml @@ -0,0 +1,120 @@ +imports: + errors: ../common/errors.yml + types: ../common/types.yml + unified: ../common/unified.yml + +types: + GetDepartmentResponse: + properties: + status: types.ResponseStatus + result: unknown + GetDepartmentsResponse: + properties: + status: types.ResponseStatus + next: optional + previous: optional + results: unknown + CreateOrUpdateDepartmentRequest: unknown + CreateOrUpdateDepartmentResponse: + properties: + status: types.ResponseStatus + message: string + result: unknown + + DeleteDepartmentResponse: + properties: + status: types.ResponseStatus + message: string +service: + base-path: /ats/departments + auth: false + headers: + x-revert-api-token: + type: string + docs: Your official API key for accessing revert apis. + x-revert-t-id: + type: string + docs: The unique customer id used when the customer linked their account. + x-api-version: + type: optional + docs: Optional Revert API version you're using. If missing we default to the latest version of the API. + audiences: + - external + endpoints: + getDepartment: + docs: Get details of a department. + method: GET + path: /{id} + path-parameters: + id: + type: string + docs: The unique `id` of the department. + request: + name: GetDepartmentRequest + query-parameters: + fields: optional + response: GetDepartmentResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + getDepartments: + docs: Get all the departments. + method: GET + path: '' + request: + name: GetDepartmentsRequest + query-parameters: + fields: optional + pageSize: optional + cursor: optional + response: GetDepartmentsResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + createDepartment: + docs: Create a new Department + method: POST + path: '' + request: + name: CreateDepartmentRequest + body: CreateOrUpdateDepartmentRequest + query-parameters: + fields: optional + response: CreateOrUpdateDepartmentResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + updateDepartment: + docs: Update a Department + method: PATCH + path: /{id} + path-parameters: + id: string + request: + name: UpdateDepartmentRequest + body: CreateOrUpdateDepartmentRequest + query-parameters: + fields: optional + response: CreateOrUpdateDepartmentResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + deleteDepartment: + docs: Delete details of an Department + method: DELETE + path: /{id} + path-parameters: + id: string + request: + name: DeleteDepartmentRequest + query-parameters: + fields: optional + response: DeleteDepartmentResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError diff --git a/fern/definition/ats/job.yml b/fern/definition/ats/job.yml new file mode 100644 index 000000000..055e87b8c --- /dev/null +++ b/fern/definition/ats/job.yml @@ -0,0 +1,121 @@ +imports: + errors: ../common/errors.yml + types: ../common/types.yml + unified: ../common/unified.yml + +types: + GetJobResponse: + properties: + status: types.ResponseStatus + result: unknown + GetJobsResponse: + properties: + status: types.ResponseStatus + next: optional + previous: optional + results: unknown + CreateOrUpdateJobRequest: unknown + CreateOrUpdateJobResponse: + properties: + status: types.ResponseStatus + message: string + result: unknown + + DeleteJobResponse: + properties: + status: types.ResponseStatus + message: string + +service: + base-path: /ats/jobs + auth: false + headers: + x-revert-api-token: + type: string + docs: Your official API key for accessing revert apis. + x-revert-t-id: + type: string + docs: The unique customer id used when the customer linked their account. + x-api-version: + type: optional + docs: Optional Revert API version you're using. If missing we default to the latest version of the API. + audiences: + - external + endpoints: + getJob: + docs: Get details of a job. + method: GET + path: /{id} + path-parameters: + id: + type: string + docs: The unique `id` of the job. + request: + name: GetJobRequest + query-parameters: + fields: optional + response: GetJobResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + getJobs: + docs: Get all the jobs. + method: GET + path: '' + request: + name: GetJobsRequest + query-parameters: + fields: optional + pageSize: optional + cursor: optional + response: GetJobsResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + createJob: + docs: Create a new Job + method: POST + path: '' + request: + name: CreateJobRequest + body: CreateOrUpdateJobRequest + query-parameters: + fields: optional + response: CreateOrUpdateJobResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + updateJob: + docs: Update a Job + method: PATCH + path: /{id} + path-parameters: + id: string + request: + name: UpdateJobRequest + body: CreateOrUpdateJobRequest + query-parameters: + fields: optional + response: CreateOrUpdateJobResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + deleteJob: + docs: Delete details of an Job + method: DELETE + path: /{id} + path-parameters: + id: string + request: + name: DeleteJobRequest + query-parameters: + fields: optional + response: DeleteJobResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError diff --git a/fern/definition/ats/offer.yml b/fern/definition/ats/offer.yml new file mode 100644 index 000000000..1bef813e7 --- /dev/null +++ b/fern/definition/ats/offer.yml @@ -0,0 +1,121 @@ +imports: + errors: ../common/errors.yml + types: ../common/types.yml + unified: ../common/unified.yml + +types: + GetOfferResponse: + properties: + status: types.ResponseStatus + result: unknown + GetOffersResponse: + properties: + status: types.ResponseStatus + next: optional + previous: optional + results: unknown + CreateOrUpdateOfferRequest: unknown + CreateOrUpdateOfferResponse: + properties: + status: types.ResponseStatus + message: string + result: unknown + + DeleteOfferResponse: + properties: + status: types.ResponseStatus + message: string + +service: + base-path: /ats/offers + auth: false + headers: + x-revert-api-token: + type: string + docs: Your official API key for accessing revert apis. + x-revert-t-id: + type: string + docs: The unique customer id used when the customer linked their account. + x-api-version: + type: optional + docs: Optional Revert API version you're using. If missing we default to the latest version of the API. + audiences: + - external + endpoints: + getOffer: + docs: Get details of a offer. + method: GET + path: /{id} + path-parameters: + id: + type: string + docs: The unique `id` of the offer. + request: + name: GetOfferRequest + query-parameters: + fields: optional + response: GetOfferResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + getOffers: + docs: Get all the offers. + method: GET + path: '' + request: + name: GetOffersRequest + query-parameters: + fields: optional + pageSize: optional + cursor: optional + response: GetOffersResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + createOffer: + docs: Create a new Offer + method: POST + path: '' + request: + name: CreateOfferRequest + body: CreateOrUpdateOfferRequest + query-parameters: + fields: optional + response: CreateOrUpdateOfferResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + updateOffer: + docs: Update a Offer + method: PATCH + path: /{id} + path-parameters: + id: string + request: + name: UpdateOfferRequest + body: CreateOrUpdateOfferRequest + query-parameters: + fields: optional + response: CreateOrUpdateOfferResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError + deleteOffer: + docs: Delete details of an Offer + method: DELETE + path: /{id} + path-parameters: + id: string + request: + name: DeleteOfferRequest + query-parameters: + fields: optional + response: DeleteOfferResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError diff --git a/fern/definition/ats/proxy.yml b/fern/definition/ats/proxy.yml new file mode 100644 index 000000000..837a13564 --- /dev/null +++ b/fern/definition/ats/proxy.yml @@ -0,0 +1,43 @@ +imports: + errors: ../common/errors.yml + types: ../common/types.yml + +types: + ProxyResponse: + properties: + result: unknown + PostProxyRequestBody: + properties: + path: string + body: optional + method: string + queryParams: optional + +service: + base-path: /ats/proxy + auth: false + headers: + x-revert-api-token: + type: string + docs: Your official API key for accessing revert apis. + x-revert-t-id: + type: string + docs: The unique customer id used when the customer linked their account. + x-api-version: + type: optional + docs: Optional Revert API version you're using. If missing we default to the latest version of the API. + audiences: + - external + endpoints: + tunnel: + docs: Call the native ATS api for a specific connection + method: POST + path: '' + request: + name: PostProxyRequest + body: PostProxyRequestBody + response: ProxyResponse + errors: + - errors.UnAuthorizedError + - errors.InternalServerError + - errors.NotFoundError diff --git a/fern/docs.yml b/fern/docs.yml index 5a4208bb3..02f28f754 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -51,6 +51,8 @@ navigation: path: ./docs/contents/messaging-supported.mdx - page: Ticketing Systems path: ./docs/contents/ticketing-supported.mdx + - page: ATSs + path: ./docs/contents/ats-supported.mdx - tab: api layout: - api: API Reference @@ -84,8 +86,13 @@ navigation: path: ./docs/contents/guides/trello.mdx - page: BitBucket path: ./docs/contents/guides/bitbucket.mdx + - page: Greenhouse + path: ./docs/contents/guides/greenhouse.mdx + - page: Lever + path: ./docs/contents/guides/lever.mdx - page: GitHub path: ./docs/contents/guides/github.mdx + colors: accentPrimary: '#89a3ff' logo: diff --git a/fern/docs/contents/ats-supported.mdx b/fern/docs/contents/ats-supported.mdx new file mode 100644 index 000000000..56bd1f360 --- /dev/null +++ b/fern/docs/contents/ats-supported.mdx @@ -0,0 +1,10 @@ +### ATS Support + +We currently support the following ATSs in our APIs: + +| ATS | Support Status | +| ---------- | -------------- | +| Greenhouse | ✅ | +| Lever | ✅ | + +Need an integration thats not listed here? Contact us on our [discord](https://discord.gg/q5K5cRhymW) diff --git a/fern/docs/contents/guides/greenhouse.mdx b/fern/docs/contents/guides/greenhouse.mdx new file mode 100644 index 000000000..1de29af85 --- /dev/null +++ b/fern/docs/contents/guides/greenhouse.mdx @@ -0,0 +1,13 @@ + + +#### Obtaining Greenhouse API key + +- Open a [Greenhouse account](https://www.greenhouse.com/). +- Create a new Greenhouse API key, using the steps mentioned [here](https://support.greenhouse.io/hc/en-us/articles/115000521723-Manage-Harvest-API-key-permissions) + +#### Connect to Greenhouse via Revert + +- Create an account on Revert if you don't already have one. (https://app.revert.dev/sign-up) +- Login to your revert dashboard (https://app.revert.dev/sign-in) and click on `Create App` - `Greenhouse` + + diff --git a/fern/docs/contents/guides/lever.mdx b/fern/docs/contents/guides/lever.mdx new file mode 100644 index 000000000..c9f17a06d --- /dev/null +++ b/fern/docs/contents/guides/lever.mdx @@ -0,0 +1,14 @@ + + +#### Obtaining Lever Client ID and Secret + +- Become a [Lever partner](https://hire.lever.co/developer/partner) and follow to get the sandbox account details. + +#### Connect to Lever via Revert + +- Create an account on Revert if you don't already have one. (https://app.revert.dev/sign-up) +- Login to your revert dashboard (https://app.revert.dev/sign-in) and go to Integrations section and click on `Create App` - `Lever` + + + +- Enter the `client_id` and `client_secret` you copied in the previous step into the App credentials here and click `Submit`. diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 0cc223842..9b8969600 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -45,6 +45,9 @@ OPEN_INT_API_KEY= TWENTY_ACCOUNT_ID= BITBUCKET_CLIENT_ID= BITBUCKET_CLIENT_SECRET= +LEVER_CLIENT_ID= +LEVER_CLIENT_SECRET= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= DEFAULT_RATE_LIMIT_DEVELOPER_PLAN= + diff --git a/packages/backend/config.ts b/packages/backend/config.ts index cfd7938ad..c3def043b 100644 --- a/packages/backend/config.ts +++ b/packages/backend/config.ts @@ -57,6 +57,8 @@ const config = { GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET!, DEFAULT_RATE_LIMIT_DEVELOPER_PLAN: process.env.DEFAULT_RATE_LIMIT_DEVELOPER_PLAN, + LEVER_CLIENT_ID: process.env.LEVER_CLIENT_ID!, + LEVER_CLIENT_SECRET: process.env.LEVER_CLIENT_SECRET!, }; export default config; diff --git a/packages/backend/constants/common.ts b/packages/backend/constants/common.ts index 281ab3458..cdc7f15ff 100644 --- a/packages/backend/constants/common.ts +++ b/packages/backend/constants/common.ts @@ -3,6 +3,8 @@ import { Request, Response } from 'express'; export type CRM_TP_ID = 'zohocrm' | 'sfdc' | 'pipedrive' | 'hubspot' | 'closecrm' | 'ms_dynamics_365_sales'; export type CHAT_TP_ID = 'slack' | 'discord'; + +export type ATS_TP_ID = 'greenhouse' | 'lever'; export type TICKET_TP_ID = 'linear' | 'clickup' | 'asana' | 'jira' | 'trello' | 'bitbucket' | 'github'; export const DEFAULT_SCOPE = { @@ -49,6 +51,31 @@ export const DEFAULT_SCOPE = { [TP_ID.jira]: ['read:jira-work', 'read:jira-user', 'write:jira-work', 'offline_access'], [TP_ID.ms_dynamics_365_sales]: ['offline_access', 'User.Read'], [TP_ID.bitbucket]: ['issue', 'issue:write', 'repository', 'account'], + + [TP_ID.greenhouse]: [], + [TP_ID.lever]: [ + 'applications:read:admin', + 'archive_reasons:read:admin', + 'audit_events:read:admin,contact:write:admin', + 'diversity_surveys:read:admin', + 'eeo_responses:read:admin', + 'eeo_responses_pii:read:admin', + 'feedback:write:admin', + 'feedback_templates:write:admin', + 'files:write:admin', + 'form_templates:write:admin', + 'forms:write:admin', + 'offers:read:admin', + 'opportunities:write:admin', + 'postings:write:admin', + 'stages:read:admin', + 'tasks:read:admin', + 'users:write:admin', + 'webhooks:write:admin', + 'offline_access', + 'tags:read:admin', + ], + [TP_ID.github]: [ 'repo', 'issues:write', @@ -75,6 +102,9 @@ export const mapIntegrationIdToIntegrationName = { [TP_ID.jira]: 'Jira', [TP_ID.ms_dynamics_365_sales]: 'Microsoft Dynamics 365 Sales', [TP_ID.bitbucket]: 'Bitbucket', + [TP_ID.greenhouse]: 'Greenhouse', + [TP_ID.lever]: 'Lever', + [TP_ID.github]: 'GitHub', }; @@ -103,6 +133,13 @@ export enum TicketStandardObjects { ticketComment = 'ticketComment', } +export enum AtsStandardObjects { + job = 'job', + offer = 'offer', + candidate = 'candidate', + department = 'department', +} + export const objectNameMapping: Record> = { [StandardObjects.company]: { [TP_ID.hubspot]: 'companies', @@ -174,6 +211,7 @@ export const objectNameMapping: Record { error && logError(error); if (error instanceof Prisma.PrismaClientKnownRequestError) { @@ -57,7 +57,7 @@ const processOAuthResult = async ({ integrationName, tenantId, tenantSecretToken, - redirectUrl + redirectUrl, } as IntegrationStatusSseMessage); return response.send({ diff --git a/packages/backend/helpers/authMiddleware.ts b/packages/backend/helpers/authMiddleware.ts index 1af8ac581..c0ef9189e 100644 --- a/packages/backend/helpers/authMiddleware.ts +++ b/packages/backend/helpers/authMiddleware.ts @@ -4,7 +4,7 @@ import { logError } from './logger'; const revertAuthMiddleware = () => async (req: Request, res: Response, next: () => any) => { const nonSecurePaths = ['/oauth-callback', '/oauth/refresh']; - const nonSecurePathsPartialMatch = ['/integration-status', '/trello-request-token']; + const nonSecurePathsPartialMatch = ['/integration-status', '/trello-request-token', '/lever-app_config']; if (nonSecurePaths.includes(req.path) || nonSecurePathsPartialMatch.some((path) => req.path.includes(path))) return next(); const { 'x-revert-api-token': token, 'x-revert-t-token': tenantSecretToken } = req.headers; diff --git a/packages/backend/helpers/crm/transform/disunify.ts b/packages/backend/helpers/crm/transform/disunify.ts index cea78edb8..acf829c04 100644 --- a/packages/backend/helpers/crm/transform/disunify.ts +++ b/packages/backend/helpers/crm/transform/disunify.ts @@ -1,5 +1,7 @@ import { TP_ID, accountFieldMappingConfig } from '@prisma/client'; import { + ATS_TP_ID, + AtsStandardObjects, CHAT_TP_ID, CRM_TP_ID, ChatStandardObjects, @@ -15,7 +17,7 @@ import { handleSfdcDisunify, handleZohoDisunify, } from '..'; -import { postprocessDisUnifyObject, postprocessDisUnifyTicketObject } from './preprocess'; +import { postprocessDisUnifyAtsObject, postprocessDisUnifyObject, postprocessDisUnifyTicketObject } from './preprocess'; import { flattenObj } from '../../../helpers/flattenObj'; import handleCloseCRMDisunify from '../closecrm'; @@ -282,3 +284,179 @@ export async function disunifyTicketObject>({ } } } +export async function disunifyAtsObject>({ + obj, + tpId, + objType, + tenantSchemaMappingId, + accountFieldMappingConfig, +}: { + obj: T; + tpId: ATS_TP_ID; + objType: AtsStandardObjects; + tenantSchemaMappingId?: string; + accountFieldMappingConfig?: accountFieldMappingConfig; +}) { + const flattenedObj = flattenObj(obj, ['additional']); + const transformedObj = await transformModelToFieldMapping({ + unifiedObj: flattenedObj, + tpId, + objType, + tenantSchemaMappingId, + accountFieldMappingConfig, + }); + + if (obj.additional) { + Object.keys(obj.additional).forEach((key: any) => (transformedObj[key] = obj.additional[key])); + } + const processedObj = postprocessDisUnifyAtsObject({ obj: transformedObj, tpId, objType }); + + switch (tpId) { + case TP_ID.lever: { + if (objType === 'candidate') { + const confidential = obj.is_private ? 'confidential' : 'non-confidential'; + + let reversedEmails = []; + if (obj.email_addresses && obj.email_addresses.length > 0) { + reversedEmails = obj.email_addresses.map((email: any) => { + return email.value; + }); + } + + let applicationsIds = []; + if (obj.application_ids && obj.application_ids.length > 0) { + applicationsIds = obj.application_ids.map((id: string) => { + return { id: id }; + }); + } + + let tags = []; + if (obj.tags && obj.tags.length > 0) { + tags = obj.tags.map((tag: any) => { + return tag; + }); + } + let phones = []; + if (obj.phone_numbers && obj.phone_numbers.length > 0) { + phones = obj.phone_numbers.map((phone: any) => { + return { value: phone.value }; + }); + } + + let socialLinks = []; + + if (obj.social_media_addresses && obj.social_media_addresses.length > 0) { + socialLinks = obj.social_media_addresses.map((address: any) => { + return address.value; + }); + } + let websiteLinks = []; + + if (obj.website_addresses && obj.website_addresses.length > 0) { + websiteLinks = obj.website_addresses.map((address: any) => { + return address.value; + }); + } + + return { + ...transformedObj, + emails: reversedEmails, + confidentiality: confidential, + applicationsIds, + links: [...socialLinks, ...websiteLinks], + tags: tags, + phones, + }; + } else if (objType === 'job') { + const confidential = obj.is_private ? 'confidential' : 'non-confidential'; + + let originalState: string | undefined = ''; + switch (obj.status) { + case 'open': + originalState = 'published'; + break; + case 'closed': + originalState = 'rejected'; + break; + case 'draft': + originalState = 'pending'; + break; + default: + originalState = undefined; + break; + } + return { + ...transformedObj, + confidentiality: confidential, + state: originalState, + }; + } else if (objType === 'offer') { + let originalStatus: string | undefined; + switch (obj.status) { + case 'unresolved': + originalStatus = 'draft'; + break; + case 'rejected': + originalStatus = 'denied'; + break; + case 'accepted': + originalStatus = 'signed'; + break; + default: + originalStatus = undefined; + break; + } + + return { + ...transformedObj, + + status: originalStatus, + }; + } else if (objType === 'department') { + return { + ...transformedObj, + text: obj.name, + }; + } + + return processedObj; + } + + case TP_ID.greenhouse: { + if (objType === 'candidate') { + // every field below is an array + return { + ...transformedObj, + application_ids: obj.application_ids, + tags: obj.tags, + attachments: obj.attachments, + phone_numbers: obj.phone_numbers, + addresses: obj.addresses, + email_addresses: obj.email_addresses, + website_addresses: obj.website_addresses, + social_media_addresses: obj.social_media_addresses, + applications: obj.applications, + }; + } else if (objType === 'job') { + return { + ...transformedObj, + departments: obj.departments, + offices: obj.offices, + openings: obj.openings, + }; + } else if (objType === 'offer') { + return { + ...transformedObj, + }; + } else if (objType === 'department') { + return { + ...transformedObj, + child_ids: obj.child_ids, + child_department_external_ids: obj.child_department_external_ids, + }; + } + + return processedObj; + } + } +} diff --git a/packages/backend/helpers/crm/transform/preprocess.ts b/packages/backend/helpers/crm/transform/preprocess.ts index 665a61d12..892359b27 100644 --- a/packages/backend/helpers/crm/transform/preprocess.ts +++ b/packages/backend/helpers/crm/transform/preprocess.ts @@ -5,6 +5,8 @@ import { StandardObjects, TICKET_TP_ID, TicketStandardObjects, + AtsStandardObjects, + ATS_TP_ID, } from '../../../constants/common'; import { PipedriveDealStatus } from '../../../constants/pipedrive'; import { convertToHHMMInUTC, getDuration, getFormattedDate } from '../../../helpers/timeZoneHelper'; @@ -17,7 +19,7 @@ export const preprocessUnifyObject = >({ }: { obj: T; tpId: CRM_TP_ID | TICKET_TP_ID; - objType: StandardObjects | ChatStandardObjects | TicketStandardObjects; + objType: StandardObjects | ChatStandardObjects | TicketStandardObjects | AtsStandardObjects; }) => { const preprocessMap: any = { [TP_ID.hubspot]: { @@ -286,6 +288,207 @@ export const preprocessUnifyObject = >({ }; }, }, + [TP_ID.lever]: { + [AtsStandardObjects.candidate]: (obj: T) => { + let is_private = false; + if (obj.confidentiality && obj.confidentiality === 'non-confidential') { + is_private = false; + } else if (obj.confidentiality && obj.confidentiality === 'confidential') { + is_private = true; + } + + let application_ids: string[] = []; + if (obj.applications && obj.applications.length > 0) { + obj.applications.map((application: any) => { + if (application.id) { + application_ids.push(application.id); + } else { + application_ids.push(application); + } + }); + } + + let emails: { value: string; type: string | undefined }[] = []; + if (obj.emails && obj.emails.length > 0) { + obj.emails.map((email: any) => { + let item = { value: '', type: undefined }; + if (email) { + item.value = email.value; + item.type = undefined; + } + emails.push(item); + }); + } + + const created_at = obj.createdAt ? dayjs(Number(obj.createdAt)).toISOString() : null; + const updated_at = obj.updatedAt ? dayjs(Number(obj.updatedAt)).toISOString() : null; + const last_activity = obj.lastInteractionAt ? dayjs(Number(obj.lastInteractionAt)).toISOString() : null; + + let applications: any = []; + + if (obj.applications && obj.applications.length > 0) { + obj.applications.forEach((application: any) => { + let app = { + id: application.id, + candidate_id: application.candidateId, + prospect: undefined, + applied_at: undefined, + rejected_at: undefined, + last_activity_at: undefined, + location: undefined, + source: undefined, + credited_to: undefined, + rejection_reason: undefined, + rejection_details: undefined, + jobs: undefined, + job_post_id: application.posting, + status: undefined, + current_stage: undefined, + answers: undefined, + prospective_office: undefined, + prospective_department: undefined, + prospect_detail: undefined, + custom_fields: undefined, + keyed_custom_fields: undefined, + attachments: undefined, + }; + applications.push(app); + }); + } + + return { + ...obj, + confidentiality: is_private, + applicationIds: application_ids, + emails: emails, + createdAt: created_at, + updatedAt: updated_at, + lastInteractionAt: last_activity, + applications: applications, + }; + }, + [AtsStandardObjects.job]: (obj: T) => { + let confidential = false; + if (obj.confidentiality && obj.confidentiality === 'non-confidential') { + confidential = false; + } else if (obj.confidentiality && obj.confidentiality === 'confidential') { + confidential = true; + } + + let state: string | undefined = ''; + switch (obj.state) { + case 'published': + state = 'open'; + break; + case 'internal': + state = 'closed'; + break; + case 'closed': + state = 'closed'; + break; + case 'draft': + state = 'draft'; + break; + case 'pending': + state = 'draft'; + break; + case 'rejected': + state = 'closed'; + break; + default: + state = undefined; + break; + } + const created_at = obj.createdAt ? dayjs(Number(obj.createdAt)).toISOString() : null; + const updated_at = obj.updatedAt ? dayjs(Number(obj.updatedAt)).toISOString() : null; + + let hiringManager; + if (obj.hiringManager && obj.hiringManager.id) { + hiringManager = { + id: obj.hiringManager.id, + first_name: undefined, + last_name: undefined, + name: obj.hiringManager.name, + employee_id: undefined, + responsible: undefined, + }; + } else { + hiringManager = { + id: obj.hiringManager, + first_name: undefined, + last_name: undefined, + name: undefined, + employee_id: undefined, + responsible: undefined, + }; + } + + return { + ...obj, + confidentiality: confidential, + state: state, + createdAt: created_at, + updatedAt: updated_at, + hiringManager, + }; + }, + [AtsStandardObjects.offer]: (obj: T) => { + const created_at = obj.createdAt ? dayjs(Number(obj.createdAt)).toISOString() : null; + const sentAt = obj.createdAt ? dayjs(Number(obj.sentAt)).toISOString() : null; + const approvedAt = obj.createdAt ? dayjs(Number(obj.approvedAt)).toISOString() : null; + + let offerStatus: string | undefined; + switch (obj.status) { + case 'draft': + case 'approval-sent': + case 'approved': + case 'sent': + case 'sent-manually': + case 'opened': + offerStatus = 'unresolved'; + break; + case 'denied': + offerStatus = 'rejected'; + break; + case 'signed': + offerStatus = 'accepted'; + break; + default: + offerStatus = undefined; + break; + } + + let startsAt, posting_id; + + obj.fields && + obj.fields.length > 0 && + obj.fields.map((field: any) => { + if (field.identifier === 'job_posting') { + posting_id = field.value; + } + if (field.identifier === 'anticipated_start_date') { + startsAt = dayjs(Number(field.value)).toISOString(); + } + }); + + return { + ...obj, + sentAt: sentAt, + status: offerStatus, + createdAt: created_at, + approvedAt, + startsAt, + posting_id, + }; + }, + [AtsStandardObjects.department]: (obj: T) => { + const name = obj.text && obj.text; + return { + ...obj, + name, + }; + }, + }, }; const transformFn = (preprocessMap[tpId] || {})[objType]; return transformFn ? transformFn(obj) : obj; @@ -432,3 +635,19 @@ export const postprocessDisUnifyTicketObject = >({ const transformFn = (preprocessMap[tpId] || {})[objType]; return transformFn ? transformFn(obj) : obj; }; +export const postprocessDisUnifyAtsObject = >({ + obj, + tpId, + objType, +}: { + obj: T; + tpId: ATS_TP_ID; + objType: AtsStandardObjects; +}) => { + const preprocessMap: Record> = { + [TP_ID.greenhouse]: {}, + [TP_ID.lever]: {}, + }; + const transformFn = (preprocessMap[tpId] || {})[objType]; + return transformFn ? transformFn(obj) : obj; +}; diff --git a/packages/backend/helpers/crm/transform/transformSchemaMapping.ts b/packages/backend/helpers/crm/transform/transformSchemaMapping.ts index 21e6db2a1..6c19de17d 100644 --- a/packages/backend/helpers/crm/transform/transformSchemaMapping.ts +++ b/packages/backend/helpers/crm/transform/transformSchemaMapping.ts @@ -4,6 +4,7 @@ import { ChatStandardObjects, StandardObjects, TicketStandardObjects, + AtsStandardObjects, rootSchemaMappingId, } from '../../../constants/common'; import { logDebug } from '../../logger'; @@ -19,7 +20,7 @@ export const transformFieldMappingToModel = async ({ }: { obj: any; tpId: TP_ID; - objType: StandardObjects | ChatStandardObjects | TicketStandardObjects; + objType: StandardObjects | ChatStandardObjects | TicketStandardObjects | AtsStandardObjects; tenantSchemaMappingId?: string; accountFieldMappingConfig?: accountFieldMappingConfig; }) => { @@ -59,6 +60,7 @@ export const transformFieldMappingToModel = async ({ .includes(field) : accountFieldMappingConfig?.allow_connection_override_custom_fields)) ) || rootSchema?.fieldMappings?.find((r) => r?.target_field_name === field); + const transformedKey = fieldMapping?.source_field_name; if (transformedKey) { if (fieldMapping.is_standard_field) { @@ -85,7 +87,7 @@ export const transformModelToFieldMapping = async ({ }: { unifiedObj: any; tpId: TP_ID; - objType: StandardObjects | ChatStandardObjects | TicketStandardObjects; + objType: StandardObjects | ChatStandardObjects | TicketStandardObjects | AtsStandardObjects; tenantSchemaMappingId?: string; accountFieldMappingConfig?: accountFieldMappingConfig; }) => { diff --git a/packages/backend/helpers/crm/transform/unify.ts b/packages/backend/helpers/crm/transform/unify.ts index 4f7b38c1a..f32748ca8 100644 --- a/packages/backend/helpers/crm/transform/unify.ts +++ b/packages/backend/helpers/crm/transform/unify.ts @@ -1,5 +1,13 @@ +import { + CRM_TP_ID, + ChatStandardObjects, + StandardObjects, + TicketStandardObjects, + AtsStandardObjects, +} from '../../../constants/common'; + import { TP_ID, accountFieldMappingConfig } from '@prisma/client'; -import { CRM_TP_ID, ChatStandardObjects, StandardObjects, TicketStandardObjects } from '../../../constants/common'; + import { transformFieldMappingToModel } from '.'; import { preprocessUnifyObject } from './preprocess'; @@ -12,7 +20,7 @@ export async function unifyObject, K>({ }: { obj: T; tpId: CRM_TP_ID; - objType: StandardObjects | ChatStandardObjects | TicketStandardObjects; + objType: StandardObjects | ChatStandardObjects | TicketStandardObjects | AtsStandardObjects; tenantSchemaMappingId?: string; accountFieldMappingConfig?: accountFieldMappingConfig; }): Promise { diff --git a/packages/backend/helpers/endPointLoggerMiddleWare.ts b/packages/backend/helpers/endPointLoggerMiddleWare.ts index 88046f062..7daba90b6 100644 --- a/packages/backend/helpers/endPointLoggerMiddleWare.ts +++ b/packages/backend/helpers/endPointLoggerMiddleWare.ts @@ -6,7 +6,8 @@ const endpointLogger = () => async (req: Request, res: Response, next: NextFunct try { const path = req.path; const { 'x-revert-api-token': token } = req.headers; - const toAllow = path.includes('/crm') || path.includes('/chat') || path.includes('/ticket'); + const toAllow = + path.includes('/crm') || path.includes('/chat') || path.includes('/ticket') || path.includes('/ats'); if (!toAllow) return next(); diff --git a/packages/backend/tests/rateLimitMiddleware.test.ts b/packages/backend/helpers/rateLimitMiddleware.test.ts similarity index 93% rename from packages/backend/tests/rateLimitMiddleware.test.ts rename to packages/backend/helpers/rateLimitMiddleware.test.ts index e3f9a098c..c16552b0e 100644 --- a/packages/backend/tests/rateLimitMiddleware.test.ts +++ b/packages/backend/helpers/rateLimitMiddleware.test.ts @@ -1,6 +1,8 @@ import { Response } from 'express'; -import rateLimitMiddleware from '../helpers/rateLimitMiddleware'; -import { skipRateLimitRoutes } from '../helpers/utils'; + +import rateLimitMiddleware from './rateLimitMiddleware'; +import { skipRateLimitRoutes } from './utils'; + import { jest, describe, expect, it, beforeEach } from '@jest/globals'; type StatusFn = (code: number) => Response; diff --git a/packages/backend/index.ts b/packages/backend/index.ts index 9986f9fb0..4bbcd257d 100644 --- a/packages/backend/index.ts +++ b/packages/backend/index.ts @@ -152,6 +152,7 @@ app.listen(config.PORT, () => { await AuthService.refreshOAuthTokensForThirdParty(); await AuthService.refreshOAuthTokensForThirdPartyChatServices(); await AuthService.refreshOAuthTokensForThirdPartyTicketServices(); + await AuthService.refreshOAuthTokensForThirdPartyAtsServices(); }); if (!config.DISABLE_REVERT_TELEMETRY) { cron.schedule(`*/30 * * * *`, async () => { diff --git a/packages/backend/models/unified/candidate.ts b/packages/backend/models/unified/candidate.ts new file mode 100644 index 000000000..e942f0af1 --- /dev/null +++ b/packages/backend/models/unified/candidate.ts @@ -0,0 +1,109 @@ +import { UnifiedJob } from './job'; + +export interface UnifiedCandidate { + id: string; + first_name: string; + last_name: string; + company: string; + title: string; + created_at: Date; + updated_at: Date; + last_activity: Date; + is_private: boolean; + photo_url: string; + application_ids: string[]; + can_email: boolean; + tags: string[]; + attachments: Attachment[]; + phone_numbers: CandidateValueTypePair[]; + addresses: CandidateValueTypePair[]; + email_addresses: CandidateValueTypePair[]; + website_addresses: CandidateValueTypePair[]; + social_media_addresses: CandidateValueTypePair[]; + recruiter: HiringTeamInstance; + coordinator: HiringTeamInstance; + applications: Application[]; + additional: any; +} + +interface Attachment { + filename: string; + url: string; + type: string; + created_at: Date; +} + +interface CandidateValueTypePair { + value: string; + type: string; +} +interface HiringTeamInstance { + id: string; + first_name: string; + last_name: string; + name: string; + employee_id: string; + responsible: boolean; +} +interface Application { + id: string; + candidate_id: string; + prospect: boolean; + applied_at: string; + rejected_at: string; + last_activity_at: string; + location: LocationSchema; + source: SourceSchema; + credited_to: CreditedToSchema; + rejection_reason: string; + rejection_details: string; + jobs: UnifiedJob[]; + job_post_id: string; + status: string; + current_stage: CurrentStageSchema; + answers: AnswerSchema[]; + prospective_office: string; + prospective_department: string; + prospect_detail: { + prospect_pool: string; + prospect_stage: string; + prospect_owner: string; + }; + custom_fields: { + application_custom_test: string; + }; + keyed_custom_fields: { + application_custom_test: ApplicationCustomTestKeyedSchema; + }; + attachments: Attachment[]; +} + +interface ApplicationCustomTestKeyedSchema { + name: string; + type: string; + value: string; +} +interface AnswerSchema { + question: string; + answer: string; +} +interface CurrentStageSchema { + id: string; + name: string; +} + +interface LocationSchema { + address: string; +} + +interface SourceSchema { + id: string; + public_name: string; +} +interface CreditedToSchema { + id: string; + first_name: string; + last_name: string; + name: string; + employee_id: string; +} diff --git a/packages/backend/models/unified/department.ts b/packages/backend/models/unified/department.ts new file mode 100644 index 000000000..e9c1dbfc8 --- /dev/null +++ b/packages/backend/models/unified/department.ts @@ -0,0 +1,10 @@ +export interface UnifiedDepartment { + id: string; + name: string; + parent_id: string; + parent_department_external_ids: string; + child_ids: number[]; + child_department_external_ids: string[]; + external_id: string; + additional: any; +} diff --git a/packages/backend/models/unified/job.ts b/packages/backend/models/unified/job.ts new file mode 100644 index 000000000..8aae67741 --- /dev/null +++ b/packages/backend/models/unified/job.ts @@ -0,0 +1,46 @@ +import { UnifiedDepartment } from './department'; +import { Opening } from './offer'; + +export interface UnifiedJob { + id: string; + name: string; + requisition_id: string; + notes: string; + confidential: boolean; + status: string; + created_at: Date; + opened_at: Date; + closed_at: Date; + updated_at: Date; + is_template: boolean | null; + copied_from_id: string | null; + departments: UnifiedDepartment[]; + offices: Office[]; + openings: Opening[]; + hiring_team: HiringTeam; + additional: any; +} +interface Office { + id: string; + name: string; + location: { + name: string; + }; + parent_id: string; + child_ids: string[] | null; + external_id: string; +} +interface HiringTeam { + hiring_managers: HiringTeamInstance[]; + recruiters: HiringTeamInstance[]; + coordinators: HiringTeamInstance[]; + sourcers: HiringTeamInstance[]; +} +interface HiringTeamInstance { + id: string; + first_name: string; + last_name: string; + name: string; + employee_id: string; + responsible: boolean; +} diff --git a/packages/backend/models/unified/offer.ts b/packages/backend/models/unified/offer.ts new file mode 100644 index 000000000..e1180a762 --- /dev/null +++ b/packages/backend/models/unified/offer.ts @@ -0,0 +1,30 @@ +export interface UnifiedOffer { + id: string; + version: number; + application_id: string; + job_id: string; + candidate_id: string; + opening: Opening; + created_at: Date; + updated_at: Date; + sent_at: string; + resolved_at: Date; + starts_at: string; + status: string; + additional: any; +} + +export interface Opening { + id: string; + opening_id: string; + status: string; + opened_at: Date; + closed_at: Date; + application_id: string; + close_reason: CloseReason; +} + +interface CloseReason { + id: string; + name: string; +} diff --git a/packages/backend/oas/openapi.yml b/packages/backend/oas/openapi.yml index c4635df3c..cada1b269 100644 --- a/packages/backend/oas/openapi.yml +++ b/packages/backend/oas/openapi.yml @@ -3,6 +3,952 @@ info: title: revert-api version: '' paths: + /ats/candidates/{id}: + get: + description: Get details of a candidate. + operationId: ats_candidate_getCandidate + tags: + - AtsCandidate + parameters: + - name: id + in: path + description: The unique `id` of the candidate. + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsGetCandidateResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + patch: + description: Update a Candidate + operationId: ats_candidate_updateCandidate + tags: + - AtsCandidate + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateCandidateResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateCandidateRequest' + delete: + description: Delete details of an Candidate + operationId: ats_candidate_deleteCandidate + tags: + - AtsCandidate + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsDeleteCandidateResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + /ats/candidates: + get: + description: Get all the Candidates. + operationId: ats_candidate_getCandidates + tags: + - AtsCandidate + parameters: + - name: fields + in: query + required: false + schema: + type: string + nullable: true + - name: pageSize + in: query + required: false + schema: + type: string + nullable: true + - name: cursor + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsGetCandidatesResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + post: + description: Create a new Candidate + operationId: ats_candidate_createCandidate + tags: + - AtsCandidate + parameters: + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateCandidateResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateCandidateRequest' + /ats/departments/{id}: + get: + description: Get details of a department. + operationId: ats_department_getDepartment + tags: + - AtsDepartment + parameters: + - name: id + in: path + description: The unique `id` of the department. + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsGetDepartmentResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + patch: + description: Update a Department + operationId: ats_department_updateDepartment + tags: + - AtsDepartment + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateDepartmentResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateDepartmentRequest' + delete: + description: Delete details of an Department + operationId: ats_department_deleteDepartment + tags: + - AtsDepartment + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsDeleteDepartmentResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + /ats/departments: + get: + description: Get all the departments. + operationId: ats_department_getDepartments + tags: + - AtsDepartment + parameters: + - name: fields + in: query + required: false + schema: + type: string + nullable: true + - name: pageSize + in: query + required: false + schema: + type: string + nullable: true + - name: cursor + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsGetDepartmentsResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + post: + description: Create a new Department + operationId: ats_department_createDepartment + tags: + - AtsDepartment + parameters: + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateDepartmentResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateDepartmentRequest' + /ats/jobs/{id}: + get: + description: Get details of a job. + operationId: ats_job_getJob + tags: + - AtsJob + parameters: + - name: id + in: path + description: The unique `id` of the job. + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsGetJobResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + patch: + description: Update a Job + operationId: ats_job_updateJob + tags: + - AtsJob + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateJobResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateJobRequest' + delete: + description: Delete details of an Job + operationId: ats_job_deleteJob + tags: + - AtsJob + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsDeleteJobResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + /ats/jobs: + get: + description: Get all the jobs. + operationId: ats_job_getJobs + tags: + - AtsJob + parameters: + - name: fields + in: query + required: false + schema: + type: string + nullable: true + - name: pageSize + in: query + required: false + schema: + type: string + nullable: true + - name: cursor + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsGetJobsResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + post: + description: Create a new Job + operationId: ats_job_createJob + tags: + - AtsJob + parameters: + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateJobResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateJobRequest' + /ats/offers/{id}: + get: + description: Get details of a offer. + operationId: ats_offer_getOffer + tags: + - AtsOffer + parameters: + - name: id + in: path + description: The unique `id` of the offer. + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsGetOfferResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + patch: + description: Update a Offer + operationId: ats_offer_updateOffer + tags: + - AtsOffer + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateOfferResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateOfferRequest' + delete: + description: Delete details of an Offer + operationId: ats_offer_deleteOffer + tags: + - AtsOffer + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsDeleteOfferResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + /ats/offers: + get: + description: Get all the offers. + operationId: ats_offer_getOffers + tags: + - AtsOffer + parameters: + - name: fields + in: query + required: false + schema: + type: string + nullable: true + - name: pageSize + in: query + required: false + schema: + type: string + nullable: true + - name: cursor + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsGetOffersResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + post: + description: Create a new Offer + operationId: ats_offer_createOffer + tags: + - AtsOffer + parameters: + - name: fields + in: query + required: false + schema: + type: string + nullable: true + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateOfferResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/atsCreateOrUpdateOfferRequest' + /ats/proxy: + post: + description: Call the native ATS api for a specific connection + operationId: ats_proxy_tunnel + tags: + - AtsProxy + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/atsProxyResponse' + '401': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '404': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + '500': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/commonBaseError' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/atsPostProxyRequestBody' /chat/channels: get: description: Get all the channels @@ -3605,6 +4551,236 @@ paths: $ref: '#/components/schemas/commonBaseError' components: schemas: + atsGetCandidateResponse: + title: atsGetCandidateResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + result: {} + required: + - status + - result + atsGetCandidatesResponse: + title: atsGetCandidatesResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + next: + type: string + nullable: true + previous: + type: string + nullable: true + results: {} + required: + - status + - results + atsCreateOrUpdateCandidateRequest: + title: atsCreateOrUpdateCandidateRequest + atsCreateOrUpdateCandidateResponse: + title: atsCreateOrUpdateCandidateResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + message: + type: string + result: {} + required: + - status + - message + - result + atsDeleteCandidateResponse: + title: atsDeleteCandidateResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + message: + type: string + required: + - status + - message + atsGetDepartmentResponse: + title: atsGetDepartmentResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + result: {} + required: + - status + - result + atsGetDepartmentsResponse: + title: atsGetDepartmentsResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + next: + type: string + nullable: true + previous: + type: string + nullable: true + results: {} + required: + - status + - results + atsCreateOrUpdateDepartmentRequest: + title: atsCreateOrUpdateDepartmentRequest + atsCreateOrUpdateDepartmentResponse: + title: atsCreateOrUpdateDepartmentResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + message: + type: string + result: {} + required: + - status + - message + - result + atsDeleteDepartmentResponse: + title: atsDeleteDepartmentResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + message: + type: string + required: + - status + - message + atsGetJobResponse: + title: atsGetJobResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + result: {} + required: + - status + - result + atsGetJobsResponse: + title: atsGetJobsResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + next: + type: string + nullable: true + previous: + type: string + nullable: true + results: {} + required: + - status + - results + atsCreateOrUpdateJobRequest: + title: atsCreateOrUpdateJobRequest + atsCreateOrUpdateJobResponse: + title: atsCreateOrUpdateJobResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + message: + type: string + result: {} + required: + - status + - message + - result + atsDeleteJobResponse: + title: atsDeleteJobResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + message: + type: string + required: + - status + - message + atsGetOfferResponse: + title: atsGetOfferResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + result: {} + required: + - status + - result + atsGetOffersResponse: + title: atsGetOffersResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + next: + type: string + nullable: true + previous: + type: string + nullable: true + results: {} + required: + - status + - results + atsCreateOrUpdateOfferRequest: + title: atsCreateOrUpdateOfferRequest + atsCreateOrUpdateOfferResponse: + title: atsCreateOrUpdateOfferResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + message: + type: string + result: {} + required: + - status + - message + - result + atsDeleteOfferResponse: + title: atsDeleteOfferResponse + type: object + properties: + status: + $ref: '#/components/schemas/commonResponseStatus' + message: + type: string + required: + - status + - message + atsProxyResponse: + title: atsProxyResponse + type: object + properties: + result: {} + required: + - result + atsPostProxyRequestBody: + title: atsPostProxyRequestBody + type: object + properties: + path: + type: string + body: + nullable: true + method: + type: string + queryParams: + nullable: true + required: + - path + - method chatGetChannelsResponse: title: chatGetChannelsResponse type: object diff --git a/packages/backend/prisma/fields.ts b/packages/backend/prisma/fields.ts index 0d8756f6c..fc05b8b6e 100644 --- a/packages/backend/prisma/fields.ts +++ b/packages/backend/prisma/fields.ts @@ -1,5 +1,5 @@ import { TP_ID } from '@prisma/client'; -import { ChatStandardObjects, StandardObjects, TicketStandardObjects } from '../constants/common'; +import { AtsStandardObjects, ChatStandardObjects, StandardObjects, TicketStandardObjects } from '../constants/common'; // root schema mapping export const allFields = { @@ -1262,3 +1262,556 @@ export const ticketingFields = { }, ], }; + +export const atsFields = { + [AtsStandardObjects.candidate]: [ + { + source_field_name: { + [TP_ID.greenhouse]: 'id', + [TP_ID.lever]: 'id', + }, + target_field_name: 'id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'first_name', + [TP_ID.lever]: 'name', + }, + target_field_name: 'first_name', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'last_name', + [TP_ID.lever]: undefined, + }, + target_field_name: 'last_name', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'company', + [TP_ID.lever]: 'headline', + }, + target_field_name: 'company', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'title', + [TP_ID.lever]: undefined, + }, + target_field_name: 'title', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'created_at', + [TP_ID.lever]: 'createdAt', + }, + target_field_name: 'created_at', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'updated_at', + [TP_ID.lever]: 'updatedAt', + }, + target_field_name: 'updated_at', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'last_activity', + [TP_ID.lever]: 'lastInteractionAt', + }, + target_field_name: 'last_activity', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'is_private', + [TP_ID.lever]: 'confidentiality', + }, + target_field_name: 'is_private', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'photo_url', + [TP_ID.lever]: undefined, + }, + target_field_name: 'photo_url', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'application_ids', + [TP_ID.lever]: 'applicationIds', + }, + target_field_name: 'application_ids', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'can_email', + [TP_ID.lever]: undefined, + }, + target_field_name: 'can_email', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'tags', + [TP_ID.lever]: 'tags', + }, + target_field_name: 'tags', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'attachments', + [TP_ID.lever]: undefined, + }, + target_field_name: 'attachments', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'phone_numbers', + [TP_ID.lever]: 'phones', + }, + target_field_name: 'phone_numbers', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'addresses', + [TP_ID.lever]: undefined, + }, + target_field_name: 'addresses', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'email_addresses', + [TP_ID.lever]: 'emails', + }, + target_field_name: 'email_addresses', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'website_addresses', + [TP_ID.lever]: 'links', + }, + target_field_name: 'website_addresses', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'social_media_addresses', + [TP_ID.lever]: 'links', + }, + target_field_name: 'social_media_addresses', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'recruiter.id', + [TP_ID.lever]: 'owner.id', + }, + target_field_name: 'recruiter.id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'recruiter.first_name', + [TP_ID.lever]: undefined, + }, + target_field_name: 'recruiter.first_name', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'recruiter.last_name', + [TP_ID.lever]: undefined, + }, + target_field_name: 'recruiter.last_name', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'recruiter.name', + [TP_ID.lever]: 'owner.name', + }, + target_field_name: 'recruiter.name', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'recruiter.employee_id', + [TP_ID.lever]: undefined, + }, + target_field_name: 'recruiter.employee_id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'recruiter.responsible', + [TP_ID.lever]: undefined, + }, + target_field_name: 'recruiter.responsible', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'coordinator.id', + [TP_ID.lever]: undefined, + }, + target_field_name: 'coordinator.id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'coordinator.first_name', + [TP_ID.lever]: undefined, + }, + target_field_name: 'coordinator.first_name', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'coordinator.last_name', + [TP_ID.lever]: undefined, + }, + target_field_name: 'coordinator.last_name', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'coordinator.name', + [TP_ID.lever]: undefined, + }, + target_field_name: 'coordinator.name', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'coordinator.employee_id', + [TP_ID.lever]: undefined, + }, + target_field_name: 'coordinator.employee_id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'coordinator.responsible', + [TP_ID.lever]: undefined, + }, + target_field_name: 'coordinator.responsible', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'applications', + [TP_ID.lever]: 'applications', + }, + target_field_name: 'applications', + }, + ], + + [AtsStandardObjects.job]: [ + { + source_field_name: { + [TP_ID.greenhouse]: 'id', + [TP_ID.lever]: 'id', + }, + target_field_name: 'id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'name', + [TP_ID.lever]: 'text', + }, + target_field_name: 'name', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'requisition_id', + [TP_ID.lever]: 'reqCode', + }, + target_field_name: 'requisition_id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'notes', + [TP_ID.lever]: 'content.description', + }, + target_field_name: 'notes', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'confidential', + [TP_ID.lever]: 'confidentiality', + }, + target_field_name: 'confidential', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'status', + [TP_ID.lever]: 'state', + }, + target_field_name: 'status', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'created_at', + [TP_ID.lever]: 'createdAt', + }, + target_field_name: 'created_at', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'opened_at', + [TP_ID.lever]: undefined, + }, + target_field_name: 'opened_at', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'closed_at', + [TP_ID.lever]: undefined, + }, + target_field_name: 'closed_at', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'updated_at', + [TP_ID.lever]: 'updatedAt', + }, + target_field_name: 'updated_at', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'is_template', + [TP_ID.lever]: undefined, + }, + target_field_name: 'is_template', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'copied_from_id', + [TP_ID.lever]: undefined, + }, + target_field_name: 'copied_from_id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'departments', + [TP_ID.lever]: undefined, + }, + target_field_name: 'departments', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'offices', + [TP_ID.lever]: undefined, + }, + target_field_name: 'offices', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'openings', + [TP_ID.lever]: undefined, + }, + target_field_name: 'openings', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'hiring_team.hiring_managers', + [TP_ID.lever]: 'hiringManager', + }, + target_field_name: 'hiring_team.hiring_managers', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'hiring_team.recruiters', + [TP_ID.lever]: undefined, + }, + target_field_name: 'hiring_team.recruiters', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'hiring_team.coordinators', + [TP_ID.lever]: undefined, + }, + target_field_name: 'hiring_team.coordinators', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'hiring_team.sourcers', + [TP_ID.lever]: undefined, + }, + target_field_name: 'hiring_team.sourcers', + }, + ], + + [AtsStandardObjects.offer]: [ + { + source_field_name: { + [TP_ID.greenhouse]: 'id', + [TP_ID.lever]: 'id', + }, + target_field_name: 'id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'version', + [TP_ID.lever]: undefined, + }, + target_field_name: 'version', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'application_id', + [TP_ID.lever]: undefined, + }, + target_field_name: 'application_id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'job_id', + [TP_ID.lever]: 'posting_id', + }, + target_field_name: 'job_id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'candidate_id', + [TP_ID.lever]: undefined, + }, + target_field_name: 'candidate_id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'opening.id', + [TP_ID.lever]: undefined, + }, + target_field_name: 'opening.id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'opening.opening_id', + [TP_ID.lever]: undefined, + }, + target_field_name: 'opening.opening_id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'opening.status', + [TP_ID.lever]: undefined, + }, + target_field_name: 'opening.status', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'opening.opened_at', + [TP_ID.lever]: undefined, + }, + target_field_name: 'opening.opened_at', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'opening.closed_at', + [TP_ID.lever]: undefined, + }, + target_field_name: 'opening.closed_at', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'opening.application_id', + [TP_ID.lever]: undefined, + }, + target_field_name: 'opening.application_id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'opening.close_reason.id', + [TP_ID.lever]: undefined, + }, + target_field_name: 'opening.close_reason.id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'opening.close_reason.name', + [TP_ID.lever]: undefined, + }, + target_field_name: 'opening.close_reason.name', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'created_at', + [TP_ID.lever]: 'createdAt', + }, + target_field_name: 'created_at', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'updated_at', + [TP_ID.lever]: undefined, + }, + target_field_name: 'updated_at', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'sent_at', + [TP_ID.lever]: 'sentAt', + }, + target_field_name: 'sent_at', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'resolved_at', + [TP_ID.lever]: 'approvedAt', + }, + target_field_name: 'resolved_at', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'starts_at', + [TP_ID.lever]: 'startsAt', + }, + target_field_name: 'starts_at', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'status', + [TP_ID.lever]: 'status', + }, + target_field_name: 'status', + }, + ], + + [AtsStandardObjects.department]: [ + { + source_field_name: { + [TP_ID.greenhouse]: 'id', + [TP_ID.lever]: undefined, + }, + target_field_name: 'id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'name', + [TP_ID.lever]: 'name', + }, + target_field_name: 'name', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'parent_id', + [TP_ID.lever]: undefined, + }, + target_field_name: 'parent_id', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'parent_department_external_ids', + [TP_ID.lever]: undefined, + }, + target_field_name: 'parent_department_external_ids', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'child_ids', + [TP_ID.lever]: undefined, + }, + target_field_name: 'child_ids', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'child_department_external_ids', + [TP_ID.lever]: undefined, + }, + target_field_name: 'child_department_external_ids', + }, + { + source_field_name: { + [TP_ID.greenhouse]: 'external_id', + [TP_ID.lever]: undefined, + }, + target_field_name: 'external_id', + }, + ], +}; diff --git a/packages/backend/prisma/migrations/20240603093720_greenhouse_enum/migration.sql b/packages/backend/prisma/migrations/20240603093720_greenhouse_enum/migration.sql new file mode 100644 index 000000000..e6e3f05d0 --- /dev/null +++ b/packages/backend/prisma/migrations/20240603093720_greenhouse_enum/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "TP_ID" ADD VALUE 'greenhouse'; diff --git a/packages/backend/prisma/migrations/20240611060650_lever_enum/migration.sql b/packages/backend/prisma/migrations/20240611060650_lever_enum/migration.sql new file mode 100644 index 000000000..699a4e0e5 --- /dev/null +++ b/packages/backend/prisma/migrations/20240611060650_lever_enum/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "TP_ID" ADD VALUE 'lever'; diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index fc5cf28ca..a2d4334a1 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -22,6 +22,8 @@ enum TP_ID { trello jira bitbucket + greenhouse + lever github } diff --git a/packages/backend/prisma/seed.ts b/packages/backend/prisma/seed.ts index bc91d8469..017779c22 100644 --- a/packages/backend/prisma/seed.ts +++ b/packages/backend/prisma/seed.ts @@ -1,7 +1,13 @@ import { randomUUID } from 'crypto'; import { ENV, PrismaClient, TP_ID, fieldMappings } from '@prisma/client'; -import { ChatStandardObjects, StandardObjects, TicketStandardObjects, rootSchemaMappingId } from '../constants/common'; -import { allFields, chatFields, ticketingFields } from './fields'; +import { + AtsStandardObjects, + ChatStandardObjects, + StandardObjects, + TicketStandardObjects, + rootSchemaMappingId, +} from '../constants/common'; +import { allFields, atsFields, chatFields, ticketingFields } from './fields'; const prisma = new PrismaClient(); async function main() { @@ -71,7 +77,15 @@ async function main() { }; }); - const mergedSchema = [...allSchemas, ...chatSchemas, ...ticketSchemas]; + const atsSchemas = Object.keys(atsFields).map((obj) => { + return { + id: randomUUID(), + fields: atsFields[obj as keyof typeof atsFields].map((n) => n.target_field_name), + object: obj as AtsStandardObjects, + }; + }); + + const mergedSchema = [...allSchemas, ...chatSchemas, ...ticketSchemas, ...atsSchemas]; await prisma.schema_mapping.deleteMany({ where: { @@ -183,6 +197,29 @@ async function main() { }); }); + Object.values(AtsStandardObjects).forEach((obj) => { + Object.values(TP_ID).forEach(async (tpId) => { + if (!(tpId === 'greenhouse' || tpId === 'lever')) return; + const objSchema = atsSchemas.find((s: any) => s.object === obj); + const fieldMappings = objSchema?.fields.map((field: any) => { + const sourceFields: any = (atsFields[obj] as { target_field_name: string }[]).find( + (a) => a.target_field_name === field + ); + return { + id: randomUUID(), + source_tp_id: tpId, + schema_id: objSchema.id, + source_field_name: sourceFields?.source_field_name[tpId]!, + target_field_name: field, + is_standard_field: true, + }; + }); + if (fieldMappings) { + fieldMappingForAll.push(...fieldMappings); + } + }); + }); + await prisma.fieldMappings.createMany({ data: fieldMappingForAll, }); diff --git a/packages/backend/routes/index.ts b/packages/backend/routes/index.ts index 7c09843e4..e92a0a513 100644 --- a/packages/backend/routes/index.ts +++ b/packages/backend/routes/index.ts @@ -45,6 +45,12 @@ import { collectionServiceTicket } from '../services/ticket/collection'; import { commentServiceTicket } from '../services/ticket/comment'; import { proxyServiceTicket } from '../services/ticket/proxy'; import { syncService } from '../services/sync'; +import atsRouter from './v1/ats'; +import { departmentServiceAts } from '../services/ats/department'; +import { candidateServiceAts } from '../services/ats/candidate'; +import { offerServiceAts } from '../services/ats/offer'; +import { jobServiceAts } from '../services/ats/job'; +import { proxyServiceAts } from '../services/ats/proxy'; const router = express.Router(); @@ -147,6 +153,8 @@ router.use('/chat', cors(), [revertAuthMiddleware(), rateLimitMiddleware()], cha router.use('/ticket', cors(), [revertAuthMiddleware(), rateLimitMiddleware()], ticketRouter); +router.use('/ats', cors(), [revertAuthMiddleware(), rateLimitMiddleware()], atsRouter); + register(router, { metadata: metadataService, internal: { @@ -180,6 +188,13 @@ register(router, { collection: collectionServiceTicket, proxy: proxyServiceTicket, }, + ats: { + department: departmentServiceAts, + candidate: candidateServiceAts, + offer: offerServiceAts, + job: jobServiceAts, + proxy: proxyServiceAts, + }, sync: syncService, }); diff --git a/packages/backend/routes/v1/ats/auth.ts b/packages/backend/routes/v1/ats/auth.ts new file mode 100644 index 000000000..6123ef8dc --- /dev/null +++ b/packages/backend/routes/v1/ats/auth.ts @@ -0,0 +1,111 @@ +import express from 'express'; +import { randomUUID } from 'crypto'; +import { TP_ID } from '@prisma/client'; +import prisma from '../../../prisma/client'; +import { logInfo, logDebug } from '../../../helpers/logger'; +import processOAuthResult from '../../../helpers/auth/processOAuthResult'; +import redis from '../../../redis/client'; +import { ATS_TP_ID, mapIntegrationIdToIntegrationName } from '../../../constants/common'; +import greenhouse from './authHandlers/greenhouse'; +import lever from './authHandlers/lever'; + +const authRouter = express.Router({ mergeParams: true }); + +authRouter.get('/oauth-callback', async (req, res) => { + logInfo('OAuth callback', req.query); + const integrationId = req.query.integrationId as ATS_TP_ID; + const revertPublicKey = req.query.x_revert_public_token as string; + const redirect_url = req.query?.redirect_url; + const redirectUrl = redirect_url ? (redirect_url as string) : undefined; + + // generate a token for connection auth and save in redis for 5 mins + const tenantSecretToken = randomUUID(); + logDebug('blah tenantSecretToken', tenantSecretToken); + await redis.setEx(`tenantSecretToken_${req.query.t_id}`, 5 * 60, tenantSecretToken); + + try { + const account = await prisma.environments.findFirst({ + where: { + public_token: String(revertPublicKey), + }, + include: { + apps: { + select: { + id: true, + app_client_id: true, + app_client_secret: true, + is_revert_app: true, + app_config: true, + }, + where: { tp_id: integrationId }, + }, + accounts: true, + }, + }); + + const clientId = account?.apps[0]?.is_revert_app ? undefined : account?.apps[0]?.app_client_id; + const clientSecret = account?.apps[0]?.is_revert_app ? undefined : account?.apps[0]?.app_client_secret; + const svixAppId = account!.accounts!.id; + const environmentId = account?.id; + + const handleAuthProps = { + account, + clientId, + clientSecret, + code: req.query.code as string, //code for basic auth types is the api key + integrationId, + revertPublicKey, + svixAppId, + environmentId, + tenantId: String(req.query.t_id), + tenantSecretToken, + response: res, + request: req, + redirectUrl, + }; + + if (req.query.code && req.query.t_id && revertPublicKey) { + switch (integrationId) { + case TP_ID.greenhouse: + return greenhouse.handleBasicAuth(handleAuthProps); + case TP_ID.lever: + return lever.handleOAuth(handleAuthProps); + + default: + return processOAuthResult({ + status: false, + revertPublicKey, + tenantSecretToken, + response: res, + tenantId: req.query.t_id as string, + statusText: 'Not implemented yet', + redirectUrl, + }); + } + } else { + return processOAuthResult({ + status: false, + revertPublicKey, + tenantSecretToken, + response: res, + tenantId: req.query.t_id as string, + statusText: 'noop', + redirectUrl, + }); + } + } catch (error: any) { + return processOAuthResult({ + status: false, + error, + revertPublicKey, + integrationName: mapIntegrationIdToIntegrationName[integrationId], + tenantSecretToken, + response: res, + tenantId: req.query.t_id as string, + statusText: 'Error while getting oauth creds', + redirectUrl, + }); + } +}); + +export default authRouter; diff --git a/packages/backend/routes/v1/ats/authHandlers/greenhouse.ts b/packages/backend/routes/v1/ats/authHandlers/greenhouse.ts new file mode 100644 index 000000000..41ca022ec --- /dev/null +++ b/packages/backend/routes/v1/ats/authHandlers/greenhouse.ts @@ -0,0 +1,77 @@ +import { xprisma } from '../../../../prisma/client'; +import { TP_ID } from '@prisma/client'; +import { IntegrationAuthProps, mapIntegrationIdToIntegrationName } from '../../../../constants/common'; +import processOAuthResult from '../../../../helpers/auth/processOAuthResult'; +import sendConnectionAddedEvent from '../../../../helpers/webhooks/connection'; +import BaseOAuthHandler from '../../../../helpers/auth/baseOAuthHandler'; + +class GreenhouseAuthHandler extends BaseOAuthHandler { + async handleBasicAuth({ + account, + code, //code for basic auth types like greenhouse is the api key + integrationId, + revertPublicKey, + svixAppId, + environmentId, + tenantId, + tenantSecretToken, + response, + redirectUrl, + }: IntegrationAuthProps) { + try { + await xprisma.connections.upsert({ + where: { + id: tenantId, + }, + update: { + tp_access_token: code, + tp_refresh_token: null, + app_client_id: null, + app_client_secret: null, + tp_id: integrationId, + appId: account?.apps[0].id, + tp_customer_id: 'Greenhouse user', + }, + create: { + id: tenantId, + t_id: tenantId, + tp_id: integrationId, + tp_access_token: code, + app_client_id: null, + app_client_secret: null, + tp_customer_id: 'Greenhouse user', + owner_account_public_token: revertPublicKey, + appId: account?.apps[0].id, + environmentId: environmentId, + }, + }); + + // svix stuff here + await sendConnectionAddedEvent(svixAppId, tenantId, TP_ID.greenhouse, code, 'Greenhouse user'); + + return processOAuthResult({ + status: true, + revertPublicKey, + tenantSecretToken, + response, + tenantId: tenantId, + integrationName: mapIntegrationIdToIntegrationName[integrationId], + tpCustomerId: 'Greenhouse user', + redirectUrl, + }); + } catch (error: any) { + return processOAuthResult({ + status: false, + error, + revertPublicKey, + tenantSecretToken, + response, + tenantId: tenantId, + integrationName: mapIntegrationIdToIntegrationName[integrationId], + redirectUrl, + }); + } + } +} + +export default new GreenhouseAuthHandler(); diff --git a/packages/backend/routes/v1/ats/authHandlers/lever.ts b/packages/backend/routes/v1/ats/authHandlers/lever.ts new file mode 100644 index 000000000..aa84f143e --- /dev/null +++ b/packages/backend/routes/v1/ats/authHandlers/lever.ts @@ -0,0 +1,107 @@ +import axios from 'axios'; +import qs from 'qs'; +import config from '../../../../config'; +import { logInfo } from '../../../../helpers/logger'; +import { xprisma } from '../../../../prisma/client'; +import { TP_ID } from '@prisma/client'; +import { IntegrationAuthProps, mapIntegrationIdToIntegrationName, AppConfig } from '../../../../constants/common'; +import processOAuthResult from '../../../../helpers/auth/processOAuthResult'; +import sendConnectionAddedEvent from '../../../../helpers/webhooks/connection'; +import BaseOAuthHandler from '../../../../helpers/auth/baseOAuthHandler'; + +class LeverAuthHandler extends BaseOAuthHandler { + async handleOAuth({ + account, + clientId, + clientSecret, + code, + integrationId, + revertPublicKey, + svixAppId, + environmentId, + tenantId, + tenantSecretToken, + response, + redirectUrl, + }: IntegrationAuthProps) { + const formData = { + grant_type: 'authorization_code', + client_id: clientId, + client_secret: clientSecret, + code: code, + redirect_uri: `${config.OAUTH_REDIRECT_BASE}/lever`, + }; + + const env = (account?.apps[0]?.app_config as AppConfig)?.env; + + let url = + env === 'Sandbox' ? 'https://sandbox-lever.auth0.com/oauth/token' : 'https://auth.lever.co/oauth/token'; + + const result: any = await axios({ + method: 'post', + url: url, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data: qs.stringify(formData), + }); + + logInfo('OAuth creds for Lever', result.data); + + try { + await xprisma.connections.upsert({ + where: { + id: tenantId, + }, + create: { + id: tenantId, + t_id: tenantId, + tp_id: integrationId, + tp_access_token: result.data.access_token, + tp_refresh_token: result.data.refresh_token, + tp_customer_id: 'Lever_user', + app_client_id: clientId || config.LEVER_CLIENT_ID, + app_client_secret: clientSecret || config.LEVER_CLIENT_SECRET, + owner_account_public_token: revertPublicKey, + appId: account?.apps[0].id, + environmentId: environmentId, + }, + update: { + tp_access_token: result.data.access_token, + tp_refresh_token: result.data.refresh_token, + app_client_id: clientId || config.LEVER_CLIENT_ID, + app_client_secret: clientSecret || config.LEVER_CLIENT_SECRET, + tp_id: integrationId, + appId: account?.apps[0].id, + tp_customer_id: 'Lever_user', + }, + }); + + await sendConnectionAddedEvent(svixAppId, tenantId, TP_ID.lever, result.data.access_token, 'Lever_user'); + + return processOAuthResult({ + status: true, + revertPublicKey, + tenantSecretToken, + response, + tenantId: tenantId, + integrationName: mapIntegrationIdToIntegrationName[integrationId], + tpCustomerId: 'Lever_user', + redirectUrl, + }); + } catch (error: any) { + return processOAuthResult({ + status: false, + error, + revertPublicKey, + tenantSecretToken, + response, + tenantId: tenantId, + integrationName: mapIntegrationIdToIntegrationName[integrationId], + redirectUrl, + }); + } + } +} + +export default new LeverAuthHandler(); diff --git a/packages/backend/routes/v1/ats/index.ts b/packages/backend/routes/v1/ats/index.ts new file mode 100644 index 000000000..3e66ba72c --- /dev/null +++ b/packages/backend/routes/v1/ats/index.ts @@ -0,0 +1,51 @@ +import express from 'express'; +import prisma from '../../../prisma/client'; + +import authRouter from './auth'; +import { logError } from '../../../helpers/logger'; +import { InternalServerError } from '../../../generated/typescript/api/resources/common'; +import { AppConfig } from '../../../constants/common'; + +const atsRouter = express.Router(); + +atsRouter.get('/ping', async (_, res) => { + res.send({ + status: 'ok', + message: 'PONG', + }); +}); + +atsRouter.get('/lever-app_config', async (req, res) => { + try { + const { revertPublicToken } = req.query; + + const app = await prisma.environments.findFirst({ + where: { + public_token: String(revertPublicToken), + }, + include: { + apps: { + select: { app_config: true }, + where: { tp_id: 'lever' }, + }, + }, + }); + + const appConfig = app && (app.apps[0].app_config as AppConfig); + + if (appConfig?.env === 'Production') { + return res.send({ status: 'ok', env: 'Production' }); + } + + return res.send({ status: 'ok', env: 'Sandbox' }); + } catch (error: any) { + logError(error); + throw new InternalServerError({ + error: 'Internal Server error', + }); + } +}); + +atsRouter.use('/', authRouter); + +export default atsRouter; diff --git a/packages/backend/services/ats/candidate.ts b/packages/backend/services/ats/candidate.ts new file mode 100644 index 000000000..a7e2d3d1e --- /dev/null +++ b/packages/backend/services/ats/candidate.ts @@ -0,0 +1,492 @@ +import revertAuthMiddleware from '../../helpers/authMiddleware'; +import revertTenantMiddleware from '../../helpers/tenantIdMiddleware'; +import { logInfo, logError } from '../../helpers/logger'; +import { isStandardError } from '../../helpers/error'; +import { InternalServerError, NotFoundError } from '../../generated/typescript/api/resources/common'; +import { TP_ID } from '@prisma/client'; +import axios from 'axios'; +import { AppConfig, AtsStandardObjects } from '../../constants/common'; +import { disunifyAtsObject, unifyObject } from '../../helpers/crm/transform'; +import { UnifiedCandidate } from '../../models/unified/candidate'; +import { CandidateService } from '../../generated/typescript/api/resources/ats/resources/candidate/service/CandidateService'; + +const objType = AtsStandardObjects.candidate; + +const candidateServiceAts = new CandidateService( + { + async getCandidate(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const candidateId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + logInfo( + 'Revert::GET CANDIDATE', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken, + candidateId + ); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + const headers = { + Authorization: 'Basic ' + credentials, + }; + + const result = await axios({ + method: 'get', + url: `https://harvest.greenhouse.io/v1/candidates/${candidateId}`, + headers: headers, + }); + + const unifiedCandidate: any = await unifyObject({ + obj: result.data, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + res.send({ + status: 'ok', + result: unifiedCandidate, + }); + break; + } + case TP_ID.lever: { + const headers = { Authorization: `Bearer ${thirdPartyToken}` }; + + const env = + connection?.app?.tp_id === 'lever' && (connection?.app?.app_config as AppConfig)?.env; + + const url = + env === 'Sandbox' + ? `https://api.sandbox.lever.co/v1/opportunities/${candidateId}` + : `https://api.lever.co/v1/opportunities/${candidateId}`; + + const result = await axios({ + method: 'get', + url: url, + headers: headers, + }); + + const unifiedCandidate: any = await unifyObject({ + obj: result.data.data, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + res.send({ + status: 'ok', + result: unifiedCandidate, + }); + break; + } + + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not fetch candidate', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async getCandidates(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const fields: any = req.query.fields && JSON.parse(req.query.fields as string); + const pageSize = parseInt(String(req.query.pageSize)); + const cursor = req.query.cursor; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + + logInfo( + 'Revert::GET ALL CANDIDATES', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken + ); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + const headers = { + Authorization: 'Basic ' + credentials, + }; + let otherParams = ''; + if (fields) { + otherParams = Object.keys(fields) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(fields[key])}`) + .join('&'); + } + + let pagingString = `${pageSize ? `&per_page=${pageSize}` : ''}${ + pageSize && cursor ? `&page=${cursor}` : '' + }${otherParams ? `&${otherParams}` : ''}`; + + const result = await axios({ + method: 'get', + url: `https://harvest.greenhouse.io/v1/candidates?${pagingString}`, + headers: headers, + }); + const unifiedCandidates = await Promise.all( + result.data.map(async (candidate: any) => { + return await unifyObject({ + obj: candidate, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + }) + ); + + const linkHeader = result.headers.link; + let nextCursor, previousCursor; + if (linkHeader) { + const links = linkHeader.split(','); + + links?.forEach((link: any) => { + if (link.includes('rel="next"')) { + nextCursor = Number(link.match(/[&?]page=(\d+)/)[1]); + } else if (link.includes('rel="prev"')) { + previousCursor = Number(link.match(/[&?]page=(\d+)/)[1]); + } + }); + } + + res.send({ + status: 'ok', + next: nextCursor ? String(nextCursor) : undefined, + previous: previousCursor !== undefined ? String(previousCursor) : undefined, + results: unifiedCandidates, + }); + + break; + } + case TP_ID.lever: { + const headers = { Authorization: `Bearer ${thirdPartyToken}` }; + + const env = + connection?.app?.tp_id === 'lever' && (connection?.app?.app_config as AppConfig)?.env; + + let otherParams = ''; + if (fields) { + otherParams = Object.keys(fields) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(fields[key])}`) + .join('&'); + } + + let pagingString = `${pageSize ? `&limit=${pageSize}` : ''}${ + cursor ? `&offset=${cursor}` : '' + }${otherParams ? `&${otherParams}` : ''}`; + + const url = + env === 'Sandbox' + ? `https://api.sandbox.lever.co/v1/opportunities?${pagingString}` + : `https://api.lever.co/v1/opportunities?${pagingString}`; + + const result = await axios({ + method: 'get', + url: url, + headers: headers, + }); + const unifiedCandidates = await Promise.all( + result.data.data.map(async (candidate: any) => { + return await unifyObject({ + obj: candidate, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + }) + ); + + let nextCursor; + + if (result.data.hasNext) { + nextCursor = result.data.next; + } else { + nextCursor = undefined; + } + + res.send({ + status: 'ok', + next: nextCursor, + previous: undefined, + results: unifiedCandidates, + }); + + break; + } + + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not fetch candidates', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async createCandidate(req, res) { + try { + const candidateData: any = req.body as unknown as UnifiedCandidate; + const connection = res.locals.connection; + const account = res.locals.account; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + const candidate: any = await disunifyAtsObject({ + obj: candidateData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + logInfo('Revert::CREATE CANDIDATE', connection.app?.env?.accountId, tenantId, candidate); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + if (!fields || (fields && !fields.onBehalfOf)) { + throw new NotFoundError({ + error: 'The query parameter "onBehalfOf",which is a greenhouseUser Id, is required and should be included in the "fields" parameter.', + }); + } + + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + + const result: any = await axios({ + method: 'post', + url: `https://harvest.greenhouse.io/v1/candidates`, + headers: { + Authorization: 'Basic ' + credentials, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'On-Behalf-Of': `${fields.onBehalfOf}`, + }, + data: JSON.stringify(candidate), + }); + res.send({ status: 'ok', message: 'Greenhouse candidate created', result: result.data }); + + break; + } + case TP_ID.lever: { + if (!fields || (fields && !fields.perform_as)) { + throw new NotFoundError({ + error: 'The query parameter "perform_as" is required and should be included in the "fields" parameter.', + }); + } + + const env = + connection?.app?.tp_id === 'lever' && (connection?.app?.app_config as AppConfig)?.env; + + const url = + env === 'Sandbox' + ? `https://api.sandbox.lever.co/v1/opportunities?perform_as=${fields.perform_as}` + : `https://api.lever.co/v1/opportunities?perform_as=${fields.perform_as}`; + + const headers = { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'multipart/form-data type', + }; + + const result = await axios({ + method: 'post', + url: url, + + headers: headers, + data: JSON.stringify(candidate), + }); + + res.send({ status: 'ok', message: 'Lever candidate created', result: result.data.data }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not create candidate', error.response); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async updateCandidate(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const candidateData = req.body as unknown as UnifiedCandidate; + const candidateId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + const candidate: any = await disunifyAtsObject({ + obj: candidateData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + logInfo('Revert::UPDATE CANDIDATE', connection.app?.env?.accountId, tenantId, candidateData); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + if (!fields || (fields && !fields.onBehalfOf)) { + throw new NotFoundError({ + error: 'The query parameter "onBehalfOf",which is a greenhouseUser Id, is required and should be included in the "fields" parameter.', + }); + } + + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + + const result = await axios({ + method: 'patch', + url: `https://harvest.greenhouse.io/v1/candidates/${candidateId}`, + headers: { + Authorization: 'Basic ' + credentials, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'On-Behalf-Of': `${fields.onBehalfOf}`, + }, + data: JSON.stringify(candidate), + }); + + res.send({ + status: 'ok', + message: 'Greenhouse Candidate updated', + result: result.data, + }); + + break; + } + case TP_ID.lever: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not update candidate', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + + async deleteCandidate(req, res) { + try { + const connection = res.locals.connection; + const candidateId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + logInfo( + 'Revert::DELETE CANDIDATE', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken, + candidateId + ); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + if (!fields || (fields && !fields.onBehalfOf)) { + throw new NotFoundError({ + error: 'The query parameter "onBehalfOf",which is a greenhouseUser Id, is required and should be included in the "fields" parameter.', + }); + } + + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + + await axios({ + method: 'delete', + url: `https://harvest.greenhouse.io/v1/candidates/${candidateId}`, + headers: { + Authorization: 'Basic ' + credentials, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'On-Behalf-Of': `${fields.onBehalfOf}`, + }, + }); + + res.send({ + status: 'ok', + message: 'Greenhouse Candidate deleted', + }); + + break; + } + case TP_ID.lever: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not delete candidate', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + }, + + [revertAuthMiddleware(), revertTenantMiddleware()] +); + +export { candidateServiceAts }; diff --git a/packages/backend/services/ats/department.ts b/packages/backend/services/ats/department.ts new file mode 100644 index 000000000..dd96cbdd8 --- /dev/null +++ b/packages/backend/services/ats/department.ts @@ -0,0 +1,421 @@ +import revertAuthMiddleware from '../../helpers/authMiddleware'; +import revertTenantMiddleware from '../../helpers/tenantIdMiddleware'; +import { logInfo, logError } from '../../helpers/logger'; +import { isStandardError } from '../../helpers/error'; +import { InternalServerError, NotFoundError } from '../../generated/typescript/api/resources/common'; +import { TP_ID } from '@prisma/client'; +import axios from 'axios'; +import { disunifyAtsObject, unifyObject } from '../../helpers/crm/transform'; +import { UnifiedDepartment } from '../../models/unified/department'; +import { DepartmentService } from '../../generated/typescript/api/resources/ats/resources/department/service/DepartmentService'; +import { AppConfig, AtsStandardObjects } from '../../constants/common'; + +const objType = AtsStandardObjects.department; + +const departmentServiceAts = new DepartmentService( + { + async getDepartment(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const departmentId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + logInfo( + 'Revert::GET DEPARTMENT', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken, + departmentId + ); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + const headers = { + Authorization: 'Basic ' + credentials, + }; + + const result = await axios({ + method: 'get', + url: `https://harvest.greenhouse.io/v1/departments/${departmentId}`, + headers: headers, + }); + const UnifiedDepartment: any = await unifyObject({ + obj: result.data, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + res.send({ + status: 'ok', + result: UnifiedDepartment, + }); + break; + } + case TP_ID.lever: { + res.send({ + status: 'ok', + result: 'This endpoint is currently not supported.', + }); + break; + } + + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not fetch department', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async getDepartments(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const fields: any = req.query.fields && JSON.parse(req.query.fields as string); + const pageSize = parseInt(String(req.query.pageSize)); + const cursor = req.query.cursor; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + + logInfo( + 'Revert::GET ALL DEPARTMENTS', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken + ); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + const headers = { + Authorization: 'Basic ' + credentials, + }; + + let otherParams = ''; + if (fields) { + otherParams = Object.keys(fields) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(fields[key])}`) + .join('&'); + } + + let pagingString = `${pageSize ? `&per_page=${pageSize}` : ''}${ + pageSize && cursor ? `&page=${cursor}` : '' + }${otherParams ? `&${otherParams}` : ''}`; + + const result = await axios({ + method: 'get', + url: `https://harvest.greenhouse.io/v1/departments?${pagingString}`, + headers: headers, + }); + + const unifiedDepartments = await Promise.all( + result.data.map(async (department: any) => { + return await unifyObject({ + obj: department, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + }) + ); + const linkHeader = result.headers.link; + let nextCursor, previousCursor; + if (linkHeader) { + const links = linkHeader.split(','); + + links?.forEach((link: any) => { + if (link.includes('rel="next"')) { + nextCursor = Number(link.match(/[&?]page=(\d+)/)[1]); + } else if (link.includes('rel="prev"')) { + previousCursor = Number(link.match(/[&?]page=(\d+)/)[1]); + } + }); + } + + res.send({ + status: 'ok', + next: nextCursor ? String(nextCursor) : undefined, + previous: previousCursor !== undefined ? String(previousCursor) : undefined, + results: unifiedDepartments, + }); + + break; + } + case TP_ID.lever: { + const headers = { Authorization: `Bearer ${thirdPartyToken}` }; + + const env = + connection?.app?.tp_id === 'lever' && (connection?.app?.app_config as AppConfig)?.env; + + let otherParams = ''; + if (fields) { + otherParams = Object.keys(fields) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(fields[key])}`) + .join('&'); + } + + let pagingString = `${pageSize ? `&limit=${pageSize}` : ''}${ + cursor ? `&offset=${cursor}` : '' + }${otherParams ? `&${otherParams}` : ''}`; + + const url = + env === 'Sandbox' + ? `https://api.sandbox.lever.co/v1/tags?${pagingString}` + : `https://api.lever.co/v1/tags?${pagingString}`; + + const result = await axios({ + method: 'get', + url: url, + headers: headers, + }); + + const unifiedDepartments = await Promise.all( + result.data.data.map(async (department: any) => { + return await unifyObject({ + obj: department, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + }) + ); + let nextCursor; + + if (result.data.hasNext) { + nextCursor = result.data.next; + } else { + nextCursor = undefined; + } + + res.send({ + status: 'ok', + next: nextCursor, + previous: undefined, + results: unifiedDepartments, + }); + + break; + } + + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not fetch departments', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async createDepartment(req, res) { + try { + const departmentData: any = req.body as unknown as UnifiedDepartment; + const connection = res.locals.connection; + const account = res.locals.account; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + const department: any = await disunifyAtsObject({ + obj: departmentData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + logInfo('Revert::CREATE DEPARTMENT', connection.app?.env?.accountId, tenantId, department); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + if (!fields || (fields && !fields.onBehalfOf)) { + throw new NotFoundError({ + error: 'The query parameter "onBehalfOf",which is a greenhouseUser Id, is required and should be included in the "fields" parameter.', + }); + } + + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + + const result: any = await axios({ + method: 'post', + url: `https://harvest.greenhouse.io/v1/departments`, + headers: { + Authorization: 'Basic ' + credentials, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'On-Behalf-Of': `${fields.onBehalfOf}`, + }, + data: JSON.stringify(department), + }); + res.send({ status: 'ok', message: 'Greenhouse department created', result: result.data }); + + break; + } + case TP_ID.lever: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not create department', error.response); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async updateDepartment(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const departmentData = req.body as unknown as UnifiedDepartment; + const departmentId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + const department: any = await disunifyAtsObject({ + obj: departmentData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + logInfo('Revert::UPDATE DEPARTMENT', connection.app?.env?.accountId, tenantId, departmentData); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + if (!fields || (fields && !fields.onBehalfOf)) { + throw new NotFoundError({ + error: 'The query parameter "onBehalfOf",which is a greenhouseUser Id, is required and should be included in the "fields" parameter.', + }); + } + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + + const result = await axios({ + method: 'patch', + url: `https://harvest.greenhouse.io/v1/departments/${departmentId}`, + headers: { + Authorization: 'Basic ' + credentials, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'On-Behalf-Of': `${fields.onBehalfOf}`, + }, + data: JSON.stringify(department), + }); + + res.send({ + status: 'ok', + message: 'Greenhouse department updated', + result: result.data, + }); + + break; + } + case TP_ID.lever: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not update department', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + + async deleteDepartment(req, res) { + try { + const connection = res.locals.connection; + const departmentId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + // const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + logInfo( + 'Revert::DELETE DEPARTMENT', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken, + departmentId + ); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + + break; + } + case TP_ID.lever: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not delete department', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + }, + [revertAuthMiddleware(), revertTenantMiddleware()] +); + +export { departmentServiceAts }; diff --git a/packages/backend/services/ats/job.ts b/packages/backend/services/ats/job.ts new file mode 100644 index 000000000..f16035d68 --- /dev/null +++ b/packages/backend/services/ats/job.ts @@ -0,0 +1,497 @@ +import revertAuthMiddleware from '../../helpers/authMiddleware'; +import revertTenantMiddleware from '../../helpers/tenantIdMiddleware'; +import { logInfo, logError } from '../../helpers/logger'; +import { isStandardError } from '../../helpers/error'; +import { InternalServerError, NotFoundError } from '../../generated/typescript/api/resources/common'; +import { TP_ID } from '@prisma/client'; +import axios from 'axios'; +import { AppConfig, AtsStandardObjects } from '../../constants/common'; +import { disunifyAtsObject, unifyObject } from '../../helpers/crm/transform'; +import { UnifiedJob } from '../../models/unified/job'; +import { JobService } from '../../generated/typescript/api/resources/ats/resources/job/service/JobService'; + +const objType = AtsStandardObjects.job; + +const jobServiceAts = new JobService( + { + async getJob(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const jobId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + logInfo( + 'Revert::GET JOB', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken, + jobId + ); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + const headers = { + Authorization: 'Basic ' + credentials, + }; + + const result = await axios({ + method: 'get', + url: `https://harvest.greenhouse.io/v1/jobs/${jobId}`, + headers: headers, + }); + + const unifiedJob: any = await unifyObject({ + obj: result.data, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + res.send({ + status: 'ok', + result: unifiedJob, + }); + break; + } + case TP_ID.lever: { + const headers = { Authorization: `Bearer ${thirdPartyToken}` }; + + const env = + connection?.app?.tp_id === 'lever' && (connection?.app?.app_config as AppConfig)?.env; + + const url = + env === 'Sandbox' + ? `https://api.sandbox.lever.co/v1/postings/${jobId}` + : `https://api.lever.co/v1/postings/${jobId}`; + + const result = await axios({ + method: 'get', + url: url, + headers: headers, + }); + + const unifiedJob: any = await unifyObject({ + obj: result.data.data, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + res.send({ + status: 'ok', + result: unifiedJob, + }); + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not fetch job', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async getJobs(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const fields: any = req.query.fields && JSON.parse(req.query.fields as string); + + const pageSize = parseInt(String(req.query.pageSize)); + const cursor = req.query.cursor; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + + logInfo( + 'Revert::GET ALL JOBS', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken + ); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + const headers = { + Authorization: 'Basic ' + credentials, + }; + + let otherParams = ''; + if (fields) { + otherParams = Object.keys(fields) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(fields[key])}`) + .join('&'); + } + + let pagingString = `${pageSize ? `&per_page=${pageSize}` : ''}${ + pageSize && cursor ? `&page=${cursor}` : '' + }${otherParams ? `&${otherParams}` : ''}`; + + const result = await axios({ + method: 'get', + url: `https://harvest.greenhouse.io/v1/jobs?${pagingString}`, + headers: headers, + }); + + const unifiedJobs = await Promise.all( + result.data.map(async (job: any) => { + return await unifyObject({ + obj: job, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + }) + ); + + const linkHeader = result.headers.link; + let nextCursor, previousCursor; + if (linkHeader) { + const links = linkHeader.split(','); + + links?.forEach((link: any) => { + if (link.includes('rel="next"')) { + nextCursor = Number(link.match(/[&?]page=(\d+)/)[1]); + } else if (link.includes('rel="prev"')) { + previousCursor = Number(link.match(/[&?]page=(\d+)/)[1]); + } + }); + } + + res.send({ + status: 'ok', + next: nextCursor ? String(nextCursor) : undefined, + previous: previousCursor !== undefined ? String(previousCursor) : undefined, + results: unifiedJobs, + }); + + break; + } + case TP_ID.lever: { + const headers = { Authorization: `Bearer ${thirdPartyToken}` }; + + const env = + connection?.app?.tp_id === 'lever' && (connection?.app?.app_config as AppConfig)?.env; + + let otherParams = ''; + if (fields) { + otherParams = Object.keys(fields) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(fields[key])}`) + .join('&'); + } + + let pagingString = `${pageSize ? `&limit=${pageSize}` : ''}${ + cursor ? `&offset=${cursor}` : '' + }${otherParams ? `&${otherParams}` : ''}`; + + const url = + env === 'Sandbox' + ? `https://api.sandbox.lever.co/v1/postings?${pagingString}` + : `https://api.lever.co/v1/postings?${pagingString}`; + + const result = await axios({ + method: 'get', + url: url, + headers: headers, + }); + + const unifiedJobs = await Promise.all( + result.data.data.map(async (job: any) => { + return await unifyObject({ + obj: job, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + }) + ); + + let nextCursor; + + if (result.data.hasNext) { + nextCursor = result.data.next; + } else { + nextCursor = undefined; + } + + res.send({ + status: 'ok', + next: nextCursor, + previous: undefined, + results: unifiedJobs, + }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not fetch jobs', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async createJob(req, res) { + try { + const jobData: any = req.body as unknown as UnifiedJob; + const connection = res.locals.connection; + const account = res.locals.account; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + const job: any = await disunifyAtsObject({ + obj: jobData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + logInfo('Revert::CREATE JOB', connection.app?.env?.accountId, tenantId, job); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + if (!fields || (fields && !fields.onBehalfOf)) { + throw new NotFoundError({ + error: 'The query parameter "onBehalfOf",which is a greenhouseUser Id, is required and should be included in the "fields" parameter.', + }); + } + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + + const result: any = await axios({ + method: 'post', + url: `https://harvest.greenhouse.io/v1/jobs`, + headers: { + Authorization: 'Basic ' + credentials, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'On-Behalf-Of': `${fields.onBehalfOf}`, + }, + data: JSON.stringify(job), + }); + res.send({ status: 'ok', message: 'Greenhouse job created', result: result.data }); + + break; + } + case TP_ID.lever: { + if (!fields || (fields && !fields.perform_as)) { + throw new NotFoundError({ + error: 'The query parameter "perform_as", is required and should be included in the "fields" parameter.', + }); + } + const env = + connection?.app?.tp_id === 'lever' && (connection?.app?.app_config as AppConfig)?.env; + + const url = + env === 'Sandbox' + ? `https://api.sandbox.lever.co/v1/postings?perform_as=${fields.perform_as}` + : `https://api.lever.co/v1/postings?perform_as=${fields.perform_as}`; + + const headers = { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + const result = await axios({ + method: 'post', + url: url, + headers: headers, + data: JSON.stringify(job), + }); + + res.send({ + status: 'ok', + message: 'Lever job created', + result: result.data.data, + }); + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not create job', error.response); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async updateJob(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const jobData = req.body as unknown as UnifiedJob; + const jobId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + const job: any = await disunifyAtsObject({ + obj: jobData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + logInfo('Revert::UPDATE JOB', connection.app?.env?.accountId, tenantId, jobData); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + if (!fields || (fields && !fields.onBehalfOf)) { + throw new NotFoundError({ + error: 'The query parameter "onBehalfOf",which is a greenhouseUser Id, is required and should be included in the "fields" parameter.', + }); + } + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + + const result = await axios({ + method: 'patch', + url: `https://harvest.greenhouse.io/v1/jobs/${jobId}`, + headers: { + Authorization: 'Basic ' + credentials, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'On-Behalf-Of': `${fields.onBehalfOf}`, + }, + data: JSON.stringify(job), + }); + + res.send({ + status: 'ok', + message: 'Greenhouse job updated', + result: result.data, + }); + + break; + } + case TP_ID.lever: { + if (!fields || (fields && !fields.perform_as)) { + throw new NotFoundError({ + error: 'The query parameter "perform_as", is required and should be included in the "fields" parameter.', + }); + } + + const env = + connection?.app?.tp_id === 'lever' && (connection?.app?.app_config as AppConfig)?.env; + + const url = + env === 'Sandbox' + ? `https://api.sandbox.lever.co/v1/postings/${jobId}?perform_as=${fields.perform_as}` + : `https://api.lever.co/v1/postings/${jobId}?perform_as=${fields.perform_as}`; + + const headers = { + Authorization: `Bearer ${thirdPartyToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + const result = await axios({ + method: 'post', + url: url, + headers: headers, + data: JSON.stringify(job), + }); + + res.send({ + status: 'ok', + message: 'Lever job updated', + result: result.data.data, + }); + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not update job', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + + async deleteJob(req, res) { + try { + const connection = res.locals.connection; + const jobId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + // const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + logInfo( + 'Revert::DELETE JOB', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken, + jobId + ); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + + break; + } + case TP_ID.lever: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not delete job', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + }, + [revertAuthMiddleware(), revertTenantMiddleware()] +); + +export { jobServiceAts }; diff --git a/packages/backend/services/ats/offer.ts b/packages/backend/services/ats/offer.ts new file mode 100644 index 000000000..5f0e24c81 --- /dev/null +++ b/packages/backend/services/ats/offer.ts @@ -0,0 +1,404 @@ +import revertAuthMiddleware from '../../helpers/authMiddleware'; +import revertTenantMiddleware from '../../helpers/tenantIdMiddleware'; +import { logInfo, logError } from '../../helpers/logger'; +import { isStandardError } from '../../helpers/error'; +import { InternalServerError, NotFoundError } from '../../generated/typescript/api/resources/common'; +import { TP_ID } from '@prisma/client'; +import axios from 'axios'; +import { AppConfig, AtsStandardObjects } from '../../constants/common'; +import { disunifyAtsObject, unifyObject } from '../../helpers/crm/transform'; +import { UnifiedOffer } from '../../models/unified/offer'; +import { OfferService } from '../../generated/typescript/api/resources/ats/resources/offer/service/OfferService'; + +const objType = AtsStandardObjects.offer; + +const offerServiceAts = new OfferService( + { + async getOffer(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const offerId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + logInfo( + 'Revert::GET OFFER', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken, + offerId + ); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + const headers = { + Authorization: 'Basic ' + credentials, + }; + + const result = await axios({ + method: 'get', + url: `https://harvest.greenhouse.io/v1/offers/${offerId}`, + headers: headers, + }); + const unifiedOffer: any = await unifyObject({ + obj: result.data, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + res.send({ + status: 'ok', + result: unifiedOffer, + }); + break; + } + case TP_ID.lever: { + res.send({ + status: 'ok', + result: 'This endpoint is currently not supported.', + }); + break; + } + + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not fetch offer', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async getOffers(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const fields: any = req.query.fields && JSON.parse(req.query.fields as string); + const pageSize = parseInt(String(req.query.pageSize)); + const cursor = req.query.cursor; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + + logInfo( + 'Revert::GET ALL OFFERS', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken + ); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + const headers = { + Authorization: 'Basic ' + credentials, + }; + + let otherParams = ''; + if (fields) { + otherParams = Object.keys(fields) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(fields[key])}`) + .join('&'); + } + + let pagingString = `${pageSize ? `&per_page=${pageSize}` : ''}${ + pageSize && cursor ? `&page=${cursor}` : '' + }${otherParams ? `&${otherParams}` : ''}`; + + const result = await axios({ + method: 'get', + url: `https://harvest.greenhouse.io/v1/offers?${pagingString}`, + headers: headers, + }); + const unifiedOffers = await Promise.all( + result.data.map(async (job: any) => { + return await unifyObject({ + obj: job, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + }) + ); + const linkHeader = result.headers.link; + let nextCursor, previousCursor; + if (linkHeader) { + const links = linkHeader.split(','); + + links?.forEach((link: any) => { + if (link.includes('rel="next"')) { + nextCursor = Number(link.match(/[&?]page=(\d+)/)[1]); + } else if (link.includes('rel="prev"')) { + previousCursor = Number(link.match(/[&?]page=(\d+)/)[1]); + } + }); + } + + res.send({ + status: 'ok', + next: nextCursor ? String(nextCursor) : undefined, + previous: previousCursor !== undefined ? String(previousCursor) : undefined, + results: unifiedOffers, + }); + break; + } + case TP_ID.lever: { + if (!fields || (fields && !fields.opportunityId)) { + throw new NotFoundError({ + error: 'The query parameter "opportunityId" is required and should be included in the "fields" parameter.', + }); + } + const env = + connection?.app?.tp_id === 'lever' && (connection?.app?.app_config as AppConfig)?.env; + + const headers = { Authorization: `Bearer ${thirdPartyToken}` }; + + let otherParams = ''; + if (fields) { + otherParams = Object.keys(fields) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(fields[key])}`) + .join('&'); + } + + let pagingString = `${pageSize ? `&limit=${pageSize}` : ''}${ + cursor ? `&offset=${cursor}` : '' + }${otherParams ? `&${otherParams}` : ''}`; + + const url = + env === 'Sandbox' + ? `https://api.sandbox.lever.co/v1/opportunities/${fields.opportunityId}/offers?${pagingString}` + : `https://api.lever.co/v1/opportunities/${fields.opportunityId}/offers?${pagingString}`; + + const result = await axios({ + method: 'get', + url: url, + headers: headers, + }); + const unifiedOffers = await Promise.all( + result.data.data.map(async (job: any) => { + return await unifyObject({ + obj: job, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + }) + ); + let nextCursor; + + if (result.data.hasNext) { + nextCursor = result.data.next; + } else { + nextCursor = undefined; + } + res.send({ + status: 'ok', + next: nextCursor, + previous: undefined, + results: unifiedOffers, + }); + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not fetch offers', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async createOffer(req, res) { + try { + const offerData: any = req.body as unknown as UnifiedOffer; + const connection = res.locals.connection; + const account = res.locals.account; + const thirdPartyId = connection.tp_id; + // const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + // const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + const offer: any = await disunifyAtsObject({ + obj: offerData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + + logInfo('Revert::CREATE OFFER', connection.app?.env?.accountId, tenantId, offer); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + + break; + } + case TP_ID.lever: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not create offer', error.response); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + async updateOffer(req, res) { + try { + const connection = res.locals.connection; + const account = res.locals.account; + const offerData = req.body as unknown as UnifiedOffer; + //const offerId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + const offer: any = await disunifyAtsObject({ + obj: offerData, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + logInfo('Revert::UPDATE OFFER', connection.app?.env?.accountId, tenantId, offerData); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + if (!fields || (fields && !fields.applicationId && !fields.onBehalfOf)) { + throw new NotFoundError({ + error: 'The query parameters "applicationId","onBehalfOf" are required and should be included in the "fields" parameter.', + }); + } + + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + + const result = await axios({ + method: 'patch', + url: `https://harvest.greenhouse.io/v1/applications/${fields.applicationId}/offers/current_offer`, + headers: { + Authorization: 'Basic ' + credentials, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'On-Behalf-Of': `${fields.onBehalfOf}`, + }, + data: JSON.stringify(offer), + }); + + res.send({ + status: 'ok', + message: 'Greenhouse Offer updated', + result: result.data, + }); + + break; + } + case TP_ID.lever: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not update offer', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + + async deleteOffer(req, res) { + try { + const connection = res.locals.connection; + const offerId = req.params.id; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + // const fields: any = req.query.fields && JSON.parse((req.query as any).fields as string); + + logInfo( + 'Revert::DELETE OFFER', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken, + offerId + ); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + + break; + } + case TP_ID.lever: { + res.send({ + status: 'ok', + message: 'This endpoint is currently not supported', + }); + + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not delete offer', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + }, + [revertAuthMiddleware(), revertTenantMiddleware()] +); + +export { offerServiceAts }; diff --git a/packages/backend/services/ats/proxy.ts b/packages/backend/services/ats/proxy.ts new file mode 100644 index 000000000..c6b85c099 --- /dev/null +++ b/packages/backend/services/ats/proxy.ts @@ -0,0 +1,86 @@ +import revertAuthMiddleware from '../../helpers/authMiddleware'; +import revertTenantMiddleware from '../../helpers/tenantIdMiddleware'; +import { logInfo, logError } from '../../helpers/logger'; +import { isStandardError } from '../../helpers/error'; +import { InternalServerError, NotFoundError } from '../../generated/typescript/api/resources/common'; + +import { TP_ID } from '@prisma/client'; +import axios from 'axios'; +import { ProxyService } from '../../generated/typescript/api/resources/ats/resources/proxy/service/ProxyService'; + +const proxyServiceAts = new ProxyService( + { + async tunnel(req, res) { + try { + const connection = res.locals.connection; + const thirdPartyId = connection.tp_id; + const thirdPartyToken = connection.tp_access_token; + const tenantId = connection.t_id; + const request = req.body; + const path = request.path; + const body: any = request.body; + const method = request.method; + const queryParams = request.queryParams; + + logInfo( + 'Revert::POST PROXY FOR ATS APP', + connection.app?.env?.accountId, + tenantId, + thirdPartyId, + thirdPartyToken + ); + + switch (thirdPartyId) { + case TP_ID.greenhouse: { + const apiToken = thirdPartyToken; + const credentials = Buffer.from(apiToken + ':').toString('base64'); + const headers = { + Authorization: 'Basic ' + credentials, + }; + const result: any = await axios({ + method: method, + url: `https://harvest.greenhouse.io/v1/${path}`, + headers: headers, + data: body, + params: queryParams, + }); + res.send({ + result: result.data, + }); + break; + } + case TP_ID.lever: { + const token = thirdPartyToken; + const headers = { + Authorization: 'Bearer ' + token, + }; + const result: any = await axios({ + method: method, + url: `https://api.lever.co/v1/${path}`, + headers: headers, + data: body, + params: queryParams, + }); + res.send({ + result: result.data, + }); + break; + } + default: { + throw new NotFoundError({ error: 'Unrecognized app!' }); + } + } + } catch (error: any) { + logError(error); + console.error('Could not do proxy request', error); + if (isStandardError(error)) { + throw error; + } + throw new InternalServerError({ error: 'Internal server error' }); + } + }, + }, + [revertAuthMiddleware(), revertTenantMiddleware()] +); + +export { proxyServiceAts }; diff --git a/packages/backend/services/auth.ts b/packages/backend/services/auth.ts index 2ad8393bc..e092d7bcf 100644 --- a/packages/backend/services/auth.ts +++ b/packages/backend/services/auth.ts @@ -378,7 +378,65 @@ class AuthService { } return { status: 'ok', message: 'Ticket services tokens refreshed' }; } + async refreshOAuthTokensForThirdPartyAtsServices() { + try { + const connections = await prisma.connections.findMany({ + include: { app: true }, + }); + + for (let i = 0; i < connections.length; i++) { + const connection = connections[i]; + if (connection.tp_refresh_token) { + try { + if (connection.tp_id === TP_ID.lever) { + const env = (connection?.app?.app_config as AppConfig)?.env; + let url = + env === 'Sandbox' + ? 'https://sandbox-lever.auth0.com/oauth/token' + : 'https://auth.lever.co/oauth/token'; + + // Refresh lever token + const formData = { + grant_type: 'refresh_token', + client_id: connection.app?.is_revert_app + ? config.LEVER_CLIENT_ID + : connection.app_client_id || config.LEVER_CLIENT_ID, + client_secret: connection.app?.is_revert_app + ? config.LEVER_CLIENT_SECRET + : connection.app_client_secret || config.LEVER_CLIENT_SECRET, + refresh_token: connection.tp_refresh_token, + }; + const result: any = await axios({ + method: 'post', + url: url, + data: qs.stringify(formData), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + await prisma.connections.update({ + where: { + id: connection.id, + }, + data: { + tp_access_token: result.data.access_token, + tp_refresh_token: result.data.refresh_token, + }, + }); + } + } catch (error: any) { + logError(error.response?.data); + console.error('Could not refresh token', connection.t_id, error.response?.data); + } + } + } + } catch (error: any) { + logError(error); + console.error('Could not update db', error.response?.data); + } + return { status: 'ok', message: 'ATS services tokens refreshed' }; + } async createAccountOnClerkUserCreation(webhookData: any, webhookEventType: string) { let response; logInfo('webhookData', webhookData, webhookEventType); @@ -565,6 +623,7 @@ class AuthService { ...(scopes.filter(Boolean).length && { scope: scopes }), ...(appConfig?.bot_token && { app_config: { bot_token: appConfig.bot_token } }), ...(appConfig?.org_url && { app_config: { org_url: appConfig.org_url } }), + ...(appConfig?.env && { app_config: { env: appConfig.env } }), }, }); if (!account) { diff --git a/packages/backend/services/metadata.ts b/packages/backend/services/metadata.ts index e23ac50a6..9aa4a4306 100644 --- a/packages/backend/services/metadata.ts +++ b/packages/backend/services/metadata.ts @@ -156,6 +156,25 @@ const metadataService = new MetadataService({ scopes: getScope(apps, TP_ID.bitbucket), clientId: getClientId(apps, TP_ID.bitbucket) || config.BITBUCKET_CLIENT_ID, }, + { + integrationId: TP_ID.greenhouse, + name: 'Greenhouse', + imageSrc: + 'https://res.cloudinary.com/dwoiwg0t5/image/upload/f_auto,q_auto/v1/RevertAppLogos/y8evsfdtfu8s1pdeicf8', + status: 'active', + scopes: getScope(apps, TP_ID.greenhouse), + clientId: undefined, + }, + { + integrationId: TP_ID.lever, + name: 'Lever', + imageSrc: + ' https://res.cloudinary.com/dwoiwg0t5/image/upload/f_auto,q_auto/v1/RevertAppLogos/rvodrek2tgqit7b9g9ef', + status: 'active', + scopes: getScope(apps, TP_ID.lever), + clientId: getClientId(apps, TP_ID.lever) || config.LEVER_CLIENT_ID, + }, + { integrationId: TP_ID.github, name: 'GitHub', diff --git a/packages/client/src/common/oauth/index.tsx b/packages/client/src/common/oauth/index.tsx index b21a2ea24..f143e0c51 100644 --- a/packages/client/src/common/oauth/index.tsx +++ b/packages/client/src/common/oauth/index.tsx @@ -522,6 +522,44 @@ export const OAuthCallback = (props) => { console.error(err); setStatus('Errored out'); }); + } else if (integrationId === 'lever') { + console.log('Post ats app installation', integrationId, params); + const { tenantId, revertPublicToken, redirectUrl } = JSON.parse(decodeURIComponent(params.state)); + fetch( + `${REVERT_BASE_API_URL}/v1/ats/oauth-callback?integrationId=${integrationId}&code=${ + params.code + }&t_id=${tenantId}&x_revert_public_token=${revertPublicToken}${ + redirectUrl ? `&redirect_url=${redirectUrl}` : `` + }`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ) + .then((d) => { + return d.json(); + }) + .then((data) => { + console.log('OAuth flow succeeded', data); + if (data.error) { + const errorMessage = + data.error?.code === 'P2002' + ? ': Already connected another app. Please disconnect first.' + : ''; + setStatus('Errored out' + errorMessage); + } else { + setStatus('Succeeded. Please feel free to close this window.'); + } + setIsLoading(false); + }) + .catch((err) => { + Sentry.captureException(err); + setIsLoading(false); + console.error(err); + setStatus('Errored out'); + }); } } }, [integrationId]); diff --git a/packages/client/src/features/integration/enums/metadata.ts b/packages/client/src/features/integration/enums/metadata.ts index 1d040efa5..2a06a8f29 100644 --- a/packages/client/src/features/integration/enums/metadata.ts +++ b/packages/client/src/features/integration/enums/metadata.ts @@ -68,6 +68,17 @@ export const appsInfo = { description: 'Configure your Bitbucket Ticketing App from here.', }, + greenhouse: { + name: 'Greenhouse', + logo: 'https://res.cloudinary.com/dwoiwg0t5/image/upload/f_auto,q_auto/v1/RevertAppLogos/rhqhzzrt6zmefncg3uid', + description: 'Configure your Greenhouse ATS App from here.', + }, + lever: { + name: 'Lever', + logo: 'https://res.cloudinary.com/dwoiwg0t5/image/upload/f_auto,q_auto/v1/RevertAppLogos/hwlwhz2lnum3hfcabqc4', + description: 'Configure your Lever ATS App from here.', + }, + github: { name: 'GitHub', logo: 'https://res.cloudinary.com/dwoiwg0t5/image/upload/v1722418192/RevertAppLogos/cmeafzqvnvvconohfnnr.png', diff --git a/packages/client/src/home/editCredentials.tsx b/packages/client/src/home/editCredentials.tsx index 9c0ec4a3a..ebec7016f 100644 --- a/packages/client/src/home/editCredentials.tsx +++ b/packages/client/src/home/editCredentials.tsx @@ -4,6 +4,7 @@ import { Box as MuiBox, Button, Chip as MuiChip, Switch } from '@mui/material'; import { LoadingButton as MuiLoadingButton } from '@mui/lab'; import { useApi } from '../data/hooks'; +import EnvironmentSelector from './environmentSelector'; const Chip = styled(MuiChip)` cursor: pointer; @@ -69,6 +70,7 @@ const LoadingButton = styled(MuiLoadingButton)` interface AppConfig { bot_token?: string; org_url?: string; + env?: string; } const EditCredentials: React.FC<{ @@ -115,6 +117,10 @@ const EditCredentials: React.FC<{ } }; + const handleEnv = (val) => { + setAppConfig({ env: val }); + }; + React.useEffect(() => { if (status === 200) { handleClose({ refetchOnClose: true }); @@ -180,6 +186,18 @@ const EditCredentials: React.FC<{ /> )} + + {(app.tp_id === 'quickbooks' || app.tp_id === 'xero' || app.tp_id === 'lever') && ( + // do for greenhouse as well + + Environment: + handleEnv(val)} + environmentList={[{ env: 'Production' }, { env: 'Sandbox' }]} + /> + + )} {!(app.tp_id === 'closecrm' || app.tp_id === 'pipedrive' || app.tp_id === 'clickup') && ( Scopes: diff --git a/packages/client/src/home/environmentSelector.tsx b/packages/client/src/home/environmentSelector.tsx index 54e0928ab..871c9f668 100644 --- a/packages/client/src/home/environmentSelector.tsx +++ b/packages/client/src/home/environmentSelector.tsx @@ -35,7 +35,7 @@ export default function EnvironmentSelector({ environmentProp, setEnvironmentPro vertical: 'top', horizontal: 'left', }, - anchorEl: anchorRef.current, + // anchorEl: anchorRef.current, // FIXME: disablePortal: true, still doesn't fix the re-render issue PaperProps: { style: { diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index 56acd5d0c..dfd6348d6 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -617,7 +617,7 @@ const createIntegrationBlock = function (self, integration) { this.redirectToUrl(parsedData); return this.renderDoneStage(parsedData.integrationName); } - + const container = document.getElementById('revert-signin-container'); const poweredByBanner = createPoweredByBanner(this); poweredByBanner.style.position = 'absolute'; @@ -1174,7 +1174,161 @@ const createIntegrationBlock = function (self, integration) { return container; }; - handleIntegrationRedirect = function (selectedIntegration) { + showAndRemoveLoader = function () { + return new Promise((resolve) => { + //show loader + this.renderProcessingStage('Loading'); + + //remove loader + setTimeout(() => { + const loaderElement = document.querySelector('.loader'); + + if (loaderElement) { + const parentElement = loaderElement.parentElement; + if (parentElement) { + parentElement.remove(); + } + } + + // Check again if the loader element exists + const loaderElementAfterTimeout = document.querySelector('.loader'); + if (!loaderElementAfterTimeout) { + resolve(); + } + }, 250); + }); + }; + + apiKeyInputContainerFunction = function () { + const parentDiv = document.createElement('div'); + parentDiv.id = 'parentDiv'; + parentDiv.style.display = 'flex'; + parentDiv.style.flexDirection = 'column'; + parentDiv.style.alignItems = 'center'; + parentDiv.style.justifyContent = 'center'; + parentDiv.style.position = 'relative'; + parentDiv.style.width = '100%'; + + // Create heading + const heading = document.createElement('h3'); + heading.textContent = 'Enter your API key'; + heading.style.textAlign = 'center'; + heading.style.textDecoration = 'underline'; + heading.style.marginBottom = '25px'; + parentDiv.appendChild(heading); + + const inputParentContainer = document.createElement('div'); + inputParentContainer.style.display = 'flex'; + inputParentContainer.style.alignItems = 'end'; + inputParentContainer.style.justifyContent = 'space-between'; + inputParentContainer.style.width = '100%'; + inputParentContainer.style.marginTop = '20px'; + inputParentContainer.style.flexDirection = 'column'; + inputParentContainer.style.gap = '10px'; + + // Create input field and label container + const inputContainer = document.createElement('div'); + inputContainer.style.display = 'flex'; + inputContainer.style.alignItems = 'center'; + inputContainer.style.flexGrow = '1'; + inputContainer.style.width = '100%'; + + const apiKeyLabel = document.createElement('label'); + apiKeyLabel.textContent = 'API Key:'; + apiKeyLabel.setAttribute('for', 'api-key-input'); + apiKeyLabel.style.marginRight = '10px'; + apiKeyLabel.style.fontWeight = 'bold'; + + const apiKeyInput = document.createElement('input'); + apiKeyInput.setAttribute('type', 'text'); + apiKeyInput.setAttribute('id', 'api-key-input'); + apiKeyInput.style.flexGrow = '1'; + apiKeyInput.style.padding = '4px'; + + inputContainer.appendChild(apiKeyLabel); + inputContainer.appendChild(apiKeyInput); + + // Create submit button + const submitButton = document.createElement('button'); + submitButton.id = 'submitButtonBasicAuth'; + submitButton.textContent = 'Submit'; + submitButton.style.marginLeft = '10px'; + submitButton.style.background = 'rgb(39 45 192)'; + submitButton.style.borderRadius = '5px'; + submitButton.style.display = 'flex'; + submitButton.style.alignItems = 'center'; + submitButton.style.justifyContent = 'center'; + submitButton.style.padding = '10px'; + submitButton.style.color = '#fff'; + submitButton.style.cursor = 'pointer'; + submitButton.style.position = 'relative'; + submitButton.disabled = true; // Initially disable the button + submitButton.style.opacity = '0.5'; + + // Append input container and button to the inputParentContainer + inputParentContainer.appendChild(inputContainer); + inputParentContainer.appendChild(submitButton); + + // Append inputParentContainer to parentDiv + parentDiv.appendChild(inputParentContainer); + + return parentDiv; + }; + + modalForApiKeyInputBasicAuth = function () { + return new Promise((resolve, reject) => { + const container = document.getElementById('revert-signin-container'); + container.style.height = '534px'; + + // Remove all children of the container + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + this.showAndRemoveLoader().then(() => { + //close button + const closeButton = createCloseButton(); + closeButton.style.position = 'absolute'; + closeButton.style.right = '20px'; + closeButton.style.top = '20px'; + closeButton.addEventListener('click', () => { + reject('Modal closed by user'); + this.close(); + }); + + const apiKeyInputContainer = this.apiKeyInputContainerFunction(); + container.appendChild(closeButton); + container.appendChild(apiKeyInputContainer); + + const inputElementForApiInput = apiKeyInputContainer.querySelector('#api-key-input'); + const submitButtonForApiInputSubmission = + apiKeyInputContainer.querySelector('#submitButtonBasicAuth'); + + //event listener on input to change the disability of submit + inputElementForApiInput.addEventListener('input', function () { + if (inputElementForApiInput.value.trim() !== '') { + submitButtonForApiInputSubmission.disabled = false; + submitButtonForApiInputSubmission.style.opacity = '1'; + } else { + submitButtonForApiInputSubmission.disabled = true; + submitButtonForApiInputSubmission.style.opacity = '0.5'; + } + }); + + //event listener for submission go Api key + submitButtonForApiInputSubmission.addEventListener('click', () => { + submitButtonForApiInputSubmission.disabled = true; + submitButtonForApiInputSubmission.style.opacity = '0.5'; + + const apiKey = inputElementForApiInput.value; + + resolve(apiKey); + }); + }); + }); + }; + + handleIntegrationRedirect = async function (selectedIntegration) { if (selectedIntegration) { const scopes = selectedIntegration.scopes; const state = JSON.stringify({ @@ -1295,6 +1449,65 @@ const createIntegrationBlock = function (self, integration) { selectedIntegration.clientId }&response_type=code&state=${encodeURIComponent(state)}` ); + } else if (selectedIntegration.integrationId === 'greenhouse') { + const apiKey = await this.modalForApiKeyInputBasicAuth(); + const url = `${this.CORE_API_BASE_URL}v1/ats/oauth-callback?integrationId=${ + selectedIntegration.integrationId + }&t_id=${this.tenantId}&code=${apiKey}&x_revert_public_token=${this.API_REVERT_PUBLIC_TOKEN}${ + this.#USER_REDIRECT_URL ? `&redirectUrl=${this.#USER_REDIRECT_URL}` : `` + }`; + fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then((d) => { + return d.json(); + }) + .then((data) => { + if (data.error) { + const errorMessage = + data.error?.code === 'P2002' + ? ': Already connected another CRM. Please disconnect first.' + : ''; + + console.log('error:', errorMessage); + } else { + console.log('OAuth flow succeeded', data); + } + }) + .catch((error) => { + console.log(error); + return this.renderFailedStage(); + }); + } else if (selectedIntegration.integrationId === 'lever') { + const encodedScopes = encodeURIComponent(scopes.join(' ')); + const encodedRedirectUri = encodeURI(`${this.#REDIRECT_URL_BASE}/lever`); + + fetch( + `${this.CORE_API_BASE_URL}ats/lever-app_config?revertPublicToken=${this.API_REVERT_PUBLIC_TOKEN}` + ) + .then((data) => data.json()) + .then((data) => { + if (data.env === 'Sandbox') { + window.open( + `https://sandbox-lever.auth0.com/authorize?client_id=${ + selectedIntegration.clientId + }&redirect_uri=${encodedRedirectUri}&response_type=code&state=${encodeURIComponent( + state + )}&prompt=consent&scope=${encodedScopes}&audience=https://api.sandbox.lever.co/v1/` + ); + } else { + window.open( + `https://auth.lever.co/authorize?client_id=${ + selectedIntegration.clientId + }&redirect_uri=${encodedRedirectUri}&response_type=code&state=${encodeURIComponent( + state + )}&prompt=consent&scope=${encodedScopes}&audience=https://api.lever.co/v1/` + ); + } + }); } else if (selectedIntegration.integrationId === 'github') { const encodedScopes = encodeURIComponent(scopes.join(',')); const encodedRedirectUri = encodeURI(`${this.#REDIRECT_URL_BASE}/github`); @@ -1337,6 +1550,7 @@ const createIntegrationBlock = function (self, integration) { evtSource.close(); const tenantToken = parsedData.tenantSecretToken; // fetch field mapping + fetch(`${this.CORE_API_BASE_URL}field-mapping`, { mode: 'cors' as RequestMode, method: 'GET',