Files
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

672 lines
22 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
import { greenhouseConnectorMeta } from '@/connectors/greenhouse/meta'
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
import { htmlToPlainText, parseTagDate } from '@/connectors/utils'
const logger = createLogger('GreenhouseConnector')
const GREENHOUSE_API_BASE = 'https://harvest.greenhouse.io/v1'
/**
* Upper bound on per-application scorecard requests during a single getDocument call.
* A candidate can have many applications (e.g. internal-transfer tracking); without a
* cap, one document fetch could fan out into dozens of sequential requests. Mirrors the
* Ashby connector's MAX_APPLICATIONS_FOR_FEEDBACK bound.
*/
const MAX_APPLICATIONS_FOR_SCORECARDS = 10
/**
* Greenhouse Harvest allows up to 500 candidates per page. We page through the
* full list using the `page` query parameter and stop when the `Link` response
* header no longer advertises a `rel="next"` relationship.
*/
const CANDIDATES_PER_PAGE = 500
/**
* Minutes of overlap subtracted from `lastSyncAt` when computing the incremental
* `updated_after` window. Catches candidates whose `updated_at` lands fractionally
* before the recorded sync boundary (clock skew, late-committed writes) at the
* cost of re-listing a small number of recently-touched candidates.
*/
const INCREMENTAL_OVERLAP_MINUTES = 5
const MS_PER_MINUTE = 60 * 1000
interface GreenhouseUser {
id?: number
first_name?: string | null
last_name?: string | null
name?: string | null
employee_id?: string | null
}
interface GreenhouseEmailAddress {
value?: string
type?: string
}
interface GreenhouseSource {
id?: number
public_name?: string | null
}
interface GreenhouseApplication {
id?: number
status?: string | null
applied_at?: string | null
last_activity_at?: string | null
source?: GreenhouseSource | null
recruiter?: GreenhouseUser | null
coordinator?: GreenhouseUser | null
}
interface GreenhouseCandidate {
id: number
first_name?: string | null
last_name?: string | null
company?: string | null
title?: string | null
created_at?: string | null
updated_at?: string | null
last_activity?: string | null
email_addresses?: GreenhouseEmailAddress[]
tags?: string[]
application_ids?: number[]
applications?: GreenhouseApplication[]
recruiter?: GreenhouseUser | null
coordinator?: GreenhouseUser | null
}
interface GreenhouseActivityNote {
id?: number
created_at?: string | null
body?: string | null
user?: GreenhouseUser | null
visibility?: string | null
}
interface GreenhouseActivityEmail {
id?: number
created_at?: string | null
subject?: string | null
body?: string | null
to?: string | null
from?: string | null
user?: GreenhouseUser | null
}
interface GreenhouseActivityEvent {
id?: number
created_at?: string | null
subject?: string | null
body?: string | null
user?: GreenhouseUser | null
}
interface GreenhouseActivityFeed {
notes?: GreenhouseActivityNote[]
emails?: GreenhouseActivityEmail[]
activities?: GreenhouseActivityEvent[]
}
interface GreenhouseScorecardAttribute {
name?: string | null
type?: string | null
note?: string | null
rating?: string | null
}
interface GreenhouseScorecardQuestion {
question?: string | null
answer?: string | null
}
interface GreenhouseScorecard {
id?: number
interview?: string | null
interviewer?: GreenhouseUser | null
submitted_by?: GreenhouseUser | null
submitted_at?: string | null
interviewed_at?: string | null
overall_recommendation?: string | null
attributes?: GreenhouseScorecardAttribute[]
questions?: GreenhouseScorecardQuestion[]
}
/**
* Builds the HTTP Basic authorization header for Greenhouse Harvest. The API key
* is used as the username with an empty password, base64-encoded as `apiKey:`.
*/
function buildAuthHeader(accessToken: string): string {
return `Basic ${Buffer.from(`${accessToken}:`).toString('base64')}`
}
/**
* Parses the RFC 5988 `Link` response header and returns true when a
* `rel="next"` relationship is present, indicating another page exists.
*/
function hasNextPage(linkHeader: string | null): boolean {
if (!linkHeader) return false
return /;\s*rel\s*=\s*"?next"?/i.test(linkHeader)
}
/**
* Builds a display name from a candidate's first and last name, falling back to
* the candidate ID when both are missing.
*/
function candidateDisplayName(candidate: GreenhouseCandidate): string {
const parts = [candidate.first_name, candidate.last_name]
.map((part) => part?.trim())
.filter((part): part is string => Boolean(part))
return parts.length > 0 ? parts.join(' ') : `Candidate ${candidate.id}`
}
/**
* Computes the metadata-based content hash for a candidate. Both the listing stub
* and `getDocument` use the same formula so the sync engine can detect changes
* without downloading the deferred content. Greenhouse advances a candidate's
* `updated_at` when the candidate record changes; profile-affecting activity
* (notes, emails, stage changes, scorecard submissions) typically also touches it,
* which is why `updated_after` listing and this hash track the same field.
*/
function buildContentHash(id: number, updatedAt?: string | null): string {
return `greenhouse:${id}:${updatedAt ?? ''}`
}
/**
* Resolves the source URL for a candidate in the Greenhouse recruiting UI.
*/
function buildSourceUrl(id: number): string {
return `https://app.greenhouse.io/people/${id}`
}
/**
* Resolves the recruiter, coordinator, and source for a candidate. Greenhouse
* exposes these both at the candidate level and per-application; the candidate
* level is preferred and the most-recent application is used as a fallback so
* the tags are populated even when the candidate-level fields are empty.
*/
function resolveOwnership(candidate: GreenhouseCandidate): {
recruiter?: string
coordinator?: string
source?: string
} {
const applications = Array.isArray(candidate.applications) ? candidate.applications : []
const latest = applications.reduce<GreenhouseApplication | undefined>((acc, app) => {
if (!acc) return app
const accTime = acc.applied_at ? Date.parse(acc.applied_at) : 0
const appTime = app.applied_at ? Date.parse(app.applied_at) : 0
return appTime >= accTime ? app : acc
}, undefined)
const recruiterName = userName(candidate.recruiter ?? latest?.recruiter)
const coordinatorName = userName(candidate.coordinator ?? latest?.coordinator)
const sourceName = latest?.source?.public_name?.trim()
return {
recruiter: recruiterName !== 'Unknown' ? recruiterName : undefined,
coordinator: coordinatorName !== 'Unknown' ? coordinatorName : undefined,
source: sourceName || undefined,
}
}
/**
* Builds the shared metadata block for a candidate, used by both the listing
* stub and the fully-hydrated document.
*/
function buildMetadata(candidate: GreenhouseCandidate): Record<string, unknown> {
const emails = (candidate.email_addresses ?? [])
.map((e) => e.value?.trim())
.filter((value): value is string => Boolean(value))
const { recruiter, coordinator, source } = resolveOwnership(candidate)
const applicationCount = Array.isArray(candidate.application_ids)
? candidate.application_ids.length
: Array.isArray(candidate.applications)
? candidate.applications.length
: 0
return {
candidateName: candidateDisplayName(candidate),
company: candidate.company ?? undefined,
title: candidate.title ?? undefined,
emails,
tags: Array.isArray(candidate.tags) ? candidate.tags : [],
createdAt: candidate.created_at ?? undefined,
updatedAt: candidate.updated_at ?? undefined,
lastActivity: candidate.last_activity ?? undefined,
recruiter,
coordinator,
source,
applicationCount,
}
}
/**
* Creates a lightweight, content-deferred document stub from a candidate listing
* entry. The real content is fetched lazily via `getDocument` for new or changed
* candidates only.
*/
function candidateToStub(candidate: GreenhouseCandidate): ExternalDocument {
return {
externalId: String(candidate.id),
title: candidateDisplayName(candidate),
content: '',
contentDeferred: true,
mimeType: 'text/plain',
sourceUrl: buildSourceUrl(candidate.id),
contentHash: buildContentHash(candidate.id, candidate.updated_at),
metadata: buildMetadata(candidate),
}
}
/**
* Formats a user's display name for inclusion in content lines.
*/
function userName(user?: GreenhouseUser | null): string {
if (!user) return 'Unknown'
if (user.name?.trim()) return user.name.trim()
const parts = [user.first_name, user.last_name]
.map((part) => part?.trim())
.filter((part): part is string => Boolean(part))
return parts.length > 0 ? parts.join(' ') : 'Unknown'
}
/**
* Renders a candidate's activity feed (notes, emails, and events) into plain text.
*/
function formatActivityFeed(feed: GreenhouseActivityFeed): string {
const lines: string[] = []
const notes = feed.notes ?? []
if (notes.length > 0) {
lines.push('--- Notes ---')
for (const note of notes) {
const body = htmlToPlainText(note.body ?? '').trim()
if (!body) continue
const when = note.created_at ? ` (${note.created_at})` : ''
lines.push(`[${userName(note.user)}${when}] ${body}`)
}
lines.push('')
}
const activities = feed.activities ?? []
if (activities.length > 0) {
lines.push('--- Activities ---')
for (const activity of activities) {
const body = htmlToPlainText(activity.body ?? '').trim()
const subject = activity.subject?.trim()
const text = [subject, body].filter(Boolean).join(': ')
if (!text) continue
const when = activity.created_at ? ` (${activity.created_at})` : ''
lines.push(`[${userName(activity.user)}${when}] ${text}`)
}
lines.push('')
}
const emails = feed.emails ?? []
if (emails.length > 0) {
lines.push('--- Emails ---')
for (const email of emails) {
const body = htmlToPlainText(email.body ?? '').trim()
const subject = email.subject?.trim()
if (!subject && !body) continue
const when = email.created_at ? ` (${email.created_at})` : ''
if (subject) lines.push(`[${userName(email.user)}${when}] Subject: ${subject}`)
if (body) lines.push(body)
}
lines.push('')
}
return lines.join('\n').trim()
}
/**
* Renders interview scorecards (recommendations, attribute ratings, and free-text
* question feedback) into plain text.
*/
function formatScorecards(scorecards: GreenhouseScorecard[]): string {
if (scorecards.length === 0) return ''
const lines: string[] = ['--- Scorecards ---']
for (const scorecard of scorecards) {
const interview = scorecard.interview?.trim() || 'Interview'
const reviewer = userName(scorecard.submitted_by ?? scorecard.interviewer)
const when = scorecard.submitted_at ? ` (${scorecard.submitted_at})` : ''
lines.push(`# ${interview}${reviewer}${when}`)
if (scorecard.overall_recommendation?.trim()) {
lines.push(`Overall recommendation: ${scorecard.overall_recommendation.trim()}`)
}
for (const attribute of scorecard.attributes ?? []) {
const name = attribute.name?.trim()
if (!name) continue
const rating = attribute.rating?.trim()
const note = htmlToPlainText(attribute.note ?? '').trim()
const detail = [rating ? `rating: ${rating}` : '', note].filter(Boolean).join(' — ')
lines.push(detail ? `- ${name}: ${detail}` : `- ${name}`)
}
for (const question of scorecard.questions ?? []) {
const q = htmlToPlainText(question.question ?? '').trim()
const a = htmlToPlainText(question.answer ?? '').trim()
if (!q && !a) continue
lines.push(q ? `Q: ${q}` : 'Q:')
if (a) lines.push(`A: ${a}`)
}
lines.push('')
}
return lines.join('\n').trim()
}
/**
* Assembles the full document content for a candidate from their profile header,
* activity feed, and interview scorecards.
*/
function formatContent(
candidate: GreenhouseCandidate,
feed: GreenhouseActivityFeed,
scorecards: GreenhouseScorecard[]
): string {
const sections: string[] = []
const header: string[] = [`Candidate: ${candidateDisplayName(candidate)}`]
if (candidate.title?.trim()) header.push(`Title: ${candidate.title.trim()}`)
if (candidate.company?.trim()) header.push(`Company: ${candidate.company.trim()}`)
const emails = (candidate.email_addresses ?? [])
.map((e) => e.value?.trim())
.filter((value): value is string => Boolean(value))
if (emails.length > 0) header.push(`Email: ${emails.join(', ')}`)
if (Array.isArray(candidate.tags) && candidate.tags.length > 0) {
header.push(`Tags: ${candidate.tags.join(', ')}`)
}
sections.push(header.join('\n'))
const activity = formatActivityFeed(feed)
if (activity) sections.push(activity)
const scorecardText = formatScorecards(scorecards)
if (scorecardText) sections.push(scorecardText)
return sections.join('\n\n').trim()
}
/**
* Fetches a single candidate by ID. Returns null on 404.
*/
async function fetchCandidate(
accessToken: string,
id: string
): Promise<GreenhouseCandidate | null> {
const response = await fetchWithRetry(`${GREENHOUSE_API_BASE}/candidates/${id}`, {
method: 'GET',
headers: { Authorization: buildAuthHeader(accessToken), Accept: 'application/json' },
})
if (!response.ok) {
if (response.status === 404) return null
throw new Error(`Failed to fetch Greenhouse candidate: ${response.status}`)
}
return (await response.json()) as GreenhouseCandidate
}
/**
* Fetches a candidate's activity feed. Returns an empty feed when the endpoint
* 404s so the candidate's profile header still produces content.
*/
async function fetchActivityFeed(accessToken: string, id: string): Promise<GreenhouseActivityFeed> {
const response = await fetchWithRetry(`${GREENHOUSE_API_BASE}/candidates/${id}/activity_feed`, {
method: 'GET',
headers: { Authorization: buildAuthHeader(accessToken), Accept: 'application/json' },
})
if (!response.ok) {
if (response.status === 404) return {}
throw new Error(`Failed to fetch Greenhouse activity feed: ${response.status}`)
}
return (await response.json()) as GreenhouseActivityFeed
}
/**
* Fetches all scorecards across a candidate's applications. Individual
* application failures are tolerated so partial feedback is still indexed.
*/
async function fetchScorecards(
accessToken: string,
applicationIds: number[]
): Promise<GreenhouseScorecard[]> {
const all: GreenhouseScorecard[] = []
for (const applicationId of applicationIds.slice(0, MAX_APPLICATIONS_FOR_SCORECARDS)) {
try {
const response = await fetchWithRetry(
`${GREENHOUSE_API_BASE}/applications/${applicationId}/scorecards`,
{
method: 'GET',
headers: { Authorization: buildAuthHeader(accessToken), Accept: 'application/json' },
}
)
if (!response.ok) {
if (response.status === 404) continue
throw new Error(`Failed to fetch Greenhouse scorecards: ${response.status}`)
}
const data = (await response.json()) as GreenhouseScorecard[]
if (Array.isArray(data)) all.push(...data)
} catch (error) {
logger.warn('Failed to fetch scorecards for application', {
applicationId,
error: toError(error).message,
})
}
}
return all
}
/**
* Computes the `updated_after` value for an incremental sync, subtracting a small
* overlap from the last sync timestamp. Returns undefined for full syncs.
*/
function computeUpdatedAfter(lastSyncAt: Date | undefined): string | undefined {
if (!lastSyncAt) return undefined
const since = new Date(lastSyncAt.getTime() - INCREMENTAL_OVERLAP_MINUTES * MS_PER_MINUTE)
return since.toISOString()
}
export const greenhouseConnector: ConnectorConfig = {
...greenhouseConnectorMeta,
listDocuments: async (
accessToken: string,
sourceConfig: Record<string, unknown>,
cursor?: string,
syncContext?: Record<string, unknown>,
lastSyncAt?: Date
): Promise<ExternalDocumentList> => {
const maxCandidates = sourceConfig.maxCandidates ? Number(sourceConfig.maxCandidates) : 0
const parsedPage = cursor ? Number(cursor) : 1
const page = Number.isFinite(parsedPage) && parsedPage > 0 ? Math.floor(parsedPage) : 1
const updatedAfter = computeUpdatedAfter(lastSyncAt)
const jobId = typeof sourceConfig.jobId === 'string' ? sourceConfig.jobId.trim() : ''
const createdAfter =
typeof sourceConfig.createdAfter === 'string' ? sourceConfig.createdAfter.trim() : ''
const createdBefore =
typeof sourceConfig.createdBefore === 'string' ? sourceConfig.createdBefore.trim() : ''
const queryParams = new URLSearchParams({
per_page: String(CANDIDATES_PER_PAGE),
page: String(page),
})
if (updatedAfter) queryParams.set('updated_after', updatedAfter)
if (jobId) queryParams.set('job_id', jobId)
if (createdAfter) queryParams.set('created_after', createdAfter)
if (createdBefore) queryParams.set('created_before', createdBefore)
const url = `${GREENHOUSE_API_BASE}/candidates?${queryParams.toString()}`
logger.info('Listing Greenhouse candidates', {
page,
perPage: CANDIDATES_PER_PAGE,
incremental: Boolean(updatedAfter),
})
const response = await fetchWithRetry(url, {
method: 'GET',
headers: { Authorization: buildAuthHeader(accessToken), Accept: 'application/json' },
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
logger.error('Failed to list Greenhouse candidates', {
status: response.status,
error: errorText.slice(0, 500),
})
throw new Error(`Failed to list Greenhouse candidates: ${response.status}`)
}
const data = (await response.json()) as GreenhouseCandidate[]
const candidates = Array.isArray(data) ? data : []
const linkHasNext = hasNextPage(response.headers.get('link'))
const prevFetched = (syncContext?.totalDocsFetched as number) ?? 0
let pageCandidates = candidates
if (maxCandidates > 0) {
const remaining = Math.max(0, maxCandidates - prevFetched)
if (pageCandidates.length > remaining) {
pageCandidates = pageCandidates.slice(0, remaining)
}
}
const documents = pageCandidates.map(candidateToStub)
const totalFetched = prevFetched + documents.length
if (syncContext) syncContext.totalDocsFetched = totalFetched
const hitLimit = maxCandidates > 0 && totalFetched >= maxCandidates
if (hitLimit && syncContext) syncContext.listingCapped = true
const hasMore = !hitLimit && linkHasNext
return {
documents,
nextCursor: hasMore ? String(page + 1) : undefined,
hasMore,
}
},
getDocument: async (
accessToken: string,
_sourceConfig: Record<string, unknown>,
externalId: string
): Promise<ExternalDocument | null> => {
try {
if (!externalId) return null
const candidate = await fetchCandidate(accessToken, externalId)
if (!candidate) return null
const applicationIds = Array.isArray(candidate.application_ids)
? candidate.application_ids
: []
const [feed, scorecards] = await Promise.all([
fetchActivityFeed(accessToken, externalId),
fetchScorecards(accessToken, applicationIds),
])
const content = formatContent(candidate, feed, scorecards)
if (!content.trim()) return null
return {
externalId: String(candidate.id),
title: candidateDisplayName(candidate),
content,
contentDeferred: false,
mimeType: 'text/plain',
sourceUrl: buildSourceUrl(candidate.id),
contentHash: buildContentHash(candidate.id, candidate.updated_at),
metadata: buildMetadata(candidate),
}
} catch (error) {
logger.warn('Failed to get Greenhouse candidate', {
externalId,
error: toError(error).message,
})
return null
}
},
validateConfig: async (
accessToken: string,
sourceConfig: Record<string, unknown>
): Promise<{ valid: boolean; error?: string }> => {
const maxCandidates = sourceConfig.maxCandidates as string | undefined
if (maxCandidates && (Number.isNaN(Number(maxCandidates)) || Number(maxCandidates) < 0)) {
return { valid: false, error: 'Max candidates must be a non-negative number' }
}
try {
const response = await fetchWithRetry(
`${GREENHOUSE_API_BASE}/candidates?per_page=1`,
{
method: 'GET',
headers: { Authorization: buildAuthHeader(accessToken), Accept: 'application/json' },
},
VALIDATE_RETRY_OPTIONS
)
if (!response.ok) {
const errorText = await response.text().catch(() => '')
return {
valid: false,
error: `Greenhouse access failed: ${response.status}${errorText ? ` — ${errorText.slice(0, 200)}` : ''}`,
}
}
return { valid: true }
} catch (error) {
const message = getErrorMessage(error, 'Failed to validate configuration')
return { valid: false, error: message }
}
},
mapTags: (metadata: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = {}
const textFields = [
'candidateName',
'company',
'title',
'recruiter',
'coordinator',
'source',
] as const
for (const field of textFields) {
const value = metadata[field]
if (typeof value === 'string' && value.trim()) {
result[field] = value.trim()
}
}
if (typeof metadata.applicationCount === 'number' && metadata.applicationCount >= 0) {
result.applicationCount = metadata.applicationCount
}
const dateFields = ['updatedAt', 'lastActivity'] as const
for (const field of dateFields) {
const parsed = parseTagDate(metadata[field])
if (parsed) result[field] = parsed
}
return result
},
}