mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(ashby): repair broken idempotency dedup and clarify duplicate-webhook errors (#5518)
* fix(ashby): repair broken idempotency dedup and clarify duplicate-webhook errors extractIdempotencyId looked for a webhookActionId field that does not exist anywhere in Ashby's webhook payload schema (confirmed against the live OpenAPI spec), so every delivery — including Ashby's own retries — got a fresh random dedup key and could re-execute workflows. Derive the key from the affected resource's id plus its updatedAt/decidedAt instead. Also surface a clear, actionable message when Ashby's webhook.create rejects a request as a duplicate (seen repeatedly in production logs, where the outbox retried the same failing subscription for ~23 minutes before dead-lettering) instead of the generic API error passthrough. Also add the interview stage `type` field to trigger outputs (present in Ashby's schema, useful for a stage-change trigger) and fix the employmentType description to match Ashby's actual enum values. * fix(ashby): correct offer status enum values in trigger output descriptions acceptanceStatus listed a non-existent "WaitingOnResponse" value and offerStatus was missing "WaitingOnApprovalDefinition" — both caught on a second pass re-checking every enum against Ashby's live OpenAPI spec. * fix(ashby): harden idempotency key against Greptile-flagged collision risks - Return null (skip dedup) when application.updatedAt is absent instead of collapsing to an empty string, which could collide two distinct events sharing an application id onto the same key. - Drop decidedAt from the offerCreate key. It's populated only after the fact, so including it gave a retry of the same delivery a different key than the original attempt once the candidate responded, defeating dedup. offer.id alone is already stable and unique per created offer. * fix(ashby): fall back to a content fingerprint instead of skipping dedup Returning null when application.updatedAt was missing avoided false collisions between distinct events, but also disabled retry dedup entirely for that payload shape — an Ashby retry would get a random key from the idempotency service's own fallback and re-run the workflow. Extract the fallback-fingerprint helper (sha256 of a stably-serialized payload) out of salesforce.ts into the shared providers/utils.ts, and use it in ashby.ts: identical retried bytes hash identically (dedup still works), while two genuinely different events hash differently (no false collision). * chore(ashby): remove inline comments, let names carry the intent * fix(ashby): fingerprint the full data payload, not just application Hashing only data.application missed other fields (e.g. offer on candidateHire) when updatedAt is absent, so two deliveries sharing an application snapshot but differing elsewhere in data could collide onto the same idempotency key. * fix(ashby): rename output field 'type' to 'stageType' to fix build TriggerOutput reserves the 'type' key for the output's own JSON type (e.g. 'string'), so a nested field literally named 'type' inside currentInterviewStage collided with that meta-field and broke the Record<string, TriggerOutput> cast — passing local type-check (which turbo was silently serving a stale cached pass for) but failing Next.js's build-time type check in CI. Renamed to stageType, matching the existing eventType-style convention used elsewhere for API fields literally called 'type'. * fix(ashby): rename currentInterviewStage.type to stageType in delivered data too Renaming the field in the output schema alone (to fix the TriggerOutput build collision) left a mismatch: Ashby's real payload still has currentInterviewStage.type, so a picker/expression using the schema's declared stageType would resolve to undefined at runtime. formatInput now renames the field in the actual delivered payload so it matches what the schema declares. * fix(ashby): fold offer id into the idempotency key when present When application.updatedAt is present, the key ignored sibling data fields — a candidateHire delivery carries both application and offer, so two deliveries sharing an application snapshot could collide even though they concern different offers. Append offer.id to the discriminator when present; it's the only sibling object Ashby's schema ever pairs with application.
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import crypto from 'crypto'
|
||||
import { createMockRequest } from '@sim/testing'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ashbyHandler } from '@/lib/webhooks/providers/ashby'
|
||||
|
||||
describe('ashbyHandler', () => {
|
||||
describe('verifyAuth', () => {
|
||||
const secret = 'test-secret-token'
|
||||
const rawBody = JSON.stringify({ action: 'ping', data: { webhookActionType: 'ping' } })
|
||||
const signature = `sha256=${crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')}`
|
||||
|
||||
it('returns 401 when secretToken is missing', () => {
|
||||
const request = createMockRequest('POST', JSON.parse(rawBody), {
|
||||
'ashby-signature': signature,
|
||||
})
|
||||
const res = ashbyHandler.verifyAuth!({
|
||||
request: request as any,
|
||||
rawBody,
|
||||
requestId: 'r1',
|
||||
providerConfig: {},
|
||||
webhook: {},
|
||||
workflow: {},
|
||||
})
|
||||
expect(res?.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 401 when signature header is missing', () => {
|
||||
const request = createMockRequest('POST', JSON.parse(rawBody), {})
|
||||
const res = ashbyHandler.verifyAuth!({
|
||||
request: request as any,
|
||||
rawBody,
|
||||
requestId: 'r1',
|
||||
providerConfig: { secretToken: secret },
|
||||
webhook: {},
|
||||
workflow: {},
|
||||
})
|
||||
expect(res?.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 401 when signature is invalid', () => {
|
||||
const request = createMockRequest('POST', JSON.parse(rawBody), {
|
||||
'ashby-signature': 'sha256=deadbeef',
|
||||
})
|
||||
const res = ashbyHandler.verifyAuth!({
|
||||
request: request as any,
|
||||
rawBody,
|
||||
requestId: 'r1',
|
||||
providerConfig: { secretToken: secret },
|
||||
webhook: {},
|
||||
workflow: {},
|
||||
})
|
||||
expect(res?.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns null when signature is valid', () => {
|
||||
const request = createMockRequest('POST', JSON.parse(rawBody), {
|
||||
'ashby-signature': signature,
|
||||
})
|
||||
const res = ashbyHandler.verifyAuth!({
|
||||
request: request as any,
|
||||
rawBody,
|
||||
requestId: 'r1',
|
||||
providerConfig: { secretToken: secret },
|
||||
webhook: {},
|
||||
workflow: {},
|
||||
})
|
||||
expect(res).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchEvent', () => {
|
||||
it('rejects ping events', async () => {
|
||||
const matched = await ashbyHandler.matchEvent!({
|
||||
webhook: { id: 'w1' } as any,
|
||||
body: { action: 'ping', data: { webhookActionType: 'ping' } },
|
||||
requestId: 'r1',
|
||||
providerConfig: { triggerId: 'ashby_application_submit' },
|
||||
} as any)
|
||||
expect(matched).toBe(false)
|
||||
})
|
||||
|
||||
it('matches when action equals the configured trigger event', async () => {
|
||||
const matched = await ashbyHandler.matchEvent!({
|
||||
webhook: { id: 'w1' } as any,
|
||||
body: { action: 'applicationSubmit', data: {} },
|
||||
requestId: 'r1',
|
||||
providerConfig: { triggerId: 'ashby_application_submit' },
|
||||
} as any)
|
||||
expect(matched).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects when action does not match the configured trigger event', async () => {
|
||||
const matched = await ashbyHandler.matchEvent!({
|
||||
webhook: { id: 'w1' } as any,
|
||||
body: { action: 'jobCreate', data: {} },
|
||||
requestId: 'r1',
|
||||
providerConfig: { triggerId: 'ashby_application_submit' },
|
||||
} as any)
|
||||
expect(matched).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatInput', () => {
|
||||
it('spreads data fields to the top level alongside action', async () => {
|
||||
const result = await ashbyHandler.formatInput!({
|
||||
body: {
|
||||
action: 'applicationSubmit',
|
||||
data: { application: { id: 'app-1', status: 'Active' } },
|
||||
},
|
||||
} as any)
|
||||
expect(result.input).toEqual({
|
||||
action: 'applicationSubmit',
|
||||
application: { id: 'app-1', status: 'Active' },
|
||||
})
|
||||
})
|
||||
|
||||
it('renames currentInterviewStage.type to stageType, matching the trigger output schema', async () => {
|
||||
const result = await ashbyHandler.formatInput!({
|
||||
body: {
|
||||
action: 'candidateStageChange',
|
||||
data: {
|
||||
application: {
|
||||
id: 'app-1',
|
||||
currentInterviewStage: { id: 'stage-1', title: 'Offer', type: 'Offer' },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any)
|
||||
expect(result.input.application).toEqual({
|
||||
id: 'app-1',
|
||||
currentInterviewStage: { id: 'stage-1', title: 'Offer', stageType: 'Offer' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractIdempotencyId', () => {
|
||||
it('derives a stable key from application id + updatedAt', () => {
|
||||
const body = {
|
||||
action: 'candidateStageChange',
|
||||
data: { application: { id: 'app-1', updatedAt: '2026-01-01T00:00:00Z' } },
|
||||
}
|
||||
expect(ashbyHandler.extractIdempotencyId!(body)).toBe(
|
||||
'ashby:candidateStageChange:app-1:2026-01-01T00:00:00Z'
|
||||
)
|
||||
expect(ashbyHandler.extractIdempotencyId!({ ...body })).toBe(
|
||||
ashbyHandler.extractIdempotencyId!(body)
|
||||
)
|
||||
})
|
||||
|
||||
it('derives a key from candidate id for candidateDelete', () => {
|
||||
const body = { action: 'candidateDelete', data: { candidate: { id: 'cand-1' } } }
|
||||
expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:candidateDelete:cand-1')
|
||||
})
|
||||
|
||||
it('derives a key from job id for jobCreate', () => {
|
||||
const body = { action: 'jobCreate', data: { job: { id: 'job-1' } } }
|
||||
expect(ashbyHandler.extractIdempotencyId!(body)).toBe('ashby:jobCreate:job-1')
|
||||
})
|
||||
|
||||
it('derives a stable key from offer id alone, ignoring mutable decidedAt', () => {
|
||||
const created = { action: 'offerCreate', data: { offer: { id: 'offer-1', decidedAt: null } } }
|
||||
expect(ashbyHandler.extractIdempotencyId!(created)).toBe('ashby:offerCreate:offer-1')
|
||||
|
||||
const retriedAfterDecision = {
|
||||
action: 'offerCreate',
|
||||
data: { offer: { id: 'offer-1', decidedAt: '2026-01-02T00:00:00Z' } },
|
||||
}
|
||||
expect(ashbyHandler.extractIdempotencyId!(retriedAfterDecision)).toBe(
|
||||
ashbyHandler.extractIdempotencyId!(created)
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to a content fingerprint when updatedAt is missing, still deduping retries', () => {
|
||||
const body = {
|
||||
action: 'candidateStageChange',
|
||||
data: { application: { id: 'app-1', status: 'Active' } },
|
||||
}
|
||||
const key = ashbyHandler.extractIdempotencyId!(body)
|
||||
expect(key).not.toBeNull()
|
||||
expect(ashbyHandler.extractIdempotencyId!({ ...body, data: { ...body.data } })).toBe(key)
|
||||
|
||||
const different = {
|
||||
action: 'candidateStageChange',
|
||||
data: { application: { id: 'app-1', status: 'Hired' } },
|
||||
}
|
||||
expect(ashbyHandler.extractIdempotencyId!(different)).not.toBe(key)
|
||||
})
|
||||
|
||||
it('distinguishes candidateHire deliveries that share an application snapshot but differ in offer', () => {
|
||||
const application = { id: 'app-1', status: 'Hired' }
|
||||
const first = {
|
||||
action: 'candidateHire',
|
||||
data: { application, offer: { id: 'offer-1' } },
|
||||
}
|
||||
const second = {
|
||||
action: 'candidateHire',
|
||||
data: { application, offer: { id: 'offer-2' } },
|
||||
}
|
||||
expect(ashbyHandler.extractIdempotencyId!(first)).not.toBe(
|
||||
ashbyHandler.extractIdempotencyId!(second)
|
||||
)
|
||||
})
|
||||
|
||||
it('distinguishes candidateHire deliveries sharing application id + updatedAt but differing in offer', () => {
|
||||
const application = { id: 'app-1', status: 'Hired', updatedAt: '2026-01-01T00:00:00Z' }
|
||||
const first = {
|
||||
action: 'candidateHire',
|
||||
data: { application, offer: { id: 'offer-1' } },
|
||||
}
|
||||
const second = {
|
||||
action: 'candidateHire',
|
||||
data: { application, offer: { id: 'offer-2' } },
|
||||
}
|
||||
expect(ashbyHandler.extractIdempotencyId!(first)).not.toBe(
|
||||
ashbyHandler.extractIdempotencyId!(second)
|
||||
)
|
||||
// a genuine retry of `first` (identical offer too) still dedupes
|
||||
expect(ashbyHandler.extractIdempotencyId!({ ...first })).toBe(
|
||||
ashbyHandler.extractIdempotencyId!(first)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null when no recognizable resource is present', () => {
|
||||
expect(ashbyHandler.extractIdempotencyId!({ action: 'ping', data: {} })).toBeNull()
|
||||
expect(ashbyHandler.extractIdempotencyId!({})).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { safeCompare } from '@sim/security/compare'
|
||||
import { hmacSha256Hex } from '@sim/security/hmac'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { omit } from '@sim/utils/object'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
|
||||
import type {
|
||||
@@ -14,6 +15,7 @@ import type {
|
||||
SubscriptionResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
import { buildFallbackDeliveryFingerprint } from '@/lib/webhooks/providers/utils'
|
||||
|
||||
const logger = createLogger('WebhookProvider:Ashby')
|
||||
|
||||
@@ -37,18 +39,54 @@ function validateAshbySignature(secretToken: string, signature: string, body: st
|
||||
export const ashbyHandler: WebhookProviderHandler = {
|
||||
extractIdempotencyId(body: unknown): string | null {
|
||||
const obj = body as Record<string, unknown>
|
||||
const webhookActionId = obj.webhookActionId
|
||||
if (typeof webhookActionId === 'string' && webhookActionId) {
|
||||
return `ashby:${webhookActionId}`
|
||||
const action = typeof obj.action === 'string' ? obj.action : undefined
|
||||
const data = obj.data as Record<string, unknown> | undefined
|
||||
if (!action || !data) return null
|
||||
|
||||
const application = data.application as Record<string, unknown> | undefined
|
||||
const candidate = data.candidate as Record<string, unknown> | undefined
|
||||
const job = data.job as Record<string, unknown> | undefined
|
||||
const offer = data.offer as Record<string, unknown> | undefined
|
||||
|
||||
if (application?.id) {
|
||||
const discriminator = application.updatedAt ?? buildFallbackDeliveryFingerprint(data)
|
||||
const offerSuffix = offer?.id ? `:${offer.id}` : ''
|
||||
return `ashby:${action}:${application.id}:${discriminator}${offerSuffix}`
|
||||
}
|
||||
if (offer?.id) {
|
||||
return `ashby:${action}:${offer.id}`
|
||||
}
|
||||
if (candidate?.id) {
|
||||
return `ashby:${action}:${candidate.id}`
|
||||
}
|
||||
if (job?.id) {
|
||||
return `ashby:${action}:${job.id}`
|
||||
}
|
||||
return null
|
||||
},
|
||||
|
||||
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
||||
const b = body as Record<string, unknown>
|
||||
const data = (b.data as Record<string, unknown>) || {}
|
||||
const application = data.application as Record<string, unknown> | undefined
|
||||
const currentInterviewStage = application?.currentInterviewStage as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
return {
|
||||
input: {
|
||||
...((b.data as Record<string, unknown>) || {}),
|
||||
...data,
|
||||
...(application && currentInterviewStage
|
||||
? {
|
||||
application: {
|
||||
...application,
|
||||
currentInterviewStage: {
|
||||
...omit(currentInterviewStage, ['type']),
|
||||
stageType: currentInterviewStage.type,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
action: b.action,
|
||||
},
|
||||
}
|
||||
@@ -185,6 +223,9 @@ export const ashbyHandler: WebhookProviderHandler = {
|
||||
} else if (ashbyResponse.status === 403) {
|
||||
userFriendlyMessage =
|
||||
'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.'
|
||||
} else if (/duplicate webhook/i.test(errorMessage)) {
|
||||
userFriendlyMessage =
|
||||
'A webhook for this URL and event already exists in Ashby. This usually happens when a previous save succeeded in Ashby but Sim failed to record it. Delete the duplicate webhook under Ashby Settings > API/Webhooks, then re-save this trigger.'
|
||||
} else if (errorMessage && errorMessage !== 'Unknown Ashby API error') {
|
||||
userFriendlyMessage = `Ashby error: ${errorMessage}`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { sha256Hex } from '@sim/security/hash'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type {
|
||||
AuthContext,
|
||||
@@ -8,7 +7,7 @@ import type {
|
||||
FormatInputResult,
|
||||
WebhookProviderHandler,
|
||||
} from '@/lib/webhooks/providers/types'
|
||||
import { verifyTokenAuth } from '@/lib/webhooks/providers/utils'
|
||||
import { buildFallbackDeliveryFingerprint, verifyTokenAuth } from '@/lib/webhooks/providers/utils'
|
||||
|
||||
export function extractSalesforceObjectTypeFromPayload(
|
||||
body: Record<string, unknown>
|
||||
@@ -98,25 +97,6 @@ function pickTimestamp(body: Record<string, unknown>, record: Record<string, unk
|
||||
return ''
|
||||
}
|
||||
|
||||
function stableSerialize(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableSerialize(item)).join(',')}]`
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`)
|
||||
.join(',')}}`
|
||||
}
|
||||
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function buildFallbackDeliveryFingerprint(body: Record<string, unknown>): string {
|
||||
return sha256Hex(stableSerialize(body))
|
||||
}
|
||||
|
||||
function pickRecordId(body: Record<string, unknown>, record: Record<string, unknown>): string {
|
||||
const id =
|
||||
(typeof body.recordId === 'string' && body.recordId) ||
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
import type { Logger } from '@sim/logger'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { safeCompare } from '@sim/security/compare'
|
||||
import { sha256Hex } from '@sim/security/hash'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { AuthContext, EventFilterContext } from '@/lib/webhooks/providers/types'
|
||||
|
||||
const logger = createLogger('WebhookProviderAuth')
|
||||
|
||||
/**
|
||||
* Deterministic JSON serialization with object keys sorted, so structurally
|
||||
* identical payloads produce identical output regardless of key order.
|
||||
*/
|
||||
function stableSerialize(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableSerialize(item)).join(',')}]`
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`)
|
||||
.join(',')}}`
|
||||
}
|
||||
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback idempotency fingerprint for payloads with no stable delivery id
|
||||
* or content timestamp to key on. A provider retry resends identical bytes,
|
||||
* so this hash is stable across retries of the same delivery while still
|
||||
* differentiating distinct events.
|
||||
*/
|
||||
export function buildFallbackDeliveryFingerprint(body: unknown): string {
|
||||
return sha256Hex(stableSerialize(body))
|
||||
}
|
||||
|
||||
interface HmacVerifierOptions {
|
||||
configKey: string
|
||||
headerName: string
|
||||
|
||||
@@ -144,6 +144,10 @@ export function buildApplicationSubmitOutputs(): Record<string, TriggerOutput> {
|
||||
currentInterviewStage: {
|
||||
id: { type: 'string', description: 'Current interview stage UUID' },
|
||||
title: { type: 'string', description: 'Current interview stage title' },
|
||||
stageType: {
|
||||
type: 'string',
|
||||
description: 'Current interview stage type (e.g., Lead, Applied, Interview, Offer)',
|
||||
},
|
||||
},
|
||||
job: {
|
||||
id: { type: 'string', description: 'Job UUID' },
|
||||
@@ -181,6 +185,10 @@ export function buildCandidateStageChangeOutputs(): Record<string, TriggerOutput
|
||||
currentInterviewStage: {
|
||||
id: { type: 'string', description: 'Current interview stage UUID' },
|
||||
title: { type: 'string', description: 'Current interview stage title' },
|
||||
stageType: {
|
||||
type: 'string',
|
||||
description: 'Current interview stage type (e.g., Lead, Applied, Interview, Offer)',
|
||||
},
|
||||
},
|
||||
job: {
|
||||
id: { type: 'string', description: 'Job UUID' },
|
||||
@@ -213,6 +221,10 @@ export function buildCandidateHireOutputs(): Record<string, TriggerOutput> {
|
||||
currentInterviewStage: {
|
||||
id: { type: 'string', description: 'Current interview stage UUID' },
|
||||
title: { type: 'string', description: 'Current interview stage title' },
|
||||
stageType: {
|
||||
type: 'string',
|
||||
description: 'Current interview stage type (e.g., Lead, Applied, Interview, Offer)',
|
||||
},
|
||||
},
|
||||
job: {
|
||||
id: { type: 'string', description: 'Job UUID' },
|
||||
@@ -262,7 +274,7 @@ export function buildJobCreateOutputs(): Record<string, TriggerOutput> {
|
||||
status: { type: 'string', description: 'Job status (Open, Closed, Draft, Archived)' },
|
||||
employmentType: {
|
||||
type: 'string',
|
||||
description: 'Employment type (Full-time, Part-time, etc.)',
|
||||
description: 'Employment type (FullTime, PartTime, Intern, Contract)',
|
||||
},
|
||||
},
|
||||
} as Record<string, TriggerOutput>
|
||||
@@ -281,13 +293,12 @@ export function buildOfferCreateOutputs(): Record<string, TriggerOutput> {
|
||||
applicationId: { type: 'string', description: 'Associated application UUID' },
|
||||
acceptanceStatus: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Offer acceptance status (Accepted, Declined, Pending, Created, Cancelled, WaitingOnResponse)',
|
||||
description: 'Offer acceptance status (Accepted, Declined, Pending, Created, Cancelled)',
|
||||
},
|
||||
offerStatus: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Offer process status (WaitingOnApprovalStart, WaitingOnOfferApproval, WaitingOnCandidateResponse, CandidateAccepted, CandidateRejected, OfferCancelled)',
|
||||
'Offer process status (WaitingOnApprovalStart, WaitingOnOfferApproval, WaitingOnApprovalDefinition, WaitingOnCandidateResponse, CandidateRejected, CandidateAccepted, OfferCancelled)',
|
||||
},
|
||||
decidedAt: {
|
||||
type: 'string',
|
||||
|
||||
Reference in New Issue
Block a user