From 152a3dce28c4407bfc75b5ec86713ad65517fb28 Mon Sep 17 00:00:00 2001 From: Konstantin Rybakov Date: Sun, 9 Jun 2024 14:28:13 +0300 Subject: [PATCH 1/3] rka-19: add jobs/jobs list --- app/add/state.ts | 4 +- app/add/variant/actions/check-url.ts | 14 +- app/companies/api/route.ts | 66 +++++++ app/job-card.tsx | 59 ++++++ app/job-list.tsx | 23 +++ app/navigation.tsx | 25 ++- app/page.tsx | 6 +- drizzle/0002_cynical_shatterstar.sql | 12 ++ drizzle/0003_keen_captain_marvel.sql | 6 + drizzle/0004_modern_norrin_radd.sql | 7 + drizzle/0005_smart_carnage.sql | 1 + drizzle/0006_marvelous_rumiko_fujikawa.sql | 7 + drizzle/meta/0002_snapshot.json | 168 +++++++++++++++++ drizzle/meta/0003_snapshot.json | 188 +++++++++++++++++++ drizzle/meta/0004_snapshot.json | 203 ++++++++++++++++++++ drizzle/meta/0005_snapshot.json | 204 +++++++++++++++++++++ drizzle/meta/0006_snapshot.json | 204 +++++++++++++++++++++ drizzle/meta/_journal.json | 35 ++++ lib/db/queries.ts | 63 ++++++- lib/db/schema.ts | 47 ++++- lib/hiring-platforms/base.ts | 8 + lib/hiring-platforms/greenhouse.ts | 68 ++++++- lib/hiring-platforms/hiring-platforms.ts | 3 - lib/hiring-platforms/registry.ts | 30 +++ lib/types/utils.ts | 3 + next.config.mjs | 6 +- 26 files changed, 1422 insertions(+), 38 deletions(-) create mode 100644 app/companies/api/route.ts create mode 100644 app/job-card.tsx create mode 100644 app/job-list.tsx create mode 100644 drizzle/0002_cynical_shatterstar.sql create mode 100644 drizzle/0003_keen_captain_marvel.sql create mode 100644 drizzle/0004_modern_norrin_radd.sql create mode 100644 drizzle/0005_smart_carnage.sql create mode 100644 drizzle/0006_marvelous_rumiko_fujikawa.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 lib/hiring-platforms/base.ts delete mode 100644 lib/hiring-platforms/hiring-platforms.ts create mode 100644 lib/hiring-platforms/registry.ts create mode 100644 lib/types/utils.ts diff --git a/app/add/state.ts b/app/add/state.ts index 3ca72ad..0d76d2e 100644 --- a/app/add/state.ts +++ b/app/add/state.ts @@ -1,7 +1,7 @@ -import type { HiringPlatform } from '@/lib/db/schema' +import type { HiringPlatformName } from '@/lib/db/schema' import { atom } from 'jotai' import type { Variant } from './variant/types' export const variantAtom = atom('url') -export const platformAtom = atom(null) +export const platformAtom = atom(null) export const trackerURLAtom = atom(null) diff --git a/app/add/variant/actions/check-url.ts b/app/add/variant/actions/check-url.ts index b94bfe3..669de74 100644 --- a/app/add/variant/actions/check-url.ts +++ b/app/add/variant/actions/check-url.ts @@ -1,14 +1,14 @@ 'use server' import { querySelectCompanyByTrackerURL } from '@/lib/db/queries' -import type { HiringPlatform } from '@/lib/db/schema' -import { hiringPlatforms } from '@/lib/hiring-platforms/hiring-platforms' +import type { HiringPlatformName } from '@/lib/db/schema' +import { platformRegistry } from '@/lib/hiring-platforms/registry' import type { ActionResponse } from '@/lib/types/api' export const actionCheckURL = async ( urlString: string, -): Promise> => { +): Promise> => { try { const isDuplicate = Boolean( (await querySelectCompanyByTrackerURL(urlString)).length, @@ -20,11 +20,13 @@ export const actionCheckURL = async ( const url = new URL(urlString) - const platformCheckPromises = hiringPlatforms.map(Platform => { + const platformCheckPromises = [] + + for (const Platform of platformRegistry.values()) { const platform = new Platform(url) - return platform.checkURL() - }) + platformCheckPromises.push(platform.checkURL()) + } return { data: await Promise.any(platformCheckPromises), diff --git a/app/companies/api/route.ts b/app/companies/api/route.ts new file mode 100644 index 0000000..f896e12 --- /dev/null +++ b/app/companies/api/route.ts @@ -0,0 +1,66 @@ +import { querySelectAllCompanies } from '@/lib/db/queries' +import type { SelectCompany } from '@/lib/db/schema' +import { createPlatform } from '@/lib/hiring-platforms/registry' + +import { logger } from '@/lib/logger' +import type { NonNullableProperty } from '@/lib/types/utils' +import { StatusCodes } from 'http-status-codes' + +// TODO: Express this through type also, on a lower level +const isHiringPlatform = ( + company: SelectCompany, +): company is NonNullableProperty => + company.trackerType === 'hiring_platform' + +export const POST = async () => { + logger.info('Processing companies and fetching jobs') + + try { + const companies = await querySelectAllCompanies() + + for (const company of companies) { + const start = performance.now() + + logger.info(`Processing company ${company.name}`) + + if (isHiringPlatform(company)) { + const platform = createPlatform( + company.hiringPlatform, + new URL(company.trackerURL), + ) + + await platform.fetchJobs(company.id) + } + + const end = performance.now() + + logger.info( + `Company ${company.name} processed in ${(end - start) / 1000}s`, + ) + } + + return Response.json( + { + message: 'Companies processed', + }, + { + status: StatusCodes.OK, + }, + ) + } catch (error) { + logger.error(error) + + const message = + error instanceof Error ? error.message : '[/companies/api] Unknown error' + + // TODO: normalize api return types + return Response.json( + { + message, + }, + { + status: StatusCodes.INTERNAL_SERVER_ERROR, + }, + ) + } +} diff --git a/app/job-card.tsx b/app/job-card.tsx new file mode 100644 index 0000000..8a81f47 --- /dev/null +++ b/app/job-card.tsx @@ -0,0 +1,59 @@ +import type { QueryGetJobsResult } from '@/lib/db/queries' +import { ExternalLinkIcon } from '@radix-ui/react-icons' +import { + AccessibleIcon, + Badge, + Card, + Flex, + Grid, + Heading, + Link, + Separator, + Text, +} from '@radix-ui/themes' +import NextLink from 'next/link' + +type JobCardProps = { + job: Awaited[number] +} + +// TODO: design, refactor +export const JobCard = async ({ job }: JobCardProps) => { + return ( + + + + {job.title} + + + + + + + + + {job.status} + + + + {job.location} + + + {job.company.name} + + + + {job.departments.join(' > ')} + + + + {new Intl.DateTimeFormat('en-UK', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(job.lastUpdatedAt))} + + + + + ) +} diff --git a/app/job-list.tsx b/app/job-list.tsx new file mode 100644 index 0000000..057a7e3 --- /dev/null +++ b/app/job-list.tsx @@ -0,0 +1,23 @@ +import { queryGetJobs } from '@/lib/db/queries' +import { JobCard } from './job-card' + +import { Flex, Reset } from '@radix-ui/themes' + +export const JobList = async () => { + const jobs = await queryGetJobs() + + return ( + + + {/* biome-ignore lint/a11y/noRedundantRoles: safari 👀 */} +
    + {jobs.map(job => ( +
  • + +
  • + ))} +
+
+
+ ) +} diff --git a/app/navigation.tsx b/app/navigation.tsx index 592b608..af9778b 100644 --- a/app/navigation.tsx +++ b/app/navigation.tsx @@ -1,6 +1,7 @@ 'use client' import * as NavigationMenu from '@radix-ui/react-navigation-menu' +import { Reset, Section } from '@radix-ui/themes' import type { Route } from 'next' import NextLink, { type LinkProps } from 'next/link' import { usePathname } from 'next/navigation' @@ -22,15 +23,19 @@ const Link: FC & HTMLProps> = ({ export const Navigation = () => { return ( - - - - Home - - - Companies - - - +
+ + + + + Home + + + Companies + + + + +
) } diff --git a/app/page.tsx b/app/page.tsx index 175a4d0..100eb26 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,8 +1,9 @@ import { PlusIcon } from '@radix-ui/react-icons' import { Box, Button, Heading, Section } from '@radix-ui/themes' import Link from 'next/link' +import { JobList } from './job-list' -export default function Home() { +export default async function Home() { return ( <>
@@ -20,6 +21,9 @@ export default function Home() {
+
+ +
) } diff --git a/drizzle/0002_cynical_shatterstar.sql b/drizzle/0002_cynical_shatterstar.sql new file mode 100644 index 0000000..f5ae533 --- /dev/null +++ b/drizzle/0002_cynical_shatterstar.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS "jobs" ( + "id" serial PRIMARY KEY NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "url" text NOT NULL, + "title" text NOT NULL, + "location" text NOT NULL, + "last_updated_at" timestamp NOT NULL, + "content" text NOT NULL, + "departments" json NOT NULL, + CONSTRAINT "jobs_url_unique" UNIQUE("url") +); diff --git a/drizzle/0003_keen_captain_marvel.sql b/drizzle/0003_keen_captain_marvel.sql new file mode 100644 index 0000000..715226f --- /dev/null +++ b/drizzle/0003_keen_captain_marvel.sql @@ -0,0 +1,6 @@ +ALTER TABLE "jobs" ADD COLUMN "company_id" integer NOT NULL;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "jobs" ADD CONSTRAINT "jobs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/drizzle/0004_modern_norrin_radd.sql b/drizzle/0004_modern_norrin_radd.sql new file mode 100644 index 0000000..3b0aa72 --- /dev/null +++ b/drizzle/0004_modern_norrin_radd.sql @@ -0,0 +1,7 @@ +DO $$ BEGIN + CREATE TYPE "public"."job_status" AS ENUM('open', 'closed'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +ALTER TABLE "jobs" ADD COLUMN "status" "job_status" NOT NULL; \ No newline at end of file diff --git a/drizzle/0005_smart_carnage.sql b/drizzle/0005_smart_carnage.sql new file mode 100644 index 0000000..17f29fc --- /dev/null +++ b/drizzle/0005_smart_carnage.sql @@ -0,0 +1 @@ +ALTER TABLE "jobs" ALTER COLUMN "status" SET DEFAULT 'open'; \ No newline at end of file diff --git a/drizzle/0006_marvelous_rumiko_fujikawa.sql b/drizzle/0006_marvelous_rumiko_fujikawa.sql new file mode 100644 index 0000000..cf516a4 --- /dev/null +++ b/drizzle/0006_marvelous_rumiko_fujikawa.sql @@ -0,0 +1,7 @@ +ALTER TABLE "jobs" DROP CONSTRAINT "jobs_company_id_companies_id_fk"; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "jobs" ADD CONSTRAINT "jobs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..6967609 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,168 @@ +{ + "id": "e3726936-3cb8-481d-84ce-7606138120aa", + "prevId": "916f74d8-7c78-43ed-8b84-49c9bfc9989a", + "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 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "jobs_url_unique": { + "name": "jobs_url_unique", + "nullsNotDistinct": false, + "columns": [ + "url" + ] + } + } + } + }, + "enums": { + "public.hiring_platform": { + "name": "hiring_platform", + "schema": "public", + "values": [ + "greenhouse" + ] + }, + "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/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..0138396 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,188 @@ +{ + "id": "4f182102-c127-4771-ae16-271d56ec402b", + "prevId": "e3726936-3cb8-481d-84ce-7606138120aa", + "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 + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "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": "no action", + "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" + ] + }, + "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/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..4fcd511 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,203 @@ +{ + "id": "ded8d859-90ae-4165-9bb8-a1ef1f969f75", + "prevId": "4f182102-c127-4771-ae16-271d56ec402b", + "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 + }, + "company_id": { + "name": "company_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "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": "no action", + "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" + ] + }, + "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/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..01f0b69 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,204 @@ +{ + "id": "98d6f773-731f-4b24-a284-e409456b519d", + "prevId": "ded8d859-90ae-4165-9bb8-a1ef1f969f75", + "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 + } + }, + "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": "no action", + "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" + ] + }, + "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/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..00489b1 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,204 @@ +{ + "id": "1affdfae-cbeb-4867-b25b-9dacad9b76f2", + "prevId": "98d6f773-731f-4b24-a284-e409456b519d", + "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 + } + }, + "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" + ] + }, + "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 28e6d9e..bcfd705 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,41 @@ "when": 1717249661243, "tag": "0001_deep_drax", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1717482293942, + "tag": "0002_cynical_shatterstar", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1717746338609, + "tag": "0003_keen_captain_marvel", + "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1717929356761, + "tag": "0004_modern_norrin_radd", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1717929384279, + "tag": "0005_smart_carnage", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1717929473056, + "tag": "0006_marvelous_rumiko_fujikawa", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 033a84d..fe6b85d 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -1,6 +1,9 @@ -import { eq } from 'drizzle-orm' +// TODO: split this file + +import { and, eq, notInArray, sql } from 'drizzle-orm' +import { logger } from '../logger' import { db } from './db' -import { type InsertCompany, companies } from './schema' +import { type InsertCompany, type InsertJob, companies, jobs } from './schema' export const queryCreateCompany = async (company: InsertCompany) => { const result = await db @@ -43,3 +46,59 @@ export const querySelectCompany = async (id: number) => { } export type QuerySelectCompanyResult = ReturnType + +export const queryInsertJobs = async (jobList: InsertJob[]) => { + const result = await db + .insert(jobs) + .values(jobList) + .onConflictDoUpdate({ + target: jobs.url, + setWhere: sql`jobs.last_updated_at < excluded.last_updated_at`, + set: { + title: sql`excluded.title`, + location: sql`excluded.location`, + lastUpdatedAt: sql`excluded.last_updated_at`, + content: sql`excluded.content`, + departments: sql`excluded.departments`, + status: 'open', + }, + }) + + return result +} + +export type QueryInsertJobsResult = ReturnType + +export const queryGetJobs = async () => { + const result = await db.query.jobs.findMany({ + with: { + company: true, + }, + }) + + return result +} + +export type QueryGetJobsResult = ReturnType + +export const queryMarkJobsAsClosed = async ( + companyId: number, + openJobs: InsertJob[], +) => { + logger.debug(openJobs.map(({ url }) => url)) + + const result = await db + .update(jobs) + .set({ status: 'closed' }) + .where( + and( + eq(jobs.companyId, companyId), + notInArray( + jobs.url, + openJobs.map(({ url }) => url), + ), + ), + ) + + return result +} diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 9f71d9b..ccd959f 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -1,11 +1,20 @@ -import { pgEnum, pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core' +import { relations } from 'drizzle-orm' +import { + integer, + json, + pgEnum, + pgTable, + serial, + text, + timestamp, +} from 'drizzle-orm/pg-core' const trackerTypes = ['hiring_platform'] as const export type TrackerType = (typeof trackerTypes)[number] export const trackerType = pgEnum('tracker_type', trackerTypes) const hiringPlatforms = ['greenhouse'] as const -export type HiringPlatform = (typeof hiringPlatforms)[number] +export type HiringPlatformName = (typeof hiringPlatforms)[number] export const hiringPlatform = pgEnum('hiring_platform', hiringPlatforms) export const companies = pgTable('companies', { @@ -20,3 +29,37 @@ export const companies = pgTable('companies', { export type SelectCompany = typeof companies.$inferSelect export type InsertCompany = typeof companies.$inferInsert + +export const companiesRelations = relations(companies, ({ many }) => ({ + jobs: many(jobs), +})) + +const jobStatuses = ['open', 'closed'] as const +export type JobStatus = (typeof jobStatuses)[number] +export const jobStatus = pgEnum('job_status', jobStatuses) + +export const jobs = pgTable('jobs', { + id: serial('id').primaryKey(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + url: text('url').notNull().unique(), + title: text('title').notNull(), + location: text('location').notNull(), + lastUpdatedAt: timestamp('last_updated_at').notNull(), + content: text('content').notNull(), + departments: json('departments').$type().notNull(), + status: jobStatus('status').notNull().default('open'), + companyId: integer('company_id') + .notNull() + .references(() => companies.id, { onDelete: 'cascade' }), +}) + +export type SelectJob = typeof jobs.$inferSelect +export type InsertJob = typeof jobs.$inferInsert + +export const jobsRelations = relations(jobs, ({ one }) => ({ + company: one(companies, { + fields: [jobs.companyId], + references: [companies.id], + }), +})) diff --git a/lib/hiring-platforms/base.ts b/lib/hiring-platforms/base.ts new file mode 100644 index 0000000..23f2fda --- /dev/null +++ b/lib/hiring-platforms/base.ts @@ -0,0 +1,8 @@ +import type { HiringPlatformName, SelectCompany } from '../db/schema' + +export abstract class HiringPlatform { + constructor(protected url: URL) {} + + abstract checkURL(): Promise + abstract fetchJobs(companyId: SelectCompany['id']): Promise +} diff --git a/lib/hiring-platforms/greenhouse.ts b/lib/hiring-platforms/greenhouse.ts index c6b582e..1ffcacc 100644 --- a/lib/hiring-platforms/greenhouse.ts +++ b/lib/hiring-platforms/greenhouse.ts @@ -1,17 +1,31 @@ -import type { HiringPlatform } from '../db/schema' +import { queryInsertJobs, queryMarkJobsAsClosed } from '../db/queries' +import type { HiringPlatformName, InsertJob, SelectCompany } from '../db/schema' +import { logger } from '../logger' +import { HiringPlatform } from './base' -export class GreenHouse { - constructor(private url: URL) {} +type GreenhouseJob = { + absolute_url: string + location: { + name: string + } + updated_at: string + title: string + content: string + departments: Array<{ + name: string + }> +} - async checkURL(): Promise { - if (!this.url.hostname.includes('greenhouse.io')) { - throw new Error('[GreenHouse] URL mismatch') +export class Greenhouse extends HiringPlatform { + async checkURL(): Promise { + if (!this.url.hostname.endsWith('greenhouse.io')) { + throw new Error('[Greenhouse] URL mismatch') } const response = await fetch(this.getJobBoardURL()) if (!response.ok) { - throw new Error('[GreenHouse] Job board not found') + throw new Error('[Greenhouse] Job board not found') } return 'greenhouse' @@ -24,4 +38,44 @@ export class GreenHouse { 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` + } + + async fetchJobs(companyId: SelectCompany['id']): Promise { + const response = await fetch(this.getJobBoardAPIURL()) + + if (!response.ok) { + throw new Error('[Greenhouse] Job board API not found') + } + + const result = (await response.json()) as { jobs: GreenhouseJob[] } + + logger.debug( + `[Greenhouse][${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: GreenhouseJob, + companyId: SelectCompany['id'], + ): InsertJob { + return { + url: job.absolute_url, + title: job.title, + location: job.location.name, + lastUpdatedAt: new Date(job.updated_at), + content: job.content, + departments: job.departments.map(department => department.name), + companyId, + } + } } diff --git a/lib/hiring-platforms/hiring-platforms.ts b/lib/hiring-platforms/hiring-platforms.ts deleted file mode 100644 index 08479b9..0000000 --- a/lib/hiring-platforms/hiring-platforms.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { GreenHouse } from './greenhouse' - -export const hiringPlatforms = [GreenHouse] diff --git a/lib/hiring-platforms/registry.ts b/lib/hiring-platforms/registry.ts new file mode 100644 index 0000000..bbb5e3d --- /dev/null +++ b/lib/hiring-platforms/registry.ts @@ -0,0 +1,30 @@ +import type { HiringPlatformName } from '../db/schema' +import type { HiringPlatform } from './base' +import { Greenhouse } from './greenhouse' + +type HiringPlatformConstructor = new (url: URL) => HiringPlatform + +export const platformRegistry = new Map< + HiringPlatformName, + HiringPlatformConstructor +>() + +const registerPlatform = ( + name: HiringPlatformName, + platform: HiringPlatformConstructor, +) => platformRegistry.set(name, platform) + +registerPlatform('greenhouse', Greenhouse) + +export const createPlatform = ( + name: HiringPlatformName, + url: URL, +): HiringPlatform => { + const Platform = platformRegistry.get(name) + + if (!Platform) { + throw new Error(`Unknown platform name: ${name}`) + } + + return new Platform(url) +} diff --git a/lib/types/utils.ts b/lib/types/utils.ts new file mode 100644 index 0000000..f523efc --- /dev/null +++ b/lib/types/utils.ts @@ -0,0 +1,3 @@ +export type NonNullableProperty = T & { + [P in K]-?: NonNullable +} diff --git a/next.config.mjs b/next.config.mjs index b44fec7..4678774 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,8 +1,4 @@ /** @type {import('next').NextConfig} */ -const nextConfig = { - experimental: { - typedRoutes: true - } -}; +const nextConfig = {}; export default nextConfig; From 187c296c1dd0e620d043bd8f47c82524d0f65e42 Mon Sep 17 00:00:00 2001 From: Konstantin Rybakov Date: Sun, 9 Jun 2024 14:31:48 +0300 Subject: [PATCH 2/3] rka-19: remove unnecessary async from home page --- app/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/page.tsx b/app/page.tsx index 100eb26..e12e755 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,7 +3,7 @@ import { Box, Button, Heading, Section } from '@radix-ui/themes' import Link from 'next/link' import { JobList } from './job-list' -export default async function Home() { +export default function Home() { return ( <>
From c57972a5ce045813367aeff2b5d85fb5b9be0259 Mon Sep 17 00:00:00 2001 From: Konstantin Rybakov Date: Sun, 9 Jun 2024 14:55:30 +0300 Subject: [PATCH 3/3] rka-19: fix the security warning, added allowed hosts --- lib/db/queries.ts | 3 --- lib/hiring-platforms/base.ts | 2 ++ lib/hiring-platforms/greenhouse.ts | 4 +++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/db/queries.ts b/lib/db/queries.ts index fe6b85d..bf3b2a7 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -1,7 +1,6 @@ // TODO: split this file import { and, eq, notInArray, sql } from 'drizzle-orm' -import { logger } from '../logger' import { db } from './db' import { type InsertCompany, type InsertJob, companies, jobs } from './schema' @@ -85,8 +84,6 @@ export const queryMarkJobsAsClosed = async ( companyId: number, openJobs: InsertJob[], ) => { - logger.debug(openJobs.map(({ url }) => url)) - const result = await db .update(jobs) .set({ status: 'closed' }) diff --git a/lib/hiring-platforms/base.ts b/lib/hiring-platforms/base.ts index 23f2fda..7ac23d5 100644 --- a/lib/hiring-platforms/base.ts +++ b/lib/hiring-platforms/base.ts @@ -3,6 +3,8 @@ import type { HiringPlatformName, SelectCompany } from '../db/schema' export abstract class HiringPlatform { constructor(protected url: URL) {} + abstract allowedHosts: string[] + abstract checkURL(): Promise abstract fetchJobs(companyId: SelectCompany['id']): Promise } diff --git a/lib/hiring-platforms/greenhouse.ts b/lib/hiring-platforms/greenhouse.ts index 1ffcacc..f7cb94c 100644 --- a/lib/hiring-platforms/greenhouse.ts +++ b/lib/hiring-platforms/greenhouse.ts @@ -17,8 +17,10 @@ type GreenhouseJob = { } export class Greenhouse extends HiringPlatform { + allowedHosts = ['boards.eu.greenhouse.io', 'boards.greenhouse.io'] + async checkURL(): Promise { - if (!this.url.hostname.endsWith('greenhouse.io')) { + if (!this.allowedHosts.includes(this.url.host)) { throw new Error('[Greenhouse] URL mismatch') }