From 23e9ad8106fe7c92f2a959c8139c9a82551c925c Mon Sep 17 00:00:00 2001 From: Konstantin Rybakov Date: Wed, 7 Aug 2024 08:10:13 +0300 Subject: [PATCH] rka-53: add lever --- app/add/components/variant/picker.tsx | 10 - app/add/components/variant/variants/url.tsx | 1 + drizzle/0011_sad_manta.sql | 1 + drizzle/meta/0011_snapshot.json | 282 ++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/db/schema.ts | 2 +- lib/hiring-platforms/ashby.ts | 58 +--- lib/hiring-platforms/ashby/types.ts | 50 ++++ lib/hiring-platforms/greenhouse.ts | 20 +- lib/hiring-platforms/greenhouse/types.ts | 12 + lib/hiring-platforms/lever.ts | 100 +++++++ lib/hiring-platforms/lever/types.ts | 26 ++ lib/hiring-platforms/registry.ts | 2 + public/hiring-platforms/lever.svg | 1 + 14 files changed, 487 insertions(+), 85 deletions(-) create mode 100644 drizzle/0011_sad_manta.sql create mode 100644 drizzle/meta/0011_snapshot.json create mode 100644 lib/hiring-platforms/ashby/types.ts create mode 100644 lib/hiring-platforms/greenhouse/types.ts create mode 100644 lib/hiring-platforms/lever.ts create mode 100644 lib/hiring-platforms/lever/types.ts create mode 100644 public/hiring-platforms/lever.svg 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