From dfe632f6b5535cd844393b2462f2bd0b942fc418 Mon Sep 17 00:00:00 2001 From: Konstantin Rybakov Date: Sun, 28 Jul 2024 13:45:24 +0300 Subject: [PATCH] Konstantin/rka 51 ashby (#14) * rka-51: add ashby * rka-51: fix jobs emails layout * rka-51: add applied handling * rka-48, rka-51: fix no results in list view --- app/add/components/variant/variants/url.tsx | 8 +- app/api/companies/process/route.ts | 7 + app/api/email/jobs/route.tsx | 13 +- app/components/list/actions/mark-property.ts | 2 + app/components/list/filter-panel.tsx | 109 +----- app/components/list/job-card-actions.tsx | 11 + app/components/list/job-card.tsx | 15 +- app/components/list/job-list.tsx | 38 ++- drizzle/0008_plain_midnight.sql | 1 + drizzle/0009_strong_mariko_yashida.sql | 25 ++ drizzle/0010_silly_joseph.sql | 8 + drizzle/meta/0008_snapshot.json | 233 +++++++++++++ drizzle/meta/0009_snapshot.json | 334 +++++++++++++++++++ drizzle/meta/0010_snapshot.json | 289 ++++++++++++++++ drizzle/meta/_journal.json | 21 ++ emails/jobs.tsx | 8 +- lib/db/queries.ts | 20 +- lib/db/queries/job-action.ts | 24 +- lib/db/schema.ts | 11 +- lib/hiring-platforms/ashby.ts | 161 +++++++++ lib/hiring-platforms/registry.ts | 2 + lib/utils/wait-for.ts | 3 + public/hiring-platforms/ashby.png | Bin 0 -> 3450 bytes 23 files changed, 1218 insertions(+), 125 deletions(-) create mode 100644 drizzle/0008_plain_midnight.sql create mode 100644 drizzle/0009_strong_mariko_yashida.sql create mode 100644 drizzle/0010_silly_joseph.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 drizzle/meta/0009_snapshot.json create mode 100644 drizzle/meta/0010_snapshot.json create mode 100644 lib/hiring-platforms/ashby.ts create mode 100644 lib/utils/wait-for.ts create mode 100644 public/hiring-platforms/ashby.png diff --git a/app/add/components/variant/variants/url.tsx b/app/add/components/variant/variants/url.tsx index 308052d..f74ee23 100644 --- a/app/add/components/variant/variants/url.tsx +++ b/app/add/components/variant/variants/url.tsx @@ -1,3 +1,4 @@ +import type { HiringPlatformName } from '@/lib/db/schema' import { normalizeURL } from '@/lib/utils/normalize-url' import { zodResolver } from '@hookform/resolvers/zod' import { CheckIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons' @@ -21,6 +22,11 @@ const schema = z.object({ url: z.string().min(1, 'URL must not be empty').url(), }) +const platformLogo: Record = { + ashby: 'ashby.png', + greenhouse: 'greenhouse.svg', +} + type FormType = z.infer export const VariantURL = () => { @@ -104,7 +110,7 @@ export const VariantURL = () => { {`${platform} { status: StatusCodes.INTERNAL_SERVER_ERROR, }, ) + } finally { + logger.info('Finished `job-processing` cron job') + logger.flush() + + // TODO: vercel edge functions finish too quick, before the logger flushes + await waitFor(1000) } } diff --git a/app/api/email/jobs/route.tsx b/app/api/email/jobs/route.tsx index 85a0ec9..1731b7d 100644 --- a/app/api/email/jobs/route.tsx +++ b/app/api/email/jobs/route.tsx @@ -1,6 +1,7 @@ import Jobs from '@/emails/jobs' import { queryGetJobs } from '@/lib/db/queries' import { logger } from '@/lib/logger' +import { waitFor } from '@/lib/utils/wait-for' import { render } from '@react-email/render' import { ReasonPhrases, StatusCodes } from 'http-status-codes' import { headers } from 'next/headers' @@ -8,10 +9,6 @@ import { Resend } from 'resend' const resend = new Resend(process.env.RESEND_API_KEY) -const delay = (ms: number) => { - return new Promise(resolve => setTimeout(resolve, ms)) -} - export const GET = async () => { try { logger.info('Starting `send-email` cron job') @@ -67,11 +64,9 @@ export const GET = async () => { ) } finally { logger.info('Finished `send-email` cron job') + logger.flush() - logger.flush(() => { - console.log('Logger flushed') - }) - - await delay(1000) + // TODO: vercel edge functions finish too quick, before the logger flushes + await waitFor(1000) } } diff --git a/app/components/list/actions/mark-property.ts b/app/components/list/actions/mark-property.ts index e640c4c..225ad2a 100644 --- a/app/components/list/actions/mark-property.ts +++ b/app/components/list/actions/mark-property.ts @@ -1,5 +1,6 @@ 'use server' import { + queryMarkApplied, queryMarkHidden, queryMarkSeen, queryMarkTopChoice, @@ -9,3 +10,4 @@ import { createAction } from './create-action' export const actionMarkTopChoice = createAction(queryMarkTopChoice) export const actionMarkHidden = createAction(queryMarkHidden) export const actionMarkSeen = createAction(queryMarkSeen) +export const actionMarkApplied = createAction(queryMarkApplied) diff --git a/app/components/list/filter-panel.tsx b/app/components/list/filter-panel.tsx index 46f0152..09d78d8 100644 --- a/app/components/list/filter-panel.tsx +++ b/app/components/list/filter-panel.tsx @@ -1,100 +1,27 @@ 'use client' - -import type { GetJobsFilter } from '@/lib/db/queries' -import { Button, Flex, Reset } from '@radix-ui/themes' -import Link from 'next/link' -import { usePathname, useSearchParams } from 'next/navigation' -import { useCallback } from 'react' - -const FilterButton = ({ - searchParams, - title, - active = false, -}: { searchParams: string; title: string; active?: boolean }) => { - const pathname = usePathname() - - return ( - - - - ) -} +import { SegmentedControl } from '@radix-ui/themes' +import { usePathname, useRouter } from 'next/navigation' export const FilterPanel = () => { - const searchParams = useSearchParams() - - const createSearchParams = useCallback( - (filter: GetJobsFilter, shouldReset = filter === 'all') => { - const newSearchParams = new URLSearchParams(searchParams) - - const shouldRemove = newSearchParams - .getAll('filter') - .some(existingFilter => existingFilter === filter) - - if (!newSearchParams.has('filter')) { - newSearchParams.set('filter', 'new') - } - - if (shouldRemove) { - newSearchParams.delete('filter', filter) - } else if (shouldReset) { - newSearchParams.set('filter', filter) - } else { - newSearchParams.delete('filter', 'all') - newSearchParams.append('filter', filter) - } + const pathname = usePathname() + const { push } = useRouter() - newSearchParams.delete('page') + const handleValueChange = (value: string) => { + const searchParams = new URLSearchParams({ filter: value }) - return newSearchParams.toString() - }, - [searchParams], - ) + push(`${pathname}?${searchParams}`) + } return ( - - -
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-
-
+ + New / Unseen + + Top Choice + + Seen + Hidden + Applied + All + ) } diff --git a/app/components/list/job-card-actions.tsx b/app/components/list/job-card-actions.tsx index b0a9a97..a22155b 100644 --- a/app/components/list/job-card-actions.tsx +++ b/app/components/list/job-card-actions.tsx @@ -4,10 +4,12 @@ import { EyeNoneIcon, EyeOpenIcon, LightningBoltIcon, + RocketIcon, } from '@radix-ui/react-icons' import { Text } from '@radix-ui/themes' import { ActionButton } from './action-button' import { + actionMarkApplied, actionMarkHidden, actionMarkSeen, actionMarkTopChoice, @@ -18,6 +20,7 @@ export const JobCardActions = ({ job }: { job: SelectJob }) => { const hidden = useJobAction(job.isHidden, actionMarkHidden) const topChoice = useJobAction(job.isTopChoice, actionMarkTopChoice) const seen = useJobAction(job.isSeen, actionMarkSeen) + const applied = useJobAction(job.isApplied, actionMarkApplied) return ( <> @@ -48,6 +51,14 @@ export const JobCardActions = ({ job }: { job: SelectJob }) => { icon={} label="Seen" /> + applied.clickHandler(job.id)} + colorActive="pink" + icon={} + label="Applied" + /> ) } diff --git a/app/components/list/job-card.tsx b/app/components/list/job-card.tsx index 35b4013..f91b32d 100644 --- a/app/components/list/job-card.tsx +++ b/app/components/list/job-card.tsx @@ -56,11 +56,16 @@ export const JobCard = async ({ job }: JobCardProps) => { }).format(new Date(job.lastUpdatedAt))}
- {isAdmin(user) && ( - - - - )} + + + {job.compensationSummary} + + {isAdmin(user) && ( + + + + )} +
) diff --git a/app/components/list/job-list.tsx b/app/components/list/job-list.tsx index 48edf5d..e8454c1 100644 --- a/app/components/list/job-list.tsx +++ b/app/components/list/job-list.tsx @@ -2,7 +2,8 @@ import { type GetJobsFilter, queryGetJobs } from '@/lib/db/queries' import { JobCard } from './job-card' import type { PageSearchParams } from '@/app/types' -import { Flex, Reset } from '@radix-ui/themes' +import { ValueIcon } from '@radix-ui/react-icons' +import { Callout, Flex, Reset } from '@radix-ui/themes' import { FilterPanel } from './filter-panel' import { Pagination } from './pagination' @@ -17,22 +18,35 @@ export const JobList = async ({ searchParams }: JobListProps) => { searchParams.page ? Number(searchParams.page) : 1, ) + const hasJobs = result.data.length > 0 + return ( - - {/* biome-ignore lint/a11y/noRedundantRoles: safari 👀 */} -
    - {result.data.map(job => ( -
  • - -
  • - ))} -
-
+ {hasJobs ? ( + + {/* biome-ignore lint/a11y/noRedundantRoles: safari 👀 */} +
    + {result.data.map(job => ( +
  • + +
  • + ))} +
+
+ ) : ( + + + + + + No jobs found. Try changing the filters. + + + )}
- + {hasJobs ? : null}
) } diff --git a/drizzle/0008_plain_midnight.sql b/drizzle/0008_plain_midnight.sql new file mode 100644 index 0000000..2d9f330 --- /dev/null +++ b/drizzle/0008_plain_midnight.sql @@ -0,0 +1 @@ +ALTER TYPE "hiring_platform" ADD VALUE 'ashby'; \ No newline at end of file diff --git a/drizzle/0009_strong_mariko_yashida.sql b/drizzle/0009_strong_mariko_yashida.sql new file mode 100644 index 0000000..3eb45a3 --- /dev/null +++ b/drizzle/0009_strong_mariko_yashida.sql @@ -0,0 +1,25 @@ +DO $$ BEGIN + CREATE TYPE "public"."compensation_type" AS ENUM('salary', 'equity'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "compensations" ( + "id" serial PRIMARY KEY NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "job_id" integer NOT NULL, + "type" "compensation_type" NOT NULL, + "currency_code" text, + "min_value" numeric(10, 3) NOT NULL, + "max_value" numeric(10, 3), + "summary" text, + "interval" text +); +--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "is_remote" boolean;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "compensations" ADD CONSTRAINT "compensations_job_id_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."jobs"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/drizzle/0010_silly_joseph.sql b/drizzle/0010_silly_joseph.sql new file mode 100644 index 0000000..ceb8dcf --- /dev/null +++ b/drizzle/0010_silly_joseph.sql @@ -0,0 +1,8 @@ +DROP TABLE "compensations";--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "currency_code" text;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "compensation_interval" text;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "compensation_summary" text;--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "salary_min" numeric(10, 3);--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "salary_max" numeric(10, 3);--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "equity_min" numeric(10, 3);--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "equity_max" numeric(10, 3); \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..b5e61a9 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,233 @@ +{ + "id": "2d7b55de-d4f7-4d68-b699-cab7459383d6", + "prevId": "3f6c5f5d-1c9c-46ed-8595-732cbc03d7fa", + "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 + } + }, + "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" + ] + }, + "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/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..9bb8de1 --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,334 @@ +{ + "id": "759f484c-8162-43c9-9987-5988c94b4f65", + "prevId": "2d7b55de-d4f7-4d68-b699-cab7459383d6", + "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.compensations": { + "name": "compensations", + "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()" + }, + "job_id": { + "name": "job_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "compensation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "currency_code": { + "name": "currency_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "min_value": { + "name": "min_value", + "type": "numeric(10, 3)", + "primaryKey": false, + "notNull": true + }, + "max_value": { + "name": "max_value", + "type": "numeric(10, 3)", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "interval": { + "name": "interval", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "compensations_job_id_jobs_id_fk": { + "name": "compensations_job_id_jobs_id_fk", + "tableFrom": "compensations", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "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 + } + }, + "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.compensation_type": { + "name": "compensation_type", + "schema": "public", + "values": [ + "salary", + "equity" + ] + }, + "public.hiring_platform": { + "name": "hiring_platform", + "schema": "public", + "values": [ + "greenhouse", + "ashby" + ] + }, + "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/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..4ae4f19 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,289 @@ +{ + "id": "1452fed9-07f1-4afb-9798-28f9bf828e85", + "prevId": "759f484c-8162-43c9-9987-5988c94b4f65", + "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.compensation_type": { + "name": "compensation_type", + "schema": "public", + "values": [ + "salary", + "equity" + ] + }, + "public.hiring_platform": { + "name": "hiring_platform", + "schema": "public", + "values": [ + "greenhouse", + "ashby" + ] + }, + "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 71b179d..ef31405 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,27 @@ "when": 1718042650768, "tag": "0007_ancient_lady_bullseye", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1721970987992, + "tag": "0008_plain_midnight", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1721973240199, + "tag": "0009_strong_mariko_yashida", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1722146825730, + "tag": "0010_silly_joseph", + "breakpoints": true } ] } \ No newline at end of file diff --git a/emails/jobs.tsx b/emails/jobs.tsx index a26d032..3d3c636 100644 --- a/emails/jobs.tsx +++ b/emails/jobs.tsx @@ -9,11 +9,17 @@ const CompanyJobs = ({

{company}

{jobs.map(job => ( -
+

{job.title}

{job.departments.join(', ')}

{job.location}

+ {job.compensationSummary && ( +

🤑 {job.compensationSummary}

+ )} { content: sql`excluded.content`, departments: sql`excluded.departments`, status: 'open', + salaryMin: sql`excluded.salary_min`, + salaryMax: sql`excluded.salary_max`, + equityMin: sql`excluded.equity_min`, + equityMax: sql`excluded.equity_max`, + compensationCurrencyCode: sql`excluded.currency_code`, + compensationInterval: sql`excluded.compensation_interval`, + compensationSummary: sql`excluded.compensation_summary`, + isRemote: sql`excluded.is_remote`, }, }) - .returning({ id: jobs.id }) + .returning({ id: jobs.id, url: jobs.url }) return result } export type QueryInsertJobsResult = ReturnType -export type GetJobsFilter = 'new' | 'seen' | 'hidden' | 'topChoice' | 'all' +export type GetJobsFilter = + | 'new' + | 'seen' + | 'hidden' + | 'topChoice' + | 'applied' + | 'all' const filterSettings = { all: undefined, @@ -77,10 +91,12 @@ const filterSettings = { eq(jobs.isSeen, false), eq(jobs.isHidden, false), eq(jobs.isTopChoice, false), + eq(jobs.isApplied, false), eq(jobs.status, 'open'), ), seen: eq(jobs.isSeen, true), hidden: eq(jobs.isHidden, true), + applied: eq(jobs.isApplied, true), topChoice: and(eq(jobs.isTopChoice, true), eq(jobs.status, 'open')), } as const diff --git a/lib/db/queries/job-action.ts b/lib/db/queries/job-action.ts index 91862ae..d31baa6 100644 --- a/lib/db/queries/job-action.ts +++ b/lib/db/queries/job-action.ts @@ -5,18 +5,29 @@ import { type SelectJob, jobs } from '../schema' type JobActionField = keyof Pick< SelectJob, - 'isTopChoice' | 'isHidden' | 'isSeen' + 'isTopChoice' | 'isHidden' | 'isSeen' | 'isApplied' > const createQuery = - (field: JobActionField) => async (jobId: SelectJob['id'], value: boolean) => { + (field: JobActionField, resetFields: JobActionField[] = []) => + async (jobId: SelectJob['id'], value: boolean) => { const l = logger.child({ jobId, field, value }) l.debug('Updating job') const result = await db .update(jobs) - .set({ [field]: value }) + .set({ + [field]: value, + ...resetFields.reduce( + (acc, fieldToReset) => { + acc[fieldToReset] = false + + return acc + }, + {} as Record, + ), + }) .where(eq(jobs.id, jobId)) .returning({ id: jobs.id }) @@ -31,3 +42,10 @@ export type QueryMarkHiddenResult = ReturnType export const queryMarkSeen = createQuery('isSeen') export type QueryMarkSeenResult = ReturnType + +export const queryMarkApplied = createQuery('isApplied', [ + 'isSeen', + 'isTopChoice', + 'isHidden', +]) +export type QueryMarkAppliedResult = ReturnType diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 99d09f9..18d352c 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -3,6 +3,7 @@ import { boolean, integer, json, + numeric, pgEnum, pgTable, serial, @@ -14,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'] as const +const hiringPlatforms = ['greenhouse', 'ashby'] as const export type HiringPlatformName = (typeof hiringPlatforms)[number] export const hiringPlatform = pgEnum('hiring_platform', hiringPlatforms) @@ -63,6 +64,14 @@ export const jobs = pgTable('jobs', { isHidden: boolean('is_hidden').notNull().default(false), isTopChoice: boolean('is_top_choice').notNull().default(false), isApplied: boolean('is_applied').notNull().default(false), + isRemote: boolean('is_remote'), + compensationCurrencyCode: text('currency_code'), + compensationInterval: text('compensation_interval'), + compensationSummary: text('compensation_summary'), + salaryMin: numeric('salary_min', { precision: 10, scale: 3 }), + salaryMax: numeric('salary_max', { precision: 10, scale: 3 }), + equityMin: numeric('equity_min', { precision: 10, scale: 3 }), + equityMax: numeric('equity_max', { precision: 10, scale: 3 }), }) export type SelectJob = typeof jobs.$inferSelect diff --git a/lib/hiring-platforms/ashby.ts b/lib/hiring-platforms/ashby.ts new file mode 100644 index 0000000..85adc65 --- /dev/null +++ b/lib/hiring-platforms/ashby.ts @@ -0,0 +1,161 @@ +import { queryInsertJobs, queryMarkJobsAsClosed } from '../db/queries' +import type { HiringPlatformName, InsertJob, SelectCompany } from '../db/schema' +import { logger } from '../logger' +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'] + + async checkURL(): Promise { + if (!this.allowedHosts.includes(this.url.host)) { + throw new Error('[Ashby] URL mismatch') + } + + const response = await fetch(this.getJobBoardURL()) + + if (!response.ok) { + throw new Error('[Ashby] Job board not found') + } + + return 'ashby' + } + + private getCompanyToken(): string { + 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` + } + + async fetchJobs(companyId: SelectCompany['id']): Promise { + const response = await fetch(this.getJobBoardAPIURL()) + + if (!response.ok) { + throw new Error('[Ashby] Job board API not found') + } + + const result = (await response.json()) as { jobs: AshbyJob[] } + + logger.debug( + `[Ashby][${this.getCompanyToken()}] Found ${result.jobs.length} jobs`, + ) + + const openJobs = result.jobs.map(job => this.mapJob(job, companyId)) + + await queryInsertJobs(openJobs) + await queryMarkJobsAsClosed(companyId, openJobs) + } + + private mapJob(job: AshbyJob, companyId: SelectCompany['id']): InsertJob { + const result: InsertJob = { + url: job.jobUrl, + title: job.title, + location: job.location, + lastUpdatedAt: new Date(job.publishedAt), + content: job.descriptionPlain, + departments: [job.department], + companyId, + isRemote: job.isRemote, + } + + if (job.compensation) { + // TODO: what happens if there are more tiers + const [compensationTier] = job.compensation.compensationTiers + + result.compensationSummary = job.compensation.compensationTierSummary + + const salaryComponent = compensationTier.components.find( + component => component.compensationType === 'Salary', + ) + + if (salaryComponent) { + result.salaryMin = + typeof salaryComponent.minValue === 'number' + ? String(salaryComponent.minValue) + : null + + result.salaryMax = + typeof salaryComponent.maxValue === 'number' + ? String(salaryComponent.maxValue) + : null + + result.compensationCurrencyCode = salaryComponent.currencyCode + result.compensationInterval = salaryComponent.interval + } + + const equityComponent = compensationTier.components.find( + component => component.compensationType === 'EquityPercentage', + ) + + if (equityComponent) { + result.equityMin = + typeof equityComponent.minValue === 'number' + ? String(equityComponent.minValue) + : null + + result.equityMax = + typeof equityComponent.maxValue === 'number' + ? String(equityComponent.maxValue) + : null + } + } + + return result + } +} diff --git a/lib/hiring-platforms/registry.ts b/lib/hiring-platforms/registry.ts index bbb5e3d..36fc730 100644 --- a/lib/hiring-platforms/registry.ts +++ b/lib/hiring-platforms/registry.ts @@ -1,4 +1,5 @@ import type { HiringPlatformName } from '../db/schema' +import { Ashby } from './ashby' import type { HiringPlatform } from './base' import { Greenhouse } from './greenhouse' @@ -15,6 +16,7 @@ const registerPlatform = ( ) => platformRegistry.set(name, platform) registerPlatform('greenhouse', Greenhouse) +registerPlatform('ashby', Ashby) export const createPlatform = ( name: HiringPlatformName, diff --git a/lib/utils/wait-for.ts b/lib/utils/wait-for.ts new file mode 100644 index 0000000..adf888d --- /dev/null +++ b/lib/utils/wait-for.ts @@ -0,0 +1,3 @@ +export const waitFor = (ms: number) => { + return new Promise(resolve => setTimeout(resolve, ms)) +} diff --git a/public/hiring-platforms/ashby.png b/public/hiring-platforms/ashby.png new file mode 100644 index 0000000000000000000000000000000000000000..43d23ef86a677a36ccc4d1dfdaf799b5d5e09ce2 GIT binary patch literal 3450 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 Mzi?S6SKBu5U#x~m-2eap literal 0 HcmV?d00001