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((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 { 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 { 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 { 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 { 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, cursor?: string, syncContext?: Record, lastSyncAt?: Date ): Promise => { 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, externalId: string ): Promise => { 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 ): 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): Record => { const result: Record = {} 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 }, }