diff --git a/.gitignore b/.gitignore index e4d6f72..f202dc8 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,8 @@ yarn-debug.log* yarn-error.log* # local env files -.env*.local + +.env # vercel .vercel diff --git a/app/add/company/actions/create-company.ts b/app/add/company/actions/create-company.ts new file mode 100644 index 0000000..24aa86b --- /dev/null +++ b/app/add/company/actions/create-company.ts @@ -0,0 +1,32 @@ +'use server' +import { + type QueryCreateCompanyResult, + queryCreateCompany, +} from '@/lib/db/queries' +import type { InsertCompany } from '@/lib/db/schema' +import { logger } from '@/lib/logger' +import type { ActionResponse } from '@/lib/types/api' + +export const actionCreateCompany = async ( + company: InsertCompany, +): Promise>> => { + try { + const result = await queryCreateCompany(company) + + logger.debug(result) + + return { + error: false, + data: result, + } + } catch (error) { + logger.error(error) + + const errorMessage = + error instanceof Error + ? error.message + : '[Action][CreateCompany] Unknown error' + + return { error: true, errorMessage } + } +} diff --git a/app/add/company/company-data.tsx b/app/add/company/company-data.tsx new file mode 100644 index 0000000..7105e6a --- /dev/null +++ b/app/add/company/company-data.tsx @@ -0,0 +1,81 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { RocketIcon } from '@radix-ui/react-icons' +import { Box, Button, Flex, Text, TextField } from '@radix-ui/themes' +import { useAtomValue } from 'jotai' +import { useForm } from 'react-hook-form' +import { z } from 'zod' +import { platformAtom, trackerURLAtom } from '../state' +import { actionCreateCompany } from './actions/create-company' + +const schema = z.object({ + name: z.string().min(1), +}) + +type FormType = z.infer + +export const CompanyName = () => { + const platform = useAtomValue(platformAtom) + const trackerURL = useAtomValue(trackerURLAtom) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + }) + + if (!platform || !trackerURL) { + return null + } + + // TODO: Handle errors + const createCompany = async ({ name }: FormType) => { + const result = await actionCreateCompany({ + trackerURL, + name, + hiringPlatform: platform, + trackerType: 'hiring_platform', // TODO: handle this logic properly + }) + + console.log(result) + } + + return ( +
+ + + + Company name + + + + + + + + + + + + + + {errors.name && ( + // TODO: Check a11y + + {errors.name.message} + + )} + + +
+ ) +} diff --git a/app/add/page.tsx b/app/add/page.tsx new file mode 100644 index 0000000..09698e3 --- /dev/null +++ b/app/add/page.tsx @@ -0,0 +1,30 @@ +import { Box, Heading, Section, Separator } from '@radix-ui/themes' + +import { Provider } from 'jotai' +import { CompanyName } from './company/company-data' +import { VariantPicker } from './variant/picker' +import { VariantRenderer } from './variant/renderer' + +export default function Add() { + return ( + +
+ + Add a company + +
+
+ +
+ + + +
+ +
+
+ +
+
+ ) +} diff --git a/app/add/state.ts b/app/add/state.ts new file mode 100644 index 0000000..3ca72ad --- /dev/null +++ b/app/add/state.ts @@ -0,0 +1,7 @@ +import type { HiringPlatform } 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 trackerURLAtom = atom(null) diff --git a/app/add/variant/actions/check-url.ts b/app/add/variant/actions/check-url.ts new file mode 100644 index 0000000..b94bfe3 --- /dev/null +++ b/app/add/variant/actions/check-url.ts @@ -0,0 +1,44 @@ +'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 { ActionResponse } from '@/lib/types/api' + +export const actionCheckURL = async ( + urlString: string, +): Promise> => { + try { + const isDuplicate = Boolean( + (await querySelectCompanyByTrackerURL(urlString)).length, + ) + + if (isDuplicate) { + throw new Error('This URL has already been added') + } + + const url = new URL(urlString) + + const platformCheckPromises = hiringPlatforms.map(Platform => { + const platform = new Platform(url) + + return platform.checkURL() + }) + + return { + data: await Promise.any(platformCheckPromises), + error: false, + } + } catch (error) { + const message = + error instanceof Error + ? error.message + : '[Action][Check URL] Unknown error' + + return { + error: true, + errorMessage: message, + } + } +} diff --git a/app/add/variant/picker.tsx b/app/add/variant/picker.tsx new file mode 100644 index 0000000..793a7ac --- /dev/null +++ b/app/add/variant/picker.tsx @@ -0,0 +1,37 @@ +'use client' + +import { Flex, RadioCards, Text } from '@radix-ui/themes' + +import { useAtom } from 'jotai' +import { variantAtom } from '../state' +import type { Variant } from './types' + +export const VariantPicker = () => { + const [variant, setVariant] = useAtom(variantAtom) + + return ( + setVariant(variant as Variant)} + defaultValue="url" + columns={{ initial: '1', sm: '4' }} + > + + + + Name + + + Search for a company by name and select from the results + + + + + + URL + Paste a URL of one of the well known hiring platforms + + + + ) +} diff --git a/app/add/variant/renderer.tsx b/app/add/variant/renderer.tsx new file mode 100644 index 0000000..9b1873d --- /dev/null +++ b/app/add/variant/renderer.tsx @@ -0,0 +1,12 @@ +'use client' + +import { useAtomValue } from 'jotai' +import { variantAtom } from '../state' +import { VariantURL } from './variants/url' + +// TODO: Rethink the renderer +export const VariantRenderer = () => { + const variant = useAtomValue(variantAtom) + + return variant === 'url' ? : <> +} diff --git a/app/add/variant/types.ts b/app/add/variant/types.ts new file mode 100644 index 0000000..c164eda --- /dev/null +++ b/app/add/variant/types.ts @@ -0,0 +1 @@ +export type Variant = 'search' | 'url' diff --git a/app/add/variant/variants/url.tsx b/app/add/variant/variants/url.tsx new file mode 100644 index 0000000..a7ec511 --- /dev/null +++ b/app/add/variant/variants/url.tsx @@ -0,0 +1,123 @@ +import { normalizeURL } from '@/lib/utils/normalize-url' +import { zodResolver } from '@hookform/resolvers/zod' +import { CheckIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons' +import { + Box, + Button, + Card, + Flex, + Heading, + Text, + TextField, +} from '@radix-ui/themes' +import { useAtom, useSetAtom } from 'jotai' +import Image from 'next/image' +import { useForm } from 'react-hook-form' +import { z } from 'zod' +import { platformAtom, trackerURLAtom } from '../../state' +import { actionCheckURL } from '../actions/check-url' + +const schema = z.object({ + url: z.string().min(1, 'URL must not be empty').url(), +}) + +type FormType = z.infer + +export const VariantURL = () => { + const [platform, setPlatform] = useAtom(platformAtom) + const setTrackerURL = useSetAtom(trackerURLAtom) + + const { + register, + handleSubmit, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + }) + + const checkURL = async ({ url }: FormType) => { + const normalizedURL = normalizeURL(url) + + const response = await actionCheckURL(normalizedURL) + + if (response.error) { + console.error(response.errorMessage) + + if (response.errorMessage === 'This URL has already been added') { + setError( + 'url', + { + type: 'manual', + message: response.errorMessage, + }, + { shouldFocus: true }, + ) + } + } else { + setPlatform(response.data) + setTrackerURL(normalizedURL) + } + } + + return ( + <> +
+ + + + Hiring Platform URL + + + + + + + + + + + + + + {errors.url && ( + // TODO: Check a11y + + {errors.url.message} + + )} + + +
+ {platform && ( + + + Matched hiring platform + + + + + {`${platform} + + {platform} + + + + + + + )} + + ) +} diff --git a/app/companies/[id]/page.tsx b/app/companies/[id]/page.tsx new file mode 100644 index 0000000..9844948 --- /dev/null +++ b/app/companies/[id]/page.tsx @@ -0,0 +1,12 @@ +import { querySelectCompany } from '@/lib/db/queries' + +export default async function Company({ params }: { params: { id: string } }) { + const [company] = await querySelectCompany(Number(params.id)) + + return ( +
+

{company.name}

+
{JSON.stringify(company, null, 2)}
+
+ ) +} diff --git a/app/companies/page.tsx b/app/companies/page.tsx new file mode 100644 index 0000000..3ed696e --- /dev/null +++ b/app/companies/page.tsx @@ -0,0 +1,19 @@ +import { querySelectAllCompanies } from '@/lib/db/queries' +import Link from 'next/link' + +export default async function Companies() { + const companies = await querySelectAllCompanies() + + return ( +
+

Companies

+
    + {companies.map(company => ( +
  • + {company.name} +
  • + ))} +
+
+ ) +} diff --git a/app/layout.tsx b/app/layout.tsx index 5b18566..5832099 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,10 +1,12 @@ -import type { Metadata } from "next" -import "./globals.css" -import { FontSans, FontSerif } from "./fonts" +import { Container, Flex, Theme } from '@radix-ui/themes' +import type { Metadata } from 'next' + +import '@radix-ui/themes/styles.css' +import { Navigation } from './navigation' export const metadata: Metadata = { - title: "OWAT!", - description: "Oh! What a tracker!", + title: 'OWAT!', + description: 'Oh! What a tracker!', } export default function RootLayout({ @@ -13,8 +15,17 @@ export default function RootLayout({ children: React.ReactNode }>) { return ( - - {children} + + + + + + + {children} + + + + ) } diff --git a/app/navigation.tsx b/app/navigation.tsx new file mode 100644 index 0000000..592b608 --- /dev/null +++ b/app/navigation.tsx @@ -0,0 +1,36 @@ +'use client' + +import * as NavigationMenu from '@radix-ui/react-navigation-menu' +import type { Route } from 'next' +import NextLink, { type LinkProps } from 'next/link' +import { usePathname } from 'next/navigation' +import type { FC, HTMLProps } from 'react' + +const Link: FC & HTMLProps> = ({ + href, + ...props +}) => { + const pathname = usePathname() + const isActive = pathname === href + + return ( + + + + ) +} + +export const Navigation = () => { + return ( + + + + Home + + + Companies + + + + ) +} diff --git a/app/page.tsx b/app/page.tsx index 476dfad..175a4d0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,25 @@ +import { PlusIcon } from '@radix-ui/react-icons' +import { Box, Button, Heading, Section } from '@radix-ui/themes' +import Link from 'next/link' + export default function Home() { return ( -
+ <> +
+ + Oh! What a tracker! + +
+
+ + + + + +
+ ) } diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..8c91267 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,16 @@ +import type { Config } from 'drizzle-kit' + +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL is required') +} + +export default { + schema: './lib/db/schema.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL, + }, + verbose: true, + strict: true, +} satisfies Config diff --git a/drizzle/0000_misty_zeigeist.sql b/drizzle/0000_misty_zeigeist.sql new file mode 100644 index 0000000..0cfc3d7 --- /dev/null +++ b/drizzle/0000_misty_zeigeist.sql @@ -0,0 +1,21 @@ +DO $$ BEGIN + CREATE TYPE "public"."hiring_platform" AS ENUM('greenhouse'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + CREATE TYPE "public"."tracker_type" AS ENUM('hiring_platform'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "companies" ( + "id" serial PRIMARY KEY NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "name" text NOT NULL, + "tracker_url" text NOT NULL, + "tracker_type" "tracker_type" NOT NULL, + "hiring_platform" "hiring_platform" +); diff --git a/drizzle/0001_deep_drax.sql b/drizzle/0001_deep_drax.sql new file mode 100644 index 0000000..c302965 --- /dev/null +++ b/drizzle/0001_deep_drax.sql @@ -0,0 +1 @@ +ALTER TABLE "companies" ADD CONSTRAINT "companies_tracker_url_unique" UNIQUE("tracker_url"); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..58305d7 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,86 @@ +{ + "id": "c0df3832-24c8-48dd-a433-3129d7dc9cfd", + "prevId": "00000000-0000-0000-0000-000000000000", + "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": {} + } + }, + "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/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..e1deac7 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,94 @@ +{ + "id": "916f74d8-7c78-43ed-8b84-49c9bfc9989a", + "prevId": "c0df3832-24c8-48dd-a433-3129d7dc9cfd", + "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" + ] + } + } + } + }, + "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/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..28e6d9e --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,20 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1717167098619, + "tag": "0000_misty_zeigeist", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1717249661243, + "tag": "0001_deep_drax", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/lib/db/db.ts b/lib/db/db.ts new file mode 100644 index 0000000..6ceaeac --- /dev/null +++ b/lib/db/db.ts @@ -0,0 +1,15 @@ +import { config } from 'dotenv' + +import { neon } from '@neondatabase/serverless' +import { drizzle } from 'drizzle-orm/neon-http' + +import * as schema from './schema' + +config({ path: '.env' }) + +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL is required') +} + +const sql = neon(process.env.DATABASE_URL) +export const db = drizzle(sql, { schema }) diff --git a/lib/db/migrate.ts b/lib/db/migrate.ts new file mode 100644 index 0000000..09e9b5c --- /dev/null +++ b/lib/db/migrate.ts @@ -0,0 +1,10 @@ +import { migrate } from 'drizzle-orm/neon-http/migrator' +import { logger } from '../logger' +import { db } from './db' + +try { + await migrate(db, { migrationsFolder: './drizzle' }) + logger.info('Migrations completed') +} catch (error) { + logger.error(error) +} diff --git a/lib/db/queries.ts b/lib/db/queries.ts new file mode 100644 index 0000000..033a84d --- /dev/null +++ b/lib/db/queries.ts @@ -0,0 +1,45 @@ +import { eq } from 'drizzle-orm' +import { db } from './db' +import { type InsertCompany, companies } from './schema' + +export const queryCreateCompany = async (company: InsertCompany) => { + const result = await db + .insert(companies) + .values(company) + .returning({ id: companies.id }) + + return result +} + +export type QueryCreateCompanyResult = ReturnType + +export const querySelectAllCompanies = async () => { + const result = await db.select().from(companies) + + return result +} + +export type QuerySelectAllCompaniesResult = ReturnType< + typeof querySelectAllCompanies +> + +export const querySelectCompanyByTrackerURL = async (trackerURL: string) => { + const result = await db + .select({ id: companies.id }) + .from(companies) + .where(eq(companies.trackerURL, trackerURL)) + + return result +} + +export type QuerySelectCompanyByTrackerURLResult = ReturnType< + typeof querySelectCompanyByTrackerURL +> + +export const querySelectCompany = async (id: number) => { + const result = await db.select().from(companies).where(eq(companies.id, id)) + + return result +} + +export type QuerySelectCompanyResult = ReturnType diff --git a/lib/db/reset.ts b/lib/db/reset.ts new file mode 100644 index 0000000..1db804c --- /dev/null +++ b/lib/db/reset.ts @@ -0,0 +1,8 @@ +if (!process.env.ALLOW_DB_RESET) { + throw new Error('💣 ALLOW_DB_RESET is required') +} + +import { db } from './db' +import { companies } from './schema' + +await db.delete(companies) diff --git a/lib/db/schema.ts b/lib/db/schema.ts new file mode 100644 index 0000000..9f71d9b --- /dev/null +++ b/lib/db/schema.ts @@ -0,0 +1,22 @@ +import { 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 const hiringPlatform = pgEnum('hiring_platform', hiringPlatforms) + +export const companies = pgTable('companies', { + id: serial('id').primaryKey(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + name: text('name').notNull(), + trackerURL: text('tracker_url').notNull().unique(), + trackerType: trackerType('tracker_type').notNull(), + hiringPlatform: hiringPlatform('hiring_platform'), +}) + +export type SelectCompany = typeof companies.$inferSelect +export type InsertCompany = typeof companies.$inferInsert diff --git a/lib/hiring-platforms/greenhouse.ts b/lib/hiring-platforms/greenhouse.ts new file mode 100644 index 0000000..c6b582e --- /dev/null +++ b/lib/hiring-platforms/greenhouse.ts @@ -0,0 +1,27 @@ +import type { HiringPlatform } from '../db/schema' + +export class GreenHouse { + constructor(private url: URL) {} + + async checkURL(): Promise { + if (!this.url.hostname.includes('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') + } + + return 'greenhouse' + } + + private getCompanyToken(): string { + return this.url.pathname.split('/')[1] + } + + private getJobBoardURL(): string { + return `https://boards.greenhouse.io/${this.getCompanyToken()}` + } +} diff --git a/lib/hiring-platforms/hiring-platforms.ts b/lib/hiring-platforms/hiring-platforms.ts new file mode 100644 index 0000000..08479b9 --- /dev/null +++ b/lib/hiring-platforms/hiring-platforms.ts @@ -0,0 +1,3 @@ +import { GreenHouse } from './greenhouse' + +export const hiringPlatforms = [GreenHouse] diff --git a/lib/logger.ts b/lib/logger.ts new file mode 100644 index 0000000..c9ae579 --- /dev/null +++ b/lib/logger.ts @@ -0,0 +1,5 @@ +import { pino } from 'pino' + +export const logger = pino({ + level: process.env.LOG_LEVEL || 'info', +}) diff --git a/lib/types/api.ts b/lib/types/api.ts new file mode 100644 index 0000000..1181b17 --- /dev/null +++ b/lib/types/api.ts @@ -0,0 +1,9 @@ +export type ActionResponse = + | { + data: Data + error: false + } + | { + error: true + errorMessage: string + } diff --git a/lib/utils/normalize-url.ts b/lib/utils/normalize-url.ts new file mode 100644 index 0000000..434e3a8 --- /dev/null +++ b/lib/utils/normalize-url.ts @@ -0,0 +1,8 @@ +import normalize from 'normalize-url' + +export const normalizeURL = (url: string) => + normalize(url, { + removeQueryParameters: true, + stripHash: true, + stripWWW: true, + }) diff --git a/public/hiring-platforms/greenhouse.svg b/public/hiring-platforms/greenhouse.svg new file mode 100644 index 0000000..648df0b --- /dev/null +++ b/public/hiring-platforms/greenhouse.svg @@ -0,0 +1,16 @@ + + + + + + + +