diff --git a/app/add/components/variant/picker.tsx b/app/add/components/variant/picker.tsx
index 167df10..66fcb33 100644
--- a/app/add/components/variant/picker.tsx
+++ b/app/add/components/variant/picker.tsx
@@ -16,16 +16,6 @@ export const VariantPicker = () => {
defaultValue="url"
columns={{ initial: '1', sm: '4' }}
>
-
-
-
- Name
-
-
- Search for a company by name and select from the results
-
-
-
URL
diff --git a/app/add/components/variant/variants/url.tsx b/app/add/components/variant/variants/url.tsx
index f74ee23..7446126 100644
--- a/app/add/components/variant/variants/url.tsx
+++ b/app/add/components/variant/variants/url.tsx
@@ -25,6 +25,7 @@ const schema = z.object({
const platformLogo: Record = {
ashby: 'ashby.png',
greenhouse: 'greenhouse.svg',
+ lever: 'lever.svg',
}
type FormType = z.infer
diff --git a/drizzle/0011_sad_manta.sql b/drizzle/0011_sad_manta.sql
new file mode 100644
index 0000000..dee8e4c
--- /dev/null
+++ b/drizzle/0011_sad_manta.sql
@@ -0,0 +1 @@
+ALTER TYPE "hiring_platform" ADD VALUE 'lever';
\ No newline at end of file
diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json
new file mode 100644
index 0000000..d3bcb3c
--- /dev/null
+++ b/drizzle/meta/0011_snapshot.json
@@ -0,0 +1,282 @@
+{
+ "id": "5e3959bd-e0c5-449a-b5d1-59edef45cb02",
+ "prevId": "1452fed9-07f1-4afb-9798-28f9bf828e85",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.companies": {
+ "name": "companies",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tracker_url": {
+ "name": "tracker_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tracker_type": {
+ "name": "tracker_type",
+ "type": "tracker_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hiring_platform": {
+ "name": "hiring_platform",
+ "type": "hiring_platform",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "companies_tracker_url_unique": {
+ "name": "companies_tracker_url_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "tracker_url"
+ ]
+ }
+ }
+ },
+ "public.jobs": {
+ "name": "jobs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "serial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_updated_at": {
+ "name": "last_updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "departments": {
+ "name": "departments",
+ "type": "json",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "job_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "company_id": {
+ "name": "company_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_seen": {
+ "name": "is_seen",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_hidden": {
+ "name": "is_hidden",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_top_choice": {
+ "name": "is_top_choice",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_applied": {
+ "name": "is_applied",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_remote": {
+ "name": "is_remote",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "currency_code": {
+ "name": "currency_code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "compensation_interval": {
+ "name": "compensation_interval",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "compensation_summary": {
+ "name": "compensation_summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "salary_min": {
+ "name": "salary_min",
+ "type": "numeric(10, 3)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "salary_max": {
+ "name": "salary_max",
+ "type": "numeric(10, 3)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "equity_min": {
+ "name": "equity_min",
+ "type": "numeric(10, 3)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "equity_max": {
+ "name": "equity_max",
+ "type": "numeric(10, 3)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "jobs_company_id_companies_id_fk": {
+ "name": "jobs_company_id_companies_id_fk",
+ "tableFrom": "jobs",
+ "tableTo": "companies",
+ "columnsFrom": [
+ "company_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "jobs_url_unique": {
+ "name": "jobs_url_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "url"
+ ]
+ }
+ }
+ }
+ },
+ "enums": {
+ "public.hiring_platform": {
+ "name": "hiring_platform",
+ "schema": "public",
+ "values": [
+ "greenhouse",
+ "ashby",
+ "lever"
+ ]
+ },
+ "public.job_status": {
+ "name": "job_status",
+ "schema": "public",
+ "values": [
+ "open",
+ "closed"
+ ]
+ },
+ "public.tracker_type": {
+ "name": "tracker_type",
+ "schema": "public",
+ "values": [
+ "hiring_platform"
+ ]
+ }
+ },
+ "schemas": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index ef31405..7af5da4 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -78,6 +78,13 @@
"when": 1722146825730,
"tag": "0010_silly_joseph",
"breakpoints": true
+ },
+ {
+ "idx": 11,
+ "version": "7",
+ "when": 1723007063294,
+ "tag": "0011_sad_manta",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/lib/db/schema.ts b/lib/db/schema.ts
index 18d352c..aea714e 100644
--- a/lib/db/schema.ts
+++ b/lib/db/schema.ts
@@ -15,7 +15,7 @@ const trackerTypes = ['hiring_platform'] as const
export type TrackerType = (typeof trackerTypes)[number]
export const trackerType = pgEnum('tracker_type', trackerTypes)
-const hiringPlatforms = ['greenhouse', 'ashby'] as const
+const hiringPlatforms = ['greenhouse', 'ashby', 'lever'] as const
export type HiringPlatformName = (typeof hiringPlatforms)[number]
export const hiringPlatform = pgEnum('hiring_platform', hiringPlatforms)
diff --git a/lib/hiring-platforms/ashby.ts b/lib/hiring-platforms/ashby.ts
index 8ce355b..85cada2 100644
--- a/lib/hiring-platforms/ashby.ts
+++ b/lib/hiring-platforms/ashby.ts
@@ -1,59 +1,9 @@
import { queryInsertJobs, queryMarkJobsAsClosed } from '../db/queries'
import type { HiringPlatformName, InsertJob, SelectCompany } from '../db/schema'
import { logger } from '../logger'
+import type { AshbyJob } from './ashby/types'
import { HiringPlatform } from './base'
-type AshbyJob = {
- id: string
- title: string
- department: string
- location: string
- publishedAt: string
- isRemote: boolean
- jobUrl: string
- descriptionPlain: string
- compensation?: {
- compensationTierSummary: string
- scrapeableCompensationSalarySummary: string
- compensationTiers: Array<{
- tierSummary: string
- components: Array<
- | {
- summary: string
- compensationType: 'Salary'
- interval: string
- currencyCode: string
- minValue: number
- maxValue: number
- }
- | {
- summary: string
- compensationType: 'EquityPercentage'
- interval: string
- currencyCode: null
- minValue: number
- maxValue: number
- }
- >
- }>
- summaryComponents: Array<
- | {
- compensationType: 'Salary'
- currencyCode: string
- interval: string
- minValue: number
- maxValue: number
- }
- | {
- compensationType: 'EquityPercentage'
- currencyCode: null
- minValue: number
- maxValue: number
- }
- >
- }
-}
-
export class Ashby extends HiringPlatform {
allowedHosts = ['jobs.ashbyhq.com']
@@ -62,7 +12,7 @@ export class Ashby extends HiringPlatform {
throw new Error('[Ashby] URL mismatch')
}
- const response = await fetch(this.getJobBoardURL())
+ const response = await fetch(this.getJobBoardAPIURL())
if (!response.ok) {
throw new Error('[Ashby] Job board not found')
@@ -75,10 +25,6 @@ export class Ashby extends HiringPlatform {
return this.url.pathname.split('/')[1]
}
- private getJobBoardURL(): string {
- return `https://jobs.ashbyhq.com/${this.getCompanyToken()}`
- }
-
private getJobBoardAPIURL(): string {
return `https://api.ashbyhq.com/posting-api/job-board/${this.getCompanyToken()}?includeCompensation=true`
}
diff --git a/lib/hiring-platforms/ashby/types.ts b/lib/hiring-platforms/ashby/types.ts
new file mode 100644
index 0000000..54e1155
--- /dev/null
+++ b/lib/hiring-platforms/ashby/types.ts
@@ -0,0 +1,50 @@
+export type AshbyJob = {
+ id: string
+ title: string
+ department: string
+ location: string
+ publishedAt: string
+ isRemote: boolean
+ jobUrl: string
+ descriptionPlain: string
+ compensation?: {
+ compensationTierSummary: string
+ scrapeableCompensationSalarySummary: string
+ compensationTiers: Array<{
+ tierSummary: string
+ components: Array<
+ | {
+ summary: string
+ compensationType: 'Salary'
+ interval: string
+ currencyCode: string
+ minValue: number
+ maxValue: number
+ }
+ | {
+ summary: string
+ compensationType: 'EquityPercentage'
+ interval: string
+ currencyCode: null
+ minValue: number
+ maxValue: number
+ }
+ >
+ }>
+ summaryComponents: Array<
+ | {
+ compensationType: 'Salary'
+ currencyCode: string
+ interval: string
+ minValue: number
+ maxValue: number
+ }
+ | {
+ compensationType: 'EquityPercentage'
+ currencyCode: null
+ minValue: number
+ maxValue: number
+ }
+ >
+ }
+}
diff --git a/lib/hiring-platforms/greenhouse.ts b/lib/hiring-platforms/greenhouse.ts
index f8658a8..6cf009d 100644
--- a/lib/hiring-platforms/greenhouse.ts
+++ b/lib/hiring-platforms/greenhouse.ts
@@ -2,19 +2,7 @@ import { queryInsertJobs, queryMarkJobsAsClosed } from '../db/queries'
import type { HiringPlatformName, InsertJob, SelectCompany } from '../db/schema'
import { logger } from '../logger'
import { HiringPlatform } from './base'
-
-type GreenhouseJob = {
- absolute_url: string
- location: {
- name: string
- }
- updated_at: string
- title: string
- content: string
- departments: Array<{
- name: string
- }>
-}
+import type { GreenhouseJob } from './greenhouse/types'
export class Greenhouse extends HiringPlatform {
allowedHosts = [
@@ -28,7 +16,7 @@ export class Greenhouse extends HiringPlatform {
throw new Error('[Greenhouse] URL mismatch')
}
- const response = await fetch(this.getJobBoardURL())
+ const response = await fetch(this.getJobBoardAPIURL())
if (!response.ok) {
throw new Error('[Greenhouse] Job board not found')
@@ -41,10 +29,6 @@ export class Greenhouse extends HiringPlatform {
return this.url.pathname.split('/')[1]
}
- private getJobBoardURL(): string {
- return `https://boards.greenhouse.io/${this.getCompanyToken()}`
- }
-
private getJobBoardAPIURL(): string {
return `https://boards-api.greenhouse.io/v1/boards/${this.getCompanyToken()}/jobs?content=true`
}
diff --git a/lib/hiring-platforms/greenhouse/types.ts b/lib/hiring-platforms/greenhouse/types.ts
new file mode 100644
index 0000000..ca34856
--- /dev/null
+++ b/lib/hiring-platforms/greenhouse/types.ts
@@ -0,0 +1,12 @@
+export type GreenhouseJob = {
+ absolute_url: string
+ location: {
+ name: string
+ }
+ updated_at: string
+ title: string
+ content: string
+ departments: Array<{
+ name: string
+ }>
+}
diff --git a/lib/hiring-platforms/lever.ts b/lib/hiring-platforms/lever.ts
new file mode 100644
index 0000000..39d9655
--- /dev/null
+++ b/lib/hiring-platforms/lever.ts
@@ -0,0 +1,100 @@
+import { queryInsertJobs, queryMarkJobsAsClosed } from '../db/queries'
+import type { HiringPlatformName, InsertJob, SelectCompany } from '../db/schema'
+import { logger } from '../logger'
+import { HiringPlatform } from './base'
+import { type LeverJob, LeverWorkplaceType } from './lever/types'
+
+export class Lever extends HiringPlatform {
+ allowedHosts = ['jobs.lever.co']
+
+ async checkURL(): Promise {
+ console.log(this.url.host)
+
+ if (!this.allowedHosts.includes(this.url.host)) {
+ throw new Error('[Lever] URL Mismatch')
+ }
+
+ const response = await fetch(this.getJobBoardAPIURL())
+
+ if (!response.ok) {
+ throw new Error('[Lever] Job board not found')
+ }
+
+ return 'lever'
+ }
+
+ private getCompanyToken(): string {
+ return this.url.pathname.split('/')[1]
+ }
+
+ private getJobBoardAPIURL(): string {
+ console.log(this.getCompanyToken())
+
+ return `https://api.lever.co/v0/postings/${this.getCompanyToken()}`
+ }
+
+ async fetchJobs(companyId: SelectCompany['id']): Promise {
+ const response = await fetch(this.getJobBoardAPIURL())
+
+ if (!response.ok) {
+ throw new Error('[Lever] Job board API not found')
+ }
+
+ const jobs = (await response.json()) as LeverJob[]
+
+ logger.debug(`[Ashby][${this.getCompanyToken()}] Found ${jobs.length} jobs`)
+
+ const openJobs = jobs.map(job => this.mapJob(job, companyId))
+
+ if (openJobs.length) {
+ await queryInsertJobs(openJobs)
+ await queryMarkJobsAsClosed(companyId, openJobs)
+ }
+ }
+
+ private mapJob(job: LeverJob, companyId: SelectCompany['id']): InsertJob {
+ const result = {
+ url: job.hostedUrl,
+ title: job.text,
+ location: this.getLocation(job),
+ lastUpdatedAt: new Date(job.createdAt),
+ content: job.descriptionPlain,
+ departments: [job.categories.team, job.categories.department],
+ companyId,
+ isRemote: job.workplaceType === LeverWorkplaceType.remote,
+ ...this.getSalary(job),
+ } satisfies InsertJob
+
+ return result
+ }
+
+ private getLocation(job: LeverJob): string {
+ if (job.country && job.categories.location) {
+ return `${job.categories.location}, ${job.country}`
+ }
+
+ return job.categories.location || job.country
+ }
+
+ private getSalary(job: LeverJob) {
+ const currency = job.salaryRange?.currency ?? null
+ const min = job.salaryRange?.min ? String(job.salaryRange.min) : null
+ const max = job.salaryRange?.max ? String(job.salaryRange.max) : null
+ const interval = job.salaryRange?.interval ?? null
+
+ const result = {
+ salaryMin: min,
+ salaryMax: max,
+ compensationCurrencyCode: currency,
+ compensationInterval: interval,
+ } satisfies Pick<
+ InsertJob,
+ | 'salaryMin'
+ | 'salaryMax'
+ | 'compensationCurrencyCode'
+ | 'compensationInterval'
+ >
+
+ return result
+ }
+}
diff --git a/lib/hiring-platforms/lever/types.ts b/lib/hiring-platforms/lever/types.ts
new file mode 100644
index 0000000..8618a62
--- /dev/null
+++ b/lib/hiring-platforms/lever/types.ts
@@ -0,0 +1,26 @@
+export enum LeverWorkplaceType {
+ unspecified = 'unspecified',
+ onSite = 'on-site',
+ remote = 'remote',
+ hybrid = 'hybrid',
+}
+
+export type LeverJob = {
+ createdAt: number
+ hostedUrl: string // url
+ text: string // title
+ workplaceType: LeverWorkplaceType // isRemote
+ descriptionPlain: string // content
+ salaryRange?: {
+ max: number
+ min: number
+ currency: string
+ interval: string
+ }
+ country: string
+ categories: {
+ team: string
+ department: string
+ location: string
+ }
+}
diff --git a/lib/hiring-platforms/registry.ts b/lib/hiring-platforms/registry.ts
index 36fc730..6797143 100644
--- a/lib/hiring-platforms/registry.ts
+++ b/lib/hiring-platforms/registry.ts
@@ -2,6 +2,7 @@ import type { HiringPlatformName } from '../db/schema'
import { Ashby } from './ashby'
import type { HiringPlatform } from './base'
import { Greenhouse } from './greenhouse'
+import { Lever } from './lever'
type HiringPlatformConstructor = new (url: URL) => HiringPlatform
@@ -17,6 +18,7 @@ const registerPlatform = (
registerPlatform('greenhouse', Greenhouse)
registerPlatform('ashby', Ashby)
+registerPlatform('lever', Lever)
export const createPlatform = (
name: HiringPlatformName,
diff --git a/public/hiring-platforms/lever.svg b/public/hiring-platforms/lever.svg
new file mode 100644
index 0000000..385fdf8
--- /dev/null
+++ b/public/hiring-platforms/lever.svg
@@ -0,0 +1 @@
+
\ No newline at end of file