Files
simstudioai--sim/apps/sim/lib/ashby/jobs.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

174 lines
5.9 KiB
TypeScript

import { createLogger } from '@sim/logger'
import { z } from 'zod'
const logger = createLogger('AshbyJobs')
/**
* The Ashby-hosted job board slug for Sim — the final path segment of
* `https://jobs.ashbyhq.com/sim`. Drives the public, no-auth job posting API.
*/
const ASHBY_JOB_BOARD_NAME = 'sim'
/** Public job posting API — returns every listed posting in one payload, no auth. */
const ASHBY_JOB_BOARD_URL = `https://api.ashbyhq.com/posting-api/job-board/${ASHBY_JOB_BOARD_NAME}?includeCompensation=true`
/** Revalidate the board hourly, shared across every render (build/revalidate-time cache). */
const REVALIDATE_SECONDS = 3600
/**
* An `http(s)`-only URL. `z.string().url()` alone accepts `javascript:`/`data:`
* (both parse as valid URLs), which would render as a live link, so the scheme is
* pinned explicitly — a posting whose `jobUrl` fails this is dropped rather than
* published as a clickable Apply link.
*/
const httpUrlSchema = z
.string()
.url()
.refine((value) => /^https?:\/\//i.test(value), 'Only http(s) URLs are allowed')
/**
* Tolerant schema for a single Ashby posting. The public board omits several
* fields depending on the posting, so everything beyond the identity/title is
* optional or nullable — Ashby is a third party and its payload varies per board.
*/
const ashbyPostingSchema = z
.object({
id: z.string(),
title: z.string(),
department: z.string().nullish(),
team: z.string().nullish(),
location: z.string().nullish(),
employmentType: z.string().nullish(),
workplaceType: z.string().nullish(),
isListed: z.boolean().nullish(),
isRemote: z.boolean().nullish(),
publishedAt: z.string().nullish(),
jobUrl: httpUrlSchema,
applyUrl: z.string().nullish(),
shouldDisplayCompensationOnJobPostings: z.boolean().nullish(),
compensation: z
.object({
compensationTierSummary: z.string().nullish(),
})
.nullish(),
})
.passthrough()
/**
* The board envelope validates loosely — each posting is validated individually
* in {@link getAshbyJobs} so a single malformed row is skipped rather than
* emptying the entire board.
*/
const ashbyJobBoardSchema = z.object({
apiVersion: z.string().nullish(),
jobs: z.array(z.unknown()),
})
/** Human-friendly labels for Ashby's enum-ish string fields. */
const EMPLOYMENT_TYPE_LABELS: Record<string, string> = {
FullTime: 'Full-time',
PartTime: 'Part-time',
Intern: 'Internship',
Contract: 'Contract',
Temporary: 'Temporary',
}
const WORKPLACE_TYPE_LABELS: Record<string, string> = {
OnSite: 'On-site',
Remote: 'Remote',
Hybrid: 'Hybrid',
}
/** A normalized, presentation-ready job posting for the careers page. */
export interface CareerPosting {
id: string
title: string
/** Grouping bucket — the posting's department (falls back to team, then "Other"). */
department: string
/** Display location, e.g. "San Francisco" or "Remote". */
location: string
/** Human employment type, e.g. "Full-time". Empty string when unknown. */
employmentType: string
/** Human workplace type, e.g. "On-site". Empty string when unknown. */
workplaceType: string
/** Compensation range summary when the posting opts to display it, else null. */
compensationSummary: string | null
/** Public detail URL on `jobs.ashbyhq.com`. */
jobUrl: string
}
/**
* Fetches the listed Sim job postings from Ashby's public job board API and
* normalizes them for the careers page. Cached at build/revalidate time and
* shared across renders. Mirrors {@link getGitHubStars}: it never throws — on any
* transport, status, or shape error it logs a warning and returns an empty list
* so the page renders its "no open roles" state instead of erroring.
*/
export async function getAshbyJobs(): Promise<CareerPosting[]> {
try {
const response = await fetch(ASHBY_JOB_BOARD_URL, {
headers: { Accept: 'application/json' },
next: { revalidate: REVALIDATE_SECONDS },
cache: 'force-cache',
})
if (!response.ok) {
logger.warn('Ashby job board request failed', { status: response.status })
return []
}
const envelope = ashbyJobBoardSchema.safeParse(await response.json())
if (!envelope.success) {
logger.warn('Ashby job board response failed validation', { issues: envelope.error.issues })
return []
}
const postings: CareerPosting[] = []
for (const raw of envelope.data.jobs) {
const parsed = ashbyPostingSchema.safeParse(raw)
if (!parsed.success) {
logger.warn('Skipping malformed Ashby posting', { issues: parsed.error.issues })
continue
}
if (parsed.data.isListed === false) continue
postings.push(normalizePosting(parsed.data))
}
return postings.sort(comparePostings)
} catch (error) {
logger.warn('Ashby job board request threw', { error })
return []
}
}
/** Maps a raw Ashby posting to the presentation-ready {@link CareerPosting} shape. */
function normalizePosting(job: z.infer<typeof ashbyPostingSchema>): CareerPosting {
const employmentType = job.employmentType
? (EMPLOYMENT_TYPE_LABELS[job.employmentType] ?? job.employmentType)
: ''
const workplaceType = job.workplaceType
? (WORKPLACE_TYPE_LABELS[job.workplaceType] ?? job.workplaceType)
: ''
const location = job.location?.trim() || (job.isRemote ? 'Remote' : '')
const compensationSummary =
job.shouldDisplayCompensationOnJobPostings && job.compensation?.compensationTierSummary
? job.compensation.compensationTierSummary
: null
return {
id: job.id,
title: job.title,
department: job.department?.trim() || job.team?.trim() || 'Other',
location,
employmentType,
workplaceType,
compensationSummary,
jobUrl: job.jobUrl,
}
}
/** Orders postings by department, then title — the render order and grouping key. */
function comparePostings(a: CareerPosting, b: CareerPosting): number {
return a.department.localeCompare(b.department) || a.title.localeCompare(b.title)
}