Skip to content

Commit

Permalink
Konstantin/rka 27 create emailing of new jobs (#12)
Browse files Browse the repository at this point in the history
* rka-27: add emailing

* rka-27: handle empty list

* rka-27: remove mock files

* rka-27: add newline to route file

* rka-27: remove mock jobs
  • Loading branch information
konstrybakov authored Jul 25, 2024
1 parent 2fa9706 commit 51a567d
Show file tree
Hide file tree
Showing 10 changed files with 236 additions and 8 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,5 @@ next-env.d.ts

# vscode
.vscode

emails/mock/
53 changes: 53 additions & 0 deletions app/api/email/jobs/route.tsx
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>',
to: [recipients],
subject: "Today's Jobs",
html: render(<Jobs newJobs={jobs} savedJobs={savedJobs} />),
})

if (error) {
throw error
}

return Response.json({ data }, { status: StatusCodes.OK })
} catch (error) {
return Response.json(
{ error },
{ status: StatusCodes.INTERNAL_SERVER_ERROR },
)
}
}
2 changes: 2 additions & 0 deletions app/components/list/filter-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export const FilterPanel = () => {
newSearchParams.append('filter', filter)
}

newSearchParams.delete('page')

return newSearchParams.toString()
},
[searchParams],
Expand Down
Binary file modified bun.lockb
Binary file not shown.
63 changes: 63 additions & 0 deletions emails/config/tailwind.ts
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions emails/jobs.tsx
Original file line number Diff line number Diff line change
@@ -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<QueryGetJobsResult>['data'] }) => (
<section className="bg-blue-50 pt-2 p-4 rounded-md">
<h3 className="mt-2 font-semibold mb-4">{company}</h3>
{jobs.map(job => (
<article key={job.id} className="py-2 px-4 bg-white rounded-md">
<h4 className="my-2 font-semibold">{job.title}</h4>

<p className="text-sm text-slate-500">{job.departments.join(', ')}</p>
<p className="mb-4 text-sm">{job.location}</p>

<a
className="block mb-2 text-brand underline"
href={job.url}
target="_blank"
rel="noreferrer"
>
More info ...
</a>
</article>
))}
</section>
)

const groupJobsByCompany = (jobs: Awaited<QueryGetJobsResult>['data']) =>
jobs.reduce(
(acc, job) => {
if (!acc[job.company.name]) {
acc[job.company.name] = []
}

acc[job.company.name].push(job)

return acc
},
{} as Record<string, Awaited<QueryGetJobsResult>['data']>,
)

const Jobs = ({
newJobs,
savedJobs,
}: {
newJobs: Awaited<QueryGetJobsResult>['data']
savedJobs: Awaited<QueryGetJobsResult>['data']
}) => {
const newJobsGrouped = groupJobsByCompany(newJobs)
const savedJobsGrouped = groupJobsByCompany(savedJobs)

return (
<Tailwind config={tailwindConfig}>
<Head>
<Font
fontFamily="Work Sans"
fallbackFontFamily="Helvetica"
webFont={{
url: 'https://fonts.googleapis.com/css2?family=Work+Sans:wght@400;500;600&display=swap',
format: 'opentype',
}}
/>
</Head>
<main className="md:container mx-auto">
<h2>✨ New Jobs</h2>

{newJobs.length ? (
Object.entries(newJobsGrouped).map(([company, jobs]) => (
<CompanyJobs key={company} company={company} jobs={jobs} />
))
) : (
<p>💦 No new jobs found</p>
)}

<h2>♥️ Saved Jobs</h2>

{savedJobs.length ? (
Object.entries(savedJobsGrouped).map(([company, jobs]) => (
<CompanyJobs key={company} company={company} jobs={jobs} />
))
) : (
<p>😭 No saved jobs found</p>
)}
</main>
</Tailwind>
)
}

export default Jobs
17 changes: 11 additions & 6 deletions lib/db/queries.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -77,31 +77,36 @@ 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
.select({ count: count() })
.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 = {
Expand Down
5 changes: 4 additions & 1 deletion middleware.ts
Original file line number Diff line number Diff line change
@@ -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)) {
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
{
"path": "/api/companies/process",
"schedule": "0 10 * * *"
},
{
"path": "/api/email/jobs",
"schedule": "0 11 * * *"
}
]
}

0 comments on commit 51a567d

Please sign in to comment.