diff --git a/fern/definition/ats/candidate.yml b/fern/definition/ats/candidate.yml new file mode 100644 index 000000000..91fc38731 --- /dev/null +++ b/fern/definition/ats/candidate.yml @@ -0,0 +1,65 @@ +imports: + errors: ../common/errors.yml + types: ../common/types.yml + unified: ../common/unified.yml + +types: + GetCandidateResponse: + properties: + status: types.ResponseStatus + result: unified.Candidate + GetCandidatesResponse: + properties: + status: types.ResponseStatus + next: optional + previous: optional + results: list + +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 diff --git a/fern/definition/ats/department.yml b/fern/definition/ats/department.yml new file mode 100644 index 000000000..7e65f5ddf --- /dev/null +++ b/fern/definition/ats/department.yml @@ -0,0 +1,65 @@ +imports: + errors: ../common/errors.yml + types: ../common/types.yml + unified: ../common/unified.yml + +types: + GetDepartmentResponse: + properties: + status: types.ResponseStatus + result: unified.Department + GetDepartmentsResponse: + properties: + status: types.ResponseStatus + next: optional + previous: optional + results: list + +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 diff --git a/fern/definition/ats/job.yml b/fern/definition/ats/job.yml new file mode 100644 index 000000000..2e5162b93 --- /dev/null +++ b/fern/definition/ats/job.yml @@ -0,0 +1,65 @@ +imports: + errors: ../common/errors.yml + types: ../common/types.yml + unified: ../common/unified.yml + +types: + GetJobResponse: + properties: + status: types.ResponseStatus + result: unified.Job + GetJobsResponse: + properties: + status: types.ResponseStatus + next: optional + previous: optional + results: list + +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 diff --git a/fern/definition/ats/offer.yml b/fern/definition/ats/offer.yml new file mode 100644 index 000000000..b6f684a86 --- /dev/null +++ b/fern/definition/ats/offer.yml @@ -0,0 +1,65 @@ +imports: + errors: ../common/errors.yml + types: ../common/types.yml + unified: ../common/unified.yml + +types: + GetOfferResponse: + properties: + status: types.ResponseStatus + result: unified.Offer + GetOffersResponse: + properties: + status: types.ResponseStatus + next: optional + previous: optional + results: list + +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 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/packages/backend/.env.example b/packages/backend/.env.example index 78b7ded93..635990a55 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -45,4 +45,7 @@ OPEN_INT_API_KEY= TWENTY_ACCOUNT_ID= BITBUCKET_CLIENT_ID= BITBUCKET_CLIENT_SECRET= -DEFAULT_RATE_LIMIT_DEVELOPER_PLAN= \ No newline at end of file +DEFAULT_RATE_LIMIT_DEVELOPER_PLAN= +WORKABLE_CLIENT_ID= +WORKABLE_CLIENT_SECRET= +WORKABLE_ORG_URL= \ No newline at end of file diff --git a/packages/backend/config.ts b/packages/backend/config.ts index 870e9f5f1..56625a4e3 100644 --- a/packages/backend/config.ts +++ b/packages/backend/config.ts @@ -54,6 +54,9 @@ const config = { OPEN_INT_BASE_API_URL: process.env.OPEN_INT_BASE_API_URL, TWENTY_ACCOUNT_ID: process.env.TWENTY_ACCOUNT_ID, DEFAULT_RATE_LIMIT_DEVELOPER_PLAN: process.env.DEFAULT_RATE_LIMIT_DEVELOPER_PLAN, + WORKABLE_CLIENT_ID: process.env.WORKABLE_CLIENT_ID!, + WORKABLE_CLIENT_SECRET: process.env.WORKABLE_CLIENT_SECRET!, + WORKABLE_ORG_URL: process.env.WORKABLE_ORG_URL!, }; export default config; diff --git a/packages/backend/constants/common.ts b/packages/backend/constants/common.ts index 9cabaf3e5..9e542b44a 100644 --- a/packages/backend/constants/common.ts +++ b/packages/backend/constants/common.ts @@ -4,6 +4,7 @@ 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 TICKET_TP_ID = 'linear' | 'clickup' | 'asana' | 'jira' | 'trello' | 'bitbucket'; +export type ATS_TP_ID = 'workable'; export const DEFAULT_SCOPE = { [TP_ID.hubspot]: [ @@ -49,6 +50,7 @@ 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.workable]: [], }; export const mapIntegrationIdToIntegrationName = { @@ -66,6 +68,7 @@ export const mapIntegrationIdToIntegrationName = { [TP_ID.jira]: 'Jira', [TP_ID.ms_dynamics_365_sales]: 'Microsoft Dynamics 365 Sales', [TP_ID.bitbucket]: 'Bitbucket', + [TP_ID.workable]: 'Workable', }; export const rootSchemaMappingId = 'revertRootSchemaMapping'; @@ -93,6 +96,13 @@ export enum TicketStandardObjects { ticketComment = 'ticketComment', } +export enum AtsStandardObjects { + jobs = 'jobs', + offers = 'offers', + candidates = 'candidates', + departments = 'departments', +} + export const objectNameMapping: Record> = { [StandardObjects.company]: { [TP_ID.hubspot]: 'companies', diff --git a/packages/backend/helpers/crm/transform/preprocess.ts b/packages/backend/helpers/crm/transform/preprocess.ts index a03c7c044..675c3dd3f 100644 --- a/packages/backend/helpers/crm/transform/preprocess.ts +++ b/packages/backend/helpers/crm/transform/preprocess.ts @@ -1,5 +1,6 @@ import { TP_ID } from '@prisma/client'; import { + AtsStandardObjects, CRM_TP_ID, ChatStandardObjects, StandardObjects, @@ -17,7 +18,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]: { diff --git a/packages/backend/helpers/crm/transform/transformSchemaMapping.ts b/packages/backend/helpers/crm/transform/transformSchemaMapping.ts index 21e6db2a1..324c5d162 100644 --- a/packages/backend/helpers/crm/transform/transformSchemaMapping.ts +++ b/packages/backend/helpers/crm/transform/transformSchemaMapping.ts @@ -1,6 +1,7 @@ import { get, merge } from 'lodash'; import { Prisma, PrismaClient, TP_ID, accountFieldMappingConfig } from '@prisma/client'; import { + AtsStandardObjects, ChatStandardObjects, StandardObjects, TicketStandardObjects, @@ -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; }) => { @@ -85,7 +86,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 c520d3f70..9946eadf2 100644 --- a/packages/backend/helpers/crm/transform/unify.ts +++ b/packages/backend/helpers/crm/transform/unify.ts @@ -1,5 +1,11 @@ import { accountFieldMappingConfig } from '@prisma/client'; -import { CRM_TP_ID, ChatStandardObjects, StandardObjects, TicketStandardObjects } from '../../../constants/common'; +import { + AtsStandardObjects, + CRM_TP_ID, + ChatStandardObjects, + StandardObjects, + TicketStandardObjects, +} from '../../../constants/common'; import { transformFieldMappingToModel } from '.'; import { preprocessUnifyObject } from './preprocess'; @@ -12,7 +18,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/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..f6dc4eed4 --- /dev/null +++ b/packages/backend/models/unified/candidate.ts @@ -0,0 +1,62 @@ +import { UnifiedJob } from './job'; + +export interface UnifiedCandidate { + id: string; + created_at: Date; + modified_at: Date; + first_name: string; + last_name: string; + last_interaction_at: Date; + is_private: boolean; + can_email: boolean; + location: { + name: string; + }[]; + phone_numbers: { value: string }[]; + email_addresses: { value: string }[]; + tags: string[]; + attachments: { filename: string; url: string; type: string; created_at: Date }[]; + applications: applications[]; + company: string; + title: string; + additional: any; +} + +interface applications { + id: number; + candidate_id: number; + prospect: boolean; + applied_at: string; + rejected_at: string; + last_activity_at: string; + location: { address: string }; + source: { id: number; public_name: string }; + credited_to: { id: number; first_name: string; last_name: string; name: string; employee_id: string }; + rejection_reason: string; + rejection_details: string; + jobs: UnifiedJob[]; + job_post_id: number; + status: string; + current_stage: { + id: number; + name: string; + }; + answers: { + question: string; + answer: string; + }[]; + 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: { name: string; type: string; value: string }; + }; + attachments: { filename: string; url: string; type: string; created_at: Date }[]; +} diff --git a/packages/backend/models/unified/department.ts b/packages/backend/models/unified/department.ts new file mode 100644 index 000000000..03ed18fd2 --- /dev/null +++ b/packages/backend/models/unified/department.ts @@ -0,0 +1,11 @@ +export interface UnifiedDepartment { + id: string; + name: string; + child_ids: string[]; + child_department_external_ids: string[]; + created_at: Date; + modified_at: Date; + parent_id: string; + parent_department_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..b8a171eb7 --- /dev/null +++ b/packages/backend/models/unified/job.ts @@ -0,0 +1,37 @@ +import { UnifiedDepartment } from './department'; + +export interface UnifiedJob { + id: string; + created_at: Date; + modified_at: Date; + name: string; + confidential: boolean; + departments: UnifiedDepartment[]; + offices: { + id: number; + name: string; + location: { + name: string; + }; + parent_id: number; + child_ids: number[]; + external_id: string; + }[]; + hiring_managers: { + id: number; + first_name: string; + last_name: string; + name: string; + employee_id: string; + responsible: boolean; + }[]; + recruiters: { + id: number; + first_name: string; + last_name: string; + name: string; + employee_id: string; + responsible: boolean; + }[]; + additional: any; +} diff --git a/packages/backend/models/unified/offer.ts b/packages/backend/models/unified/offer.ts new file mode 100644 index 000000000..932e23373 --- /dev/null +++ b/packages/backend/models/unified/offer.ts @@ -0,0 +1,11 @@ +export interface UnifiedOffer { + id: string; + created_at: Date; + modified_at: Date; + application: string; + closed_at: Date; + sent_at: Date; + start_date: Date; + status: string; + additional: any; +} diff --git a/packages/backend/prisma/migrations/20240513094018_workable_enum/migration.sql b/packages/backend/prisma/migrations/20240513094018_workable_enum/migration.sql new file mode 100644 index 000000000..1afcc5d12 --- /dev/null +++ b/packages/backend/prisma/migrations/20240513094018_workable_enum/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "TP_ID" ADD VALUE 'workable'; diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index 1d8b479d5..391e3dfb5 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -22,6 +22,7 @@ enum TP_ID { trello jira bitbucket + workable } enum ENV { diff --git a/packages/backend/routes/v1/ats/auth.ts b/packages/backend/routes/v1/ats/auth.ts new file mode 100644 index 000000000..a575c2166 --- /dev/null +++ b/packages/backend/routes/v1/ats/auth.ts @@ -0,0 +1,112 @@ +import express from 'express'; +import { randomUUID } from 'crypto'; +import { logInfo } from '../../../helpers/logger'; +import { mapIntegrationIdToIntegrationName } from '../../../constants/common'; +import redis from '../../../redis/client'; +import { TP_ID } from '@prisma/client'; +import prisma from '../../../prisma/client'; +import processOAuthResult from '../../../helpers/auth/processOAuthResult'; +import workable from './authHandlers/workable'; +import AuthService from '../../../services/auth'; + +const authRouter = express.Router(); + +authRouter.get('/oauth-callback', async (req, res) => { + logInfo('OAuth callback', req.query); + const integrationId = req.query.integrationId as 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(); + 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 authProps = { + account, + clientId, + clientSecret, + code: req.query.code as string, + 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.workable: + return workable.handleOAuth(authProps); + + default: + return processOAuthResult({ + status: false, + revertPublicKey, + tenantSecretToken, + response: res, + tenantId: req.query.t_id as string, + statusText: 'Not implemented yet', + redirectUrl, + }); + } + } + + 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, + }); + } +}); + +authRouter.get('/oauth/refresh', async (_, res) => { + res.status(200).send(await AuthService.refreshOAuthTokensForThirdPartyAtsServices()); +}); +export default authRouter; diff --git a/packages/backend/routes/v1/ats/authHandlers/workable.ts b/packages/backend/routes/v1/ats/authHandlers/workable.ts new file mode 100644 index 000000000..9962e47be --- /dev/null +++ b/packages/backend/routes/v1/ats/authHandlers/workable.ts @@ -0,0 +1,122 @@ +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 { AppConfig, IntegrationAuthProps, mapIntegrationIdToIntegrationName } from '../../../../constants/common'; +import processOAuthResult from '../../../../helpers/auth/processOAuthResult'; +import sendConnectionAddedEvent from '../../../../helpers/webhooks/connection'; +import BaseOAuthHandler from '../../../../helpers/auth/baseOAuthHandler'; + +class WorkableAuthHandler extends BaseOAuthHandler { + async handleOAuth({ + account, + clientId, + clientSecret, + code, + integrationId, + revertPublicKey, + svixAppId, + environmentId, + tenantId, + tenantSecretToken, + response, + redirectUrl, + }: IntegrationAuthProps) { + let orgURL = account?.apps[0]?.is_revert_app ? undefined : (account?.apps[0]?.app_config as AppConfig)?.org_url; + if (!orgURL) orgURL = config.WORKABLE_ORG_URL; + + const formData = { + grant_type: 'authorization_code', + client_id: clientId || config.WORKABLE_CLIENT_ID, + client_secret: clientSecret || config.WORKABLE_CLIENT_SECRET, + code: code, + redirect_uri: `${config.OAUTH_REDIRECT_BASE}/workable`, + }; + + const result = await axios({ + method: 'post', + url: 'https://www.workable.com/oauth/token', + headers: { + 'Content-Type': 'application/json', + }, + data: qs.stringify(formData), + }); + + logInfo('OAuth token from Workable', result.data); + + const auth = 'Bearer ' + result.data?.access_token; + + const info = await axios({ + method: 'GET', + url: `https://www.workable.com/spi/v3/accounts`, + headers: { + Authorization: auth, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + logInfo('User info', info.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: info.data?.id, + app_client_id: clientId || config.WORKABLE_CLIENT_ID, + app_client_secret: clientSecret || config.WORKABLE_CLIENT_SECRET, + owner_account_public_token: revertPublicKey, + appId: account?.apps[0].id, + environmentId: environmentId, + app_config: { org_url: orgURL || config.WORKABLE_ORG_URL }, + tp_account_url: orgURL, + }, + update: { + tp_access_token: result.data.access_token, + tp_refresh_token: result.data.refresh_token, + app_client_id: clientId || config.WORKABLE_CLIENT_ID, + app_client_secret: clientSecret || config.WORKABLE_CLIENT_SECRET, + tp_id: integrationId, + appId: account?.apps[0].id, + tp_customer_id: info.data?.id, + app_config: { org_url: orgURL || config.WORKABLE_ORG_URL }, + tp_account_url: orgURL, + }, + }); + + sendConnectionAddedEvent(svixAppId, tenantId, TP_ID.workable, result.data.access_token, info.data?.id); + + return processOAuthResult({ + status: true, + revertPublicKey, + tenantSecretToken, + response, + tenantId: tenantId, + integrationName: mapIntegrationIdToIntegrationName[integrationId], + tpCustomerId: info.data?.id, + redirectUrl, + }); + } catch (error: any) { + return processOAuthResult({ + status: false, + error, + revertPublicKey, + tenantSecretToken, + response, + tenantId: tenantId, + integrationName: mapIntegrationIdToIntegrationName[integrationId], + redirectUrl, + }); + } + } +} + +export default new WorkableAuthHandler(); diff --git a/packages/backend/routes/v1/ats/index.ts b/packages/backend/routes/v1/ats/index.ts new file mode 100644 index 000000000..99a3901bd --- /dev/null +++ b/packages/backend/routes/v1/ats/index.ts @@ -0,0 +1,34 @@ +import express from 'express'; +import { createSession } from 'better-sse'; +import authRouter from './auth'; +import pubsub, { IntegrationStatusSseMessage, PUBSUB_CHANNELS } from '../../../redis/client/pubsub'; +import { logDebug, logError } from '../../../helpers/logger'; + +const atsRouter = express.Router(); + +atsRouter.get('/ping', async (_, res) => { + res.send({ + status: 'ok', + message: 'PONG', + }); +}); + +atsRouter.use('/', authRouter); + +atsRouter.get('/integration-status/:publicToken', async (req, res) => { + try { + const publicToken = req.params.publicToken; + const { tenantId } = req.query; + const session = await createSession(req, res); + await pubsub.subscribe(`${PUBSUB_CHANNELS.INTEGRATION_STATUS}_${tenantId}`, async (message: any) => { + logDebug('pubsub message', message); + let parsedMessage = JSON.parse(message) as IntegrationStatusSseMessage; + if (parsedMessage.publicToken === publicToken) { + session.push(JSON.stringify(parsedMessage)); + } + }); + } catch (err: any) { + logError(err); + } +}); +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..c521ad8e3 --- /dev/null +++ b/packages/backend/services/ats/candidate.ts @@ -0,0 +1,157 @@ +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 { AtsStandardObjects } from 'constants/common'; +import { unifyObject } from 'helpers/crm/transform'; +import { UnifiedCandidate } from 'models/unified/candidate'; + +const objType = AtsStandardObjects.candidates; + +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.workable: { + const result = await axios({ + method: 'get', + url: ` ${connection.tp_account_url}/spi/v3/candidates/${candidateId}`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${thirdPartyToken}`, + }, + }); + + 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; + } + + 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 = 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.workable: { + let pagingString = `${pageSize ? `&limit=${pageSize}` : ''}${ + cursor ? `&since_id=${cursor}` : '' + }`; + const additionalParamsString = Object.keys(fields) + .map((key) => `${key}=${fields[key]}`) + .join('&'); + + const otherParams = additionalParamsString ? `&${additionalParamsString}` : ''; + + const result = await axios({ + method: 'get', + url: ` ${connection.tp_account_url}/spi/v3/candidates?${otherParams}${pagingString}`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${thirdPartyToken}`, + }, + }); + + const unifiedCandidates = await Promise.all( + result.data.candidates.map(async (candidate: any) => { + return await unifyObject({ + obj: candidate, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + }) + ); + + const sinceIdMatch = result.data?.paging?.next.match(/[?&]since_id=([^&]+)/); + + const nextCursor = sinceIdMatch ? sinceIdMatch[1] : undefined; + const previousCursor = undefined; + + res.send({ + status: 'ok', + next: nextCursor, + previous: previousCursor, + 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' }); + } + }, + }, + [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..c8a1b5af0 --- /dev/null +++ b/packages/backend/services/ats/department.ts @@ -0,0 +1,141 @@ +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 { AtsStandardObjects } from 'constants/common'; +import { unifyObject } from 'helpers/crm/transform'; +import { UnifiedDepartment } from 'models/unified/department'; + +const objType = AtsStandardObjects.departments; + +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.workable: { + res.send({ + status: 'ok', + results: '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 = 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.workable: { + //PAGINATION is NOT yet supported for this endpoint in workable. + + // let pagingString = `${pageSize ? `&limit=${pageSize}` : ''}${ + // cursor ? `&since_id=${cursor}` : '' + // }`; + // const additionalParamsString = Object.keys(fields) + // .map((key) => `${key}=${fields[key]}`) + // .join('&'); + + // const otherParams = additionalParamsString ? `&${additionalParamsString}` : ''; + + const result = await axios({ + method: 'get', + url: ` ${connection.tp_account_url}/spi/v3/departments`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${thirdPartyToken}`, + }, + }); + + 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 sinceIdMatch = result.data?.paging?.next.match(/[?&]since_id=([^&]+)/); + + // const nextCursor = sinceIdMatch ? sinceIdMatch[1] : undefined; + // const previousCursor = undefined; + + res.send({ + status: 'ok', + // next: nextCursor, + // previous: previousCursor, + 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' }); + } + }, + }, + [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..fd3361d1a --- /dev/null +++ b/packages/backend/services/ats/job.ts @@ -0,0 +1,157 @@ +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 { AtsStandardObjects } from 'constants/common'; +import { unifyObject } from 'helpers/crm/transform'; +import { UnifiedJob } from 'models/unified/job'; + +const objType = AtsStandardObjects.jobs; + +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.workable: { + const result = await axios({ + method: 'get', + url: ` ${connection.tp_account_url}/spi/v3/jobs/${jobId}`, //jobId param for workable is "shortcode" and not "id" + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${thirdPartyToken}`, + }, + }); + + 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; + } + + 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 = 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.workable: { + let pagingString = `${pageSize ? `&limit=${pageSize}` : ''}${ + cursor ? `&since_id=${cursor}` : '' + }`; + const additionalParamsString = Object.keys(fields) + .map((key) => `${key}=${fields[key]}`) + .join('&'); + + const otherParams = additionalParamsString ? `&${additionalParamsString}` : ''; + + const result = await axios({ + method: 'get', + url: ` ${connection.tp_account_url}/spi/v3/jobs?${otherParams}${pagingString}`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${thirdPartyToken}`, + }, + }); + + const unifiedJobs = await Promise.all( + result.data.jobs.map(async (job: any) => { + return await unifyObject({ + obj: job, + tpId: thirdPartyId, + objType, + tenantSchemaMappingId: connection.schema_mapping_id, + accountFieldMappingConfig: account.accountFieldMappingConfig, + }); + }) + ); + + const sinceIdMatch = result.data?.paging?.next.match(/[?&]since_id=([^&]+)/); + + const nextCursor = sinceIdMatch ? sinceIdMatch[1] : undefined; + const previousCursor = undefined; + + res.send({ + status: 'ok', + next: nextCursor, + previous: previousCursor, + 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' }); + } + }, + }, + [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..d27f7dd1c --- /dev/null +++ b/packages/backend/services/ats/offer.ts @@ -0,0 +1,118 @@ +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 { AtsStandardObjects } from 'constants/common'; +import { unifyObject } from 'helpers/crm/transform'; +import { UnifiedOffer } from 'models/unified/offer'; + +const objType = AtsStandardObjects.offers; + +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.workable: { + const result = await axios({ + method: 'get', + url: ` ${connection.tp_account_url}/spi/v3/offers/${offerId}`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${thirdPartyToken}`, + }, + }); + + 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; + } + + 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 = 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.workable: { + res.send({ + status: 'ok', + results: 'This endpoint is currently not supported.', + }); + 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' }); + } + }, + }, + [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..ab6dab60a --- /dev/null +++ b/packages/backend/services/ats/proxy.ts @@ -0,0 +1,67 @@ +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'; + +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.workable: { + const result: any = await axios({ + method: method, + url: `${connection.tp_account_url}/spi/v3/${path}`, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${thirdPartyToken}`, + }, + 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..21e4d1be4 100644 --- a/packages/backend/services/auth.ts +++ b/packages/backend/services/auth.ts @@ -379,6 +379,64 @@ 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.workable) { + const formData = { + grant_type: 'refresh_token', + client_id: connection.app?.is_revert_app + ? config.WORKABLE_CLIENT_ID + : connection.app_client_id || config.WORKABLE_CLIENT_ID, + client_secret: connection.app?.is_revert_app + ? config.WORKABLE_CLIENT_SECRET + : connection.app_client_secret || config.WORKABLE_CLIENT_SECRET, + refresh_token: connection.tp_refresh_token, + }; + const result: any = await axios({ + method: 'post', + url: 'https://www.workable.com/oauth/token', + data: JSON.stringify(formData), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + + if (result.data && result.data.access_token && result.data.refresh_token) { + await prisma.connections.update({ + where: { + id: connection.id, + }, + data: { + tp_access_token: result.data.access_token, + tp_refresh_token: result.data.refresh_token, + }, + }); + } else { + logInfo('Workable connection could not be refreshed', result); + } + } + } 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); diff --git a/packages/backend/services/metadata.ts b/packages/backend/services/metadata.ts index e7a66b956..a3326be92 100644 --- a/packages/backend/services/metadata.ts +++ b/packages/backend/services/metadata.ts @@ -156,6 +156,15 @@ const metadataService = new MetadataService({ scopes: getScope(apps, TP_ID.bitbucket), clientId: getClientId(apps, TP_ID.bitbucket) || config.BITBUCKET_CLIENT_ID, }, + { + integrationId: TP_ID.workable, + name: 'Workable', + imageSrc: + 'https://res.cloudinary.com/dfcnic8wq/image/upload/v1711468866/Revert/kuebcmuhxznyjqzl6pvb.svg', + status: 'active', + scopes: getScope(apps, TP_ID.workable), + clientId: getClientId(apps, TP_ID.workable) || config.WORKABLE_CLIENT_ID, + }, ]; res.send({ status: 'ok', diff --git a/packages/client/src/common/oauth/index.tsx b/packages/client/src/common/oauth/index.tsx index 5640cd3b9..1990bcd21 100644 --- a/packages/client/src/common/oauth/index.tsx +++ b/packages/client/src/common/oauth/index.tsx @@ -484,6 +484,45 @@ export const OAuthCallback = (props) => { console.error(err); setStatus('Errored out'); }); + } else if (integrationId === 'workable') { + console.log('Post ats 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.'); + window.close(); + } + 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 43f306615..1bac9bcb1 100644 --- a/packages/client/src/features/integration/enums/metadata.ts +++ b/packages/client/src/features/integration/enums/metadata.ts @@ -67,6 +67,11 @@ export const appsInfo = { logo: 'https://res.cloudinary.com/dfcnic8wq/image/upload/v1711549311/Revert/cmqpors8m8tid9zpn9ak.png', description: 'Configure your Bitbucket Ticketing App from here.', }, + workable: { + name: 'Workable', + logo: 'https://res.cloudinary.com/dfcnic8wq/image/upload/v1711549311/Revert/cmqpors8m8tid9zpn9ak.png', + description: 'Configure your Workable ATS App from here.', + }, // asana: { // name: 'Asana', // logo: '', diff --git a/packages/client/src/home/editCredentials.tsx b/packages/client/src/home/editCredentials.tsx index 9c0ec4a3a..fd06cbc83 100644 --- a/packages/client/src/home/editCredentials.tsx +++ b/packages/client/src/home/editCredentials.tsx @@ -112,6 +112,8 @@ const EditCredentials: React.FC<{ setAppConfig({ bot_token: val }); } else if (app.tp_id === 'ms_dynamics_365_sales') { setAppConfig({ org_url: val }); + } else if (app.tp_id === 'workable') { + setAppConfig({ org_url: val }); } }; @@ -169,7 +171,7 @@ const EditCredentials: React.FC<{ /> )} - {app.tp_id === 'ms_dynamics_365_sales' && ( + {(app.tp_id === 'ms_dynamics_365_sales' || app.tp_id === 'workable') && ( Organisation URL: