diff --git a/.gitignore b/.gitignore index f202dc8..3121ba0 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ next-env.d.ts # vscode .vscode + +emails/mock/ diff --git a/app/api/email/jobs/route.tsx b/app/api/email/jobs/route.tsx new file mode 100644 index 0000000..6ead1c0 --- /dev/null +++ b/app/api/email/jobs/route.tsx @@ -0,0 +1,53 @@ +import Jobs from '@/emails/jobs' +import { queryGetJobs } from '@/lib/db/queries' +import { render } from '@react-email/render' +import { ReasonPhrases, StatusCodes } from 'http-status-codes' +import { headers } from 'next/headers' +import { Resend } from 'resend' + +const resend = new Resend(process.env.RESEND_API_KEY) + +export const POST = async () => { + try { + const headerList = headers() + const auth = headerList.get('Authorization') + + if (auth !== `Bearer ${process.env.CRON_SECRET}`) { + return Response.json( + { + message: ReasonPhrases.UNAUTHORIZED, + }, + { + status: StatusCodes.UNAUTHORIZED, + }, + ) + } + + const recipients = process.env.PERSONAL_EMAIL + + if (!recipients) { + throw new Error('No recipients found') + } + + const { data: jobs } = await queryGetJobs(['new']) + const { data: savedJobs } = await queryGetJobs(['topChoice']) + + const { data, error } = await resend.emails.send({ + from: 'OWAT ', + to: [recipients], + subject: "Today's Jobs", + html: render(), + }) + + if (error) { + throw error + } + + return Response.json({ data }, { status: StatusCodes.OK }) + } catch (error) { + return Response.json( + { error }, + { status: StatusCodes.INTERNAL_SERVER_ERROR }, + ) + } +} diff --git a/app/components/list/filter-panel.tsx b/app/components/list/filter-panel.tsx index a3794a3..46f0152 100644 --- a/app/components/list/filter-panel.tsx +++ b/app/components/list/filter-panel.tsx @@ -44,6 +44,8 @@ export const FilterPanel = () => { newSearchParams.append('filter', filter) } + newSearchParams.delete('page') + return newSearchParams.toString() }, [searchParams], diff --git a/bun.lockb b/bun.lockb index 907dac7..abb5af6 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/emails/config/tailwind.ts b/emails/config/tailwind.ts new file mode 100644 index 0000000..686761b --- /dev/null +++ b/emails/config/tailwind.ts @@ -0,0 +1,63 @@ +import type { TailwindConfig } from '@react-email/tailwind' + +export const tailwindConfig = { + theme: { + extend: { + colors: { + brand: '#FFBA18', + }, + }, + fontSize: { + xs: ['12px', { lineHeight: '16px' }], + sm: ['14px', { lineHeight: '20px' }], + base: ['16px', { lineHeight: '24px' }], + lg: ['18px', { lineHeight: '28px' }], + xl: ['20px', { lineHeight: '28px' }], + '2xl': ['24px', { lineHeight: '32px' }], + '3xl': ['30px', { lineHeight: '36px' }], + '4xl': ['36px', { lineHeight: '36px' }], + '5xl': ['48px', { lineHeight: '1' }], + '6xl': ['60px', { lineHeight: '1' }], + '7xl': ['72px', { lineHeight: '1' }], + '8xl': ['96px', { lineHeight: '1' }], + '9xl': ['144px', { lineHeight: '1' }], + }, + spacing: { + px: '1px', + 0: '0', + 0.5: '2px', + 1: '4px', + 1.5: '6px', + 2: '8px', + 2.5: '10px', + 3: '12px', + 3.5: '14px', + 4: '16px', + 5: '20px', + 6: '24px', + 7: '28px', + 8: '32px', + 9: '36px', + 10: '40px', + 11: '44px', + 12: '48px', + 14: '56px', + 16: '64px', + 20: '80px', + 24: '96px', + 28: '112px', + 32: '128px', + 36: '144px', + 40: '160px', + 44: '176px', + 48: '192px', + 52: '208px', + 56: '224px', + 60: '240px', + 64: '256px', + 72: '288px', + 80: '320px', + 96: '384px', + }, + }, +} satisfies TailwindConfig diff --git a/emails/jobs.tsx b/emails/jobs.tsx new file mode 100644 index 0000000..a26d032 --- /dev/null +++ b/emails/jobs.tsx @@ -0,0 +1,92 @@ +import type { QueryGetJobsResult } from '@/lib/db/queries' +import { Font, Head, Tailwind } from '@react-email/components' +import { tailwindConfig } from './config/tailwind' + +const CompanyJobs = ({ + company, + jobs, +}: { company: string; jobs: Awaited['data'] }) => ( +
+

{company}

+ {jobs.map(job => ( + + ))} +
+) + +const groupJobsByCompany = (jobs: Awaited['data']) => + jobs.reduce( + (acc, job) => { + if (!acc[job.company.name]) { + acc[job.company.name] = [] + } + + acc[job.company.name].push(job) + + return acc + }, + {} as Record['data']>, + ) + +const Jobs = ({ + newJobs, + savedJobs, +}: { + newJobs: Awaited['data'] + savedJobs: Awaited['data'] +}) => { + const newJobsGrouped = groupJobsByCompany(newJobs) + const savedJobsGrouped = groupJobsByCompany(savedJobs) + + return ( + + + + +
+

✨ New Jobs

+ + {newJobs.length ? ( + Object.entries(newJobsGrouped).map(([company, jobs]) => ( + + )) + ) : ( +

💦 No new jobs found

+ )} + +

♥️ Saved Jobs

+ + {savedJobs.length ? ( + Object.entries(savedJobsGrouped).map(([company, jobs]) => ( + + )) + ) : ( +

😭 No saved jobs found

+ )} +
+
+ ) +} + +export default Jobs diff --git a/lib/db/queries.ts b/lib/db/queries.ts index b7eff81..bd4d23e 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -1,6 +1,6 @@ // TODO: split this file -import { and, asc, count, eq, notInArray, or, sql } from 'drizzle-orm' +import { and, asc, count, desc, eq, notInArray, or, sql } from 'drizzle-orm' import { db } from './db' import { type InsertCompany, type InsertJob, companies, jobs } from './schema' @@ -77,16 +77,17 @@ const filterSettings = { eq(jobs.isSeen, false), eq(jobs.isHidden, false), eq(jobs.isTopChoice, false), + eq(jobs.status, 'open'), ), seen: eq(jobs.isSeen, true), hidden: eq(jobs.isHidden, true), - topChoice: eq(jobs.isTopChoice, true), + topChoice: and(eq(jobs.isTopChoice, true), eq(jobs.status, 'open')), } as const // TODO: dynamic limit export const LIMIT = 10 -export const queryGetJobs = async (filters: GetJobsFilter[], page: number) => { +export const queryGetJobs = async (filters: GetJobsFilter[], page?: number) => { const filterExpression = or(...filters.map(filter => filterSettings[filter])) const [total] = await db @@ -94,14 +95,18 @@ export const queryGetJobs = async (filters: GetJobsFilter[], page: number) => { .from(jobs) .where(filterExpression) + const pagination = page && { + offset: (page - 1) * LIMIT, + limit: LIMIT, + } + const data = await db.query.jobs.findMany({ with: { company: true, }, - orderBy: [asc(jobs.companyId), asc(jobs.title)], + orderBy: [asc(jobs.companyId), desc(jobs.lastUpdatedAt)], where: filterExpression, - offset: (page - 1) * LIMIT, - limit: LIMIT, + ...pagination, }) const result = { diff --git a/middleware.ts b/middleware.ts index 11fc89f..401c566 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,6 +1,9 @@ import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' -const isPublic = createRouteMatcher(['/api/companies/process']) +const isPublic = createRouteMatcher([ + '/api/companies/process', + '/api/email/jobs', +]) export default clerkMiddleware((auth, request) => { if (!isPublic(request)) { diff --git a/package.json b/package.json index 07c28a7..3d015fa 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "db:generate": "drizzle-kit generate --config drizzle.config.ts", "db:migrate": "bun run lib/db/migrate.ts", "db:reset": "bun run lib/db/reset.ts", - "prepare": "husky" + "prepare": "husky", + "email:dev": "email dev" }, "dependencies": { "@clerk/nextjs": "^5.1.4", @@ -23,6 +24,7 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-navigation-menu": "^1.1.4", "@radix-ui/themes": "^3.0.5", + "@react-email/components": "0.0.21", "drizzle-orm": "^0.31.0", "http-status-codes": "^2.3.0", "jotai": "^2.8.1", @@ -32,7 +34,9 @@ "pino-pretty": "^11.1.0", "react": "^18", "react-dom": "^18", + "react-email": "2.1.5", "react-hook-form": "^7.51.5", + "resend": "^3.4.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/vercel.json b/vercel.json index 666afe9..3cf6222 100644 --- a/vercel.json +++ b/vercel.json @@ -3,6 +3,10 @@ { "path": "/api/companies/process", "schedule": "0 10 * * *" + }, + { + "path": "/api/email/jobs", + "schedule": "0 11 * * *" } ] }