diff --git a/apps/sim/blocks/blocks/greenhouse.ts b/apps/sim/blocks/blocks/greenhouse.ts index 4df4963fca4..4df34ef87cb 100644 --- a/apps/sim/blocks/blocks/greenhouse.ts +++ b/apps/sim/blocks/blocks/greenhouse.ts @@ -1,6 +1,7 @@ import { GreenhouseIcon } from '@/components/icons' import { AuthMode, type BlockConfig, IntegrationType } from '@/blocks/types' import type { GreenhouseResponse } from '@/tools/greenhouse/types' +import { getTrigger } from '@/triggers' export const GreenhouseBlock: BlockConfig = { type: 'greenhouse', @@ -16,6 +17,20 @@ export const GreenhouseBlock: BlockConfig = { icon: GreenhouseIcon, authMode: AuthMode.ApiKey, + triggers: { + enabled: true, + available: [ + 'greenhouse_candidate_hired', + 'greenhouse_new_application', + 'greenhouse_candidate_stage_change', + 'greenhouse_candidate_rejected', + 'greenhouse_offer_created', + 'greenhouse_job_created', + 'greenhouse_job_updated', + 'greenhouse_webhook', + ], + }, + subBlocks: [ { id: 'operation', @@ -291,6 +306,17 @@ Return ONLY the ISO 8601 timestamp - no explanations, no extra text.`, required: true, password: true, }, + + // ── Trigger subBlocks ── + + ...getTrigger('greenhouse_candidate_hired').subBlocks, + ...getTrigger('greenhouse_new_application').subBlocks, + ...getTrigger('greenhouse_candidate_stage_change').subBlocks, + ...getTrigger('greenhouse_candidate_rejected').subBlocks, + ...getTrigger('greenhouse_offer_created').subBlocks, + ...getTrigger('greenhouse_job_created').subBlocks, + ...getTrigger('greenhouse_job_updated').subBlocks, + ...getTrigger('greenhouse_webhook').subBlocks, ], tools: { diff --git a/apps/sim/lib/webhooks/providers/greenhouse.ts b/apps/sim/lib/webhooks/providers/greenhouse.ts new file mode 100644 index 00000000000..65f3090dee8 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/greenhouse.ts @@ -0,0 +1,80 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { isGreenhouseEventMatch } from '@/triggers/greenhouse/utils' + +const logger = createLogger('WebhookProvider:Greenhouse') + +/** + * Validates the Greenhouse HMAC-SHA256 signature. + * Greenhouse sends: `Signature: sha256 ` + */ +function validateGreenhouseSignature(secretKey: string, signature: string, body: string): boolean { + try { + if (!secretKey || !signature || !body) { + return false + } + const prefix = 'sha256 ' + if (!signature.startsWith(prefix)) { + return false + } + const providedDigest = signature.substring(prefix.length) + const computedDigest = crypto.createHmac('sha256', secretKey).update(body, 'utf8').digest('hex') + return safeCompare(computedDigest, providedDigest) + } catch { + logger.error('Error validating Greenhouse signature') + return false + } +} + +export const greenhouseHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'secretKey', + headerName: 'signature', + validateFn: validateGreenhouseSignature, + providerLabel: 'Greenhouse', + }), + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + action: b.action, + payload: b.payload || {}, + }, + } + }, + + async matchEvent({ webhook, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const b = body as Record + const action = b.action as string | undefined + + if (triggerId && triggerId !== 'greenhouse_webhook') { + if (!isGreenhouseEventMatch(triggerId, action || '')) { + logger.debug( + `[${requestId}] Greenhouse event mismatch for trigger ${triggerId}. Action: ${action}. Skipping execution.`, + { + webhookId: webhook.id, + triggerId, + receivedAction: action, + } + ) + + return NextResponse.json({ + message: 'Event type does not match trigger configuration. Ignoring.', + }) + } + } + + return true + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 00ae58a21b1..f93dc478c97 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -14,6 +14,7 @@ import { githubHandler } from '@/lib/webhooks/providers/github' import { gmailHandler } from '@/lib/webhooks/providers/gmail' import { googleFormsHandler } from '@/lib/webhooks/providers/google-forms' import { grainHandler } from '@/lib/webhooks/providers/grain' +import { greenhouseHandler } from '@/lib/webhooks/providers/greenhouse' import { hubspotHandler } from '@/lib/webhooks/providers/hubspot' import { imapHandler } from '@/lib/webhooks/providers/imap' import { jiraHandler } from '@/lib/webhooks/providers/jira' @@ -50,6 +51,7 @@ const PROVIDER_HANDLERS: Record = { google_forms: googleFormsHandler, fathom: fathomHandler, grain: grainHandler, + greenhouse: greenhouseHandler, hubspot: hubspotHandler, imap: imapHandler, jira: jiraHandler, diff --git a/apps/sim/lib/workflows/triggers/trigger-utils.ts b/apps/sim/lib/workflows/triggers/trigger-utils.ts index 276e28ce889..f1ee5ce7450 100644 --- a/apps/sim/lib/workflows/triggers/trigger-utils.ts +++ b/apps/sim/lib/workflows/triggers/trigger-utils.ts @@ -74,7 +74,12 @@ function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 1 return null } - if (field && typeof field === 'object' && 'type' in field) { + if ( + field && + typeof field === 'object' && + 'type' in field && + typeof (field as Record).type === 'string' + ) { const typedField = field as { type: string; description?: string } return generateMockValue(typedField.type, typedField.description, key) } diff --git a/apps/sim/triggers/greenhouse/candidate_hired.ts b/apps/sim/triggers/greenhouse/candidate_hired.ts new file mode 100644 index 00000000000..0805bddcbbe --- /dev/null +++ b/apps/sim/triggers/greenhouse/candidate_hired.ts @@ -0,0 +1,41 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildCandidateHiredOutputs, + buildGreenhouseExtraFields, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse Candidate Hired Trigger + * + * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type. + * Fires when a candidate is marked as hired in Greenhouse. + */ +export const greenhouseCandidateHiredTrigger: TriggerConfig = { + id: 'greenhouse_candidate_hired', + name: 'Greenhouse Candidate Hired', + provider: 'greenhouse', + description: 'Trigger workflow when a candidate is hired', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_candidate_hired', + triggerOptions: greenhouseTriggerOptions, + includeDropdown: true, + setupInstructions: greenhouseSetupInstructions('Candidate Hired'), + extraFields: buildGreenhouseExtraFields('greenhouse_candidate_hired'), + }), + + outputs: buildCandidateHiredOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/greenhouse/candidate_rejected.ts b/apps/sim/triggers/greenhouse/candidate_rejected.ts new file mode 100644 index 00000000000..52f10c038e4 --- /dev/null +++ b/apps/sim/triggers/greenhouse/candidate_rejected.ts @@ -0,0 +1,39 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildCandidateRejectedOutputs, + buildGreenhouseExtraFields, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse Candidate Rejected Trigger + * + * Fires when a candidate is rejected from a position. + */ +export const greenhouseCandidateRejectedTrigger: TriggerConfig = { + id: 'greenhouse_candidate_rejected', + name: 'Greenhouse Candidate Rejected', + provider: 'greenhouse', + description: 'Trigger workflow when a candidate is rejected', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_candidate_rejected', + triggerOptions: greenhouseTriggerOptions, + setupInstructions: greenhouseSetupInstructions('Candidate Rejected'), + extraFields: buildGreenhouseExtraFields('greenhouse_candidate_rejected'), + }), + + outputs: buildCandidateRejectedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/greenhouse/candidate_stage_change.ts b/apps/sim/triggers/greenhouse/candidate_stage_change.ts new file mode 100644 index 00000000000..d9f9505f40d --- /dev/null +++ b/apps/sim/triggers/greenhouse/candidate_stage_change.ts @@ -0,0 +1,39 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildCandidateStageChangeOutputs, + buildGreenhouseExtraFields, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse Candidate Stage Change Trigger + * + * Fires when a candidate moves to a different interview stage. + */ +export const greenhouseCandidateStageChangeTrigger: TriggerConfig = { + id: 'greenhouse_candidate_stage_change', + name: 'Greenhouse Candidate Stage Change', + provider: 'greenhouse', + description: 'Trigger workflow when a candidate changes interview stages', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_candidate_stage_change', + triggerOptions: greenhouseTriggerOptions, + setupInstructions: greenhouseSetupInstructions('Candidate Stage Change'), + extraFields: buildGreenhouseExtraFields('greenhouse_candidate_stage_change'), + }), + + outputs: buildCandidateStageChangeOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/greenhouse/index.ts b/apps/sim/triggers/greenhouse/index.ts new file mode 100644 index 00000000000..e9508aa154a --- /dev/null +++ b/apps/sim/triggers/greenhouse/index.ts @@ -0,0 +1,8 @@ +export { greenhouseCandidateHiredTrigger } from './candidate_hired' +export { greenhouseCandidateRejectedTrigger } from './candidate_rejected' +export { greenhouseCandidateStageChangeTrigger } from './candidate_stage_change' +export { greenhouseJobCreatedTrigger } from './job_created' +export { greenhouseJobUpdatedTrigger } from './job_updated' +export { greenhouseNewApplicationTrigger } from './new_application' +export { greenhouseOfferCreatedTrigger } from './offer_created' +export { greenhouseWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/greenhouse/job_created.ts b/apps/sim/triggers/greenhouse/job_created.ts new file mode 100644 index 00000000000..8cfefd33a12 --- /dev/null +++ b/apps/sim/triggers/greenhouse/job_created.ts @@ -0,0 +1,39 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGreenhouseExtraFields, + buildJobCreatedOutputs, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse Job Created Trigger + * + * Fires when a new job posting is created. + */ +export const greenhouseJobCreatedTrigger: TriggerConfig = { + id: 'greenhouse_job_created', + name: 'Greenhouse Job Created', + provider: 'greenhouse', + description: 'Trigger workflow when a new job is created', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_job_created', + triggerOptions: greenhouseTriggerOptions, + setupInstructions: greenhouseSetupInstructions('Job Created'), + extraFields: buildGreenhouseExtraFields('greenhouse_job_created'), + }), + + outputs: buildJobCreatedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/greenhouse/job_updated.ts b/apps/sim/triggers/greenhouse/job_updated.ts new file mode 100644 index 00000000000..c669ef22ec0 --- /dev/null +++ b/apps/sim/triggers/greenhouse/job_updated.ts @@ -0,0 +1,39 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGreenhouseExtraFields, + buildJobUpdatedOutputs, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse Job Updated Trigger + * + * Fires when a job posting is updated. + */ +export const greenhouseJobUpdatedTrigger: TriggerConfig = { + id: 'greenhouse_job_updated', + name: 'Greenhouse Job Updated', + provider: 'greenhouse', + description: 'Trigger workflow when a job is updated', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_job_updated', + triggerOptions: greenhouseTriggerOptions, + setupInstructions: greenhouseSetupInstructions('Job Updated'), + extraFields: buildGreenhouseExtraFields('greenhouse_job_updated'), + }), + + outputs: buildJobUpdatedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/greenhouse/new_application.ts b/apps/sim/triggers/greenhouse/new_application.ts new file mode 100644 index 00000000000..933cd624e14 --- /dev/null +++ b/apps/sim/triggers/greenhouse/new_application.ts @@ -0,0 +1,39 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGreenhouseExtraFields, + buildNewApplicationOutputs, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse New Application Trigger + * + * Fires when a new candidate application is submitted. + */ +export const greenhouseNewApplicationTrigger: TriggerConfig = { + id: 'greenhouse_new_application', + name: 'Greenhouse New Application', + provider: 'greenhouse', + description: 'Trigger workflow when a new application is submitted', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_new_application', + triggerOptions: greenhouseTriggerOptions, + setupInstructions: greenhouseSetupInstructions('New Candidate Application'), + extraFields: buildGreenhouseExtraFields('greenhouse_new_application'), + }), + + outputs: buildNewApplicationOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/greenhouse/offer_created.ts b/apps/sim/triggers/greenhouse/offer_created.ts new file mode 100644 index 00000000000..7567a9adb63 --- /dev/null +++ b/apps/sim/triggers/greenhouse/offer_created.ts @@ -0,0 +1,39 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGreenhouseExtraFields, + buildOfferCreatedOutputs, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse Offer Created Trigger + * + * Fires when a new offer is created for a candidate. + */ +export const greenhouseOfferCreatedTrigger: TriggerConfig = { + id: 'greenhouse_offer_created', + name: 'Greenhouse Offer Created', + provider: 'greenhouse', + description: 'Trigger workflow when a new offer is created', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_offer_created', + triggerOptions: greenhouseTriggerOptions, + setupInstructions: greenhouseSetupInstructions('Offer Created'), + extraFields: buildGreenhouseExtraFields('greenhouse_offer_created'), + }), + + outputs: buildOfferCreatedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/greenhouse/utils.ts b/apps/sim/triggers/greenhouse/utils.ts new file mode 100644 index 00000000000..15972379e03 --- /dev/null +++ b/apps/sim/triggers/greenhouse/utils.ts @@ -0,0 +1,326 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Dropdown options for the Greenhouse trigger type selector. + */ +export const greenhouseTriggerOptions = [ + { label: 'Candidate Hired', id: 'greenhouse_candidate_hired' }, + { label: 'New Application', id: 'greenhouse_new_application' }, + { label: 'Candidate Stage Change', id: 'greenhouse_candidate_stage_change' }, + { label: 'Candidate Rejected', id: 'greenhouse_candidate_rejected' }, + { label: 'Offer Created', id: 'greenhouse_offer_created' }, + { label: 'Job Created', id: 'greenhouse_job_created' }, + { label: 'Job Updated', id: 'greenhouse_job_updated' }, + { label: 'Generic Webhook (All Events)', id: 'greenhouse_webhook' }, +] + +/** + * Maps trigger IDs to Greenhouse webhook action strings. + * Used for event filtering in the webhook processor. + */ +export const GREENHOUSE_EVENT_MAP: Record = { + greenhouse_candidate_hired: 'hire_candidate', + greenhouse_new_application: 'new_candidate_application', + greenhouse_candidate_stage_change: 'candidate_stage_change', + greenhouse_candidate_rejected: 'reject_candidate', + greenhouse_offer_created: 'offer_created', + greenhouse_job_created: 'job_created', + greenhouse_job_updated: 'job_updated', +} + +/** + * Checks whether a Greenhouse webhook payload matches the configured trigger. + */ +export function isGreenhouseEventMatch(triggerId: string, action: string): boolean { + const expectedAction = GREENHOUSE_EVENT_MAP[triggerId] + if (!expectedAction) { + return true + } + return action === expectedAction +} + +/** + * Builds extra fields for Greenhouse triggers. + * Includes an optional secret key for HMAC signature verification. + */ +export function buildGreenhouseExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'secretKey', + title: 'Secret Key (Optional)', + type: 'short-input', + placeholder: 'Enter the same secret key configured in Greenhouse', + description: 'Used to verify webhook signatures via HMAC-SHA256.', + password: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +/** + * Generates HTML setup instructions for Greenhouse webhooks. + * Webhooks are manually configured in the Greenhouse admin panel. + */ +export function greenhouseSetupInstructions(eventType: string): string { + const instructions = [ + 'Copy the Webhook URL above.', + 'In Greenhouse, go to Configure > Dev Center > Webhooks.', + 'Click Create New Webhook.', + 'Paste the Webhook URL into the Endpoint URL field.', + 'Enter a Secret Key for signature verification (optional).', + `Under When, select the ${eventType} event.`, + 'Click Create Webhook to save.', + 'Click "Save" above to activate your trigger.', + ] + + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Build outputs for hire_candidate events. + * Greenhouse nests candidate inside application: payload.application.candidate + * Uses both singular `job` (deprecated) and `jobs` array. + */ +export function buildCandidateHiredOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type (hire_candidate)' }, + payload: { + application: { + id: { type: 'number', description: 'Application ID' }, + status: { type: 'string', description: 'Application status' }, + prospect: { type: 'boolean', description: 'Whether the applicant is a prospect' }, + applied_at: { type: 'string', description: 'When the application was submitted' }, + url: { type: 'string', description: 'Application URL in Greenhouse' }, + current_stage: { + id: { type: 'number', description: 'Current stage ID' }, + name: { type: 'string', description: 'Current stage name' }, + }, + candidate: { + id: { type: 'number', description: 'Candidate ID' }, + first_name: { type: 'string', description: 'First name' }, + last_name: { type: 'string', description: 'Last name' }, + title: { type: 'string', description: 'Current title' }, + company: { type: 'string', description: 'Current company' }, + email_addresses: { type: 'json', description: 'Email addresses' }, + phone_numbers: { type: 'json', description: 'Phone numbers' }, + recruiter: { type: 'json', description: 'Assigned recruiter' }, + coordinator: { type: 'json', description: 'Assigned coordinator' }, + }, + jobs: { type: 'json', description: 'Associated jobs (array)' }, + source: { + id: { type: 'number', description: 'Source ID' }, + public_name: { type: 'string', description: 'Source name' }, + }, + offer: { + id: { type: 'number', description: 'Offer ID' }, + version: { type: 'number', description: 'Offer version' }, + starts_at: { type: 'string', description: 'Offer start date' }, + custom_fields: { type: 'json', description: 'Offer custom fields' }, + }, + custom_fields: { type: 'json', description: 'Application custom fields' }, + }, + }, + } as Record +} + +/** + * Build outputs for new_candidate_application events. + * Candidate is nested inside application: payload.application.candidate + */ +export function buildNewApplicationOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type (new_candidate_application)' }, + payload: { + application: { + id: { type: 'number', description: 'Application ID' }, + status: { type: 'string', description: 'Application status' }, + prospect: { type: 'boolean', description: 'Whether the applicant is a prospect' }, + applied_at: { type: 'string', description: 'When the application was submitted' }, + url: { type: 'string', description: 'Application URL in Greenhouse' }, + current_stage: { + id: { type: 'number', description: 'Current stage ID' }, + name: { type: 'string', description: 'Current stage name' }, + }, + candidate: { + id: { type: 'number', description: 'Candidate ID' }, + first_name: { type: 'string', description: 'First name' }, + last_name: { type: 'string', description: 'Last name' }, + title: { type: 'string', description: 'Current title' }, + company: { type: 'string', description: 'Current company' }, + created_at: { type: 'string', description: 'When the candidate was created' }, + email_addresses: { type: 'json', description: 'Email addresses' }, + phone_numbers: { type: 'json', description: 'Phone numbers' }, + tags: { type: 'json', description: 'Candidate tags' }, + }, + jobs: { type: 'json', description: 'Associated jobs (array)' }, + source: { + id: { type: 'number', description: 'Source ID' }, + public_name: { type: 'string', description: 'Source name' }, + }, + answers: { type: 'json', description: 'Application question answers' }, + attachments: { type: 'json', description: 'Application attachments' }, + custom_fields: { type: 'json', description: 'Application custom fields' }, + }, + }, + } as Record +} + +/** + * Build outputs for candidate_stage_change events. + * Candidate is nested inside application: payload.application.candidate + */ +export function buildCandidateStageChangeOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type (candidate_stage_change)' }, + payload: { + application: { + id: { type: 'number', description: 'Application ID' }, + status: { type: 'string', description: 'Application status' }, + prospect: { type: 'boolean', description: 'Whether the applicant is a prospect' }, + applied_at: { type: 'string', description: 'When the application was submitted' }, + url: { type: 'string', description: 'Application URL in Greenhouse' }, + current_stage: { + id: { type: 'number', description: 'Current stage ID' }, + name: { type: 'string', description: 'Current stage name' }, + interviews: { type: 'json', description: 'Interviews in this stage' }, + }, + candidate: { + id: { type: 'number', description: 'Candidate ID' }, + first_name: { type: 'string', description: 'First name' }, + last_name: { type: 'string', description: 'Last name' }, + title: { type: 'string', description: 'Current title' }, + company: { type: 'string', description: 'Current company' }, + email_addresses: { type: 'json', description: 'Email addresses' }, + phone_numbers: { type: 'json', description: 'Phone numbers' }, + }, + jobs: { type: 'json', description: 'Associated jobs (array)' }, + source: { + id: { type: 'number', description: 'Source ID' }, + public_name: { type: 'string', description: 'Source name' }, + }, + custom_fields: { type: 'json', description: 'Application custom fields' }, + }, + }, + } as Record +} + +/** + * Build outputs for reject_candidate events. + * Candidate is nested inside application: payload.application.candidate + */ +export function buildCandidateRejectedOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type (reject_candidate)' }, + payload: { + application: { + id: { type: 'number', description: 'Application ID' }, + status: { type: 'string', description: 'Application status (rejected)' }, + prospect: { type: 'boolean', description: 'Whether the applicant is a prospect' }, + applied_at: { type: 'string', description: 'When the application was submitted' }, + rejected_at: { type: 'string', description: 'When the candidate was rejected' }, + url: { type: 'string', description: 'Application URL in Greenhouse' }, + current_stage: { + id: { type: 'number', description: 'Stage ID where rejected' }, + name: { type: 'string', description: 'Stage name where rejected' }, + }, + candidate: { + id: { type: 'number', description: 'Candidate ID' }, + first_name: { type: 'string', description: 'First name' }, + last_name: { type: 'string', description: 'Last name' }, + email_addresses: { type: 'json', description: 'Email addresses' }, + phone_numbers: { type: 'json', description: 'Phone numbers' }, + }, + jobs: { type: 'json', description: 'Associated jobs (array)' }, + rejection_reason: { + type: 'json', + description: 'Rejection reason object with id, name, and type fields', + }, + rejection_details: { type: 'json', description: 'Rejection details with custom fields' }, + custom_fields: { type: 'json', description: 'Application custom fields' }, + }, + }, + } as Record +} + +/** + * Build outputs for offer_created events. + * Offer payload is flat under payload (not nested under payload.offer). + */ +export function buildOfferCreatedOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type (offer_created)' }, + payload: { + id: { type: 'number', description: 'Offer ID' }, + application_id: { type: 'number', description: 'Associated application ID' }, + job_id: { type: 'number', description: 'Associated job ID' }, + user_id: { type: 'number', description: 'User who created the offer' }, + version: { type: 'number', description: 'Offer version number' }, + sent_on: { type: 'string', description: 'When the offer was sent' }, + resolved_at: { type: 'string', description: 'When the offer was resolved' }, + start_date: { type: 'string', description: 'Offer start date' }, + notes: { type: 'string', description: 'Offer notes' }, + offer_status: { type: 'string', description: 'Offer status' }, + custom_fields: { type: 'json', description: 'Custom field values' }, + }, + } as Record +} + +/** + * Shared job payload shape used by both job_created and job_updated events. + */ +function buildJobPayload(): Record { + return { + id: { type: 'number', description: 'Job ID' }, + name: { type: 'string', description: 'Job title' }, + requisition_id: { type: 'string', description: 'Requisition ID' }, + status: { type: 'string', description: 'Job status (open, closed, draft)' }, + confidential: { type: 'boolean', description: 'Whether the job is confidential' }, + created_at: { type: 'string', description: 'When the job was created' }, + opened_at: { type: 'string', description: 'When the job was opened' }, + closed_at: { type: 'string', description: 'When the job was closed' }, + departments: { type: 'json', description: 'Associated departments' }, + offices: { type: 'json', description: 'Associated offices' }, + hiring_team: { type: 'json', description: 'Hiring team (managers, recruiters, etc.)' }, + openings: { type: 'json', description: 'Job openings' }, + custom_fields: { type: 'json', description: 'Custom field values' }, + } as Record +} + +/** + * Build outputs for job_created events. + * Job data is nested under payload.job. + */ +export function buildJobCreatedOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type (job_created)' }, + payload: { job: buildJobPayload() }, + } as Record +} + +/** + * Build outputs for job_updated events. + * Same structure as job_created. + */ +export function buildJobUpdatedOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type (job_updated)' }, + payload: { job: buildJobPayload() }, + } as Record +} + +/** + * Build outputs for generic webhook (all events). + */ +export function buildWebhookOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type' }, + payload: { type: 'json', description: 'Full event payload' }, + } as Record +} diff --git a/apps/sim/triggers/greenhouse/webhook.ts b/apps/sim/triggers/greenhouse/webhook.ts new file mode 100644 index 00000000000..de436a89748 --- /dev/null +++ b/apps/sim/triggers/greenhouse/webhook.ts @@ -0,0 +1,39 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGreenhouseExtraFields, + buildWebhookOutputs, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse Generic Webhook Trigger + * + * Accepts all Greenhouse webhook events without filtering. + */ +export const greenhouseWebhookTrigger: TriggerConfig = { + id: 'greenhouse_webhook', + name: 'Greenhouse Webhook (All Events)', + provider: 'greenhouse', + description: 'Trigger workflow on any Greenhouse webhook event', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_webhook', + triggerOptions: greenhouseTriggerOptions, + setupInstructions: greenhouseSetupInstructions('All Events'), + extraFields: buildGreenhouseExtraFields('greenhouse_webhook'), + }), + + outputs: buildWebhookOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 4390bfeefff..680cc2dc03f 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -100,6 +100,16 @@ import { grainStoryCreatedTrigger, grainWebhookTrigger, } from '@/triggers/grain' +import { + greenhouseCandidateHiredTrigger, + greenhouseCandidateRejectedTrigger, + greenhouseCandidateStageChangeTrigger, + greenhouseJobCreatedTrigger, + greenhouseJobUpdatedTrigger, + greenhouseNewApplicationTrigger, + greenhouseOfferCreatedTrigger, + greenhouseWebhookTrigger, +} from '@/triggers/greenhouse' import { hubspotCompanyCreatedTrigger, hubspotCompanyDeletedTrigger, @@ -238,6 +248,14 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { confluence_label_added: confluenceLabelAddedTrigger, confluence_label_removed: confluenceLabelRemovedTrigger, generic_webhook: genericWebhookTrigger, + greenhouse_candidate_hired: greenhouseCandidateHiredTrigger, + greenhouse_new_application: greenhouseNewApplicationTrigger, + greenhouse_candidate_stage_change: greenhouseCandidateStageChangeTrigger, + greenhouse_candidate_rejected: greenhouseCandidateRejectedTrigger, + greenhouse_offer_created: greenhouseOfferCreatedTrigger, + greenhouse_job_created: greenhouseJobCreatedTrigger, + greenhouse_job_updated: greenhouseJobUpdatedTrigger, + greenhouse_webhook: greenhouseWebhookTrigger, github_webhook: githubWebhookTrigger, github_issue_opened: githubIssueOpenedTrigger, github_issue_closed: githubIssueClosedTrigger,