Creating a resume builder with NextJS, Trigger.dev and GPT4
CTO, Trigger.dev
TL;DR
In this article, you will learn how to create a resume builder using NextJS, Trigger.dev, Resend, and OpenAI. 😲
- Add basic details such as First name, last name, and last places of work.
- Generate details such as Profile Summary, Work History, and Job Responsibilities.
- Create a PDF that contains all the information.
- Send everything to your email
Your background job platform 🔌
Trigger.dev is an open source library that enables you to create and monitor long-running jobs for your app with NextJS, Remix, Astro, and so many more!
Let's set it up 🔥
Set up a new project with NextJS
_10npx create-next-app@latest
We are going to create a simple form with basic information such as:
- First name
- Last name
- Email address
- Your profile picture
- And the experience you have until today!
We are going to work with NextJS's new app router.
Open layout.tsx
and add the following code
_28import { GeistSans } from "geist/font";_28import "./globals.css";_28_28const defaultUrl = process.env.VERCEL_URL_28 ? `https://${process.env.VERCEL_URL}`_28 : "http://localhost:3000";_28_28export const metadata = {_28 metadataBase: new URL(defaultUrl),_28 title: "Resume Builder with GPT4",_28 description: "The fastest way to build a resume with GPT4",_28};_28_28export default function RootLayout({_28 children,_28}: {_28 children: React.ReactNode,_28}) {_28 return (_28 <html lang="en" className={GeistSans.className}>_28 <body className="bg-background text-foreground">_28 <main className="flex min-h-screen flex-col items-center">_28 {children}_28 </main>_28 </body>_28 </html>_28 );_28}
We are basically setting the layout for all the pages (even though we have only one page.) We set the basic page metadata, background, and global CSS elements.
Next, let's open our page.tsx
and add the following code:
_11<div className="flex w-full flex-1 flex-col items-center">_11 <nav className="flex h-16 w-full justify-center border-b border-b-foreground/10">_11 <div className="flex w-full max-w-6xl items-center justify-between p-3 text-sm">_11 <span className="select-none font-bold">resumeGPT.</span>_11 </div>_11 </nav>_11_11 <div className="flex max-w-6xl flex-1 flex-col px-3 opacity-0 animate-in">_11 <Home />_11 </div>_11</div>
This sets the headline of our resumeGPT and the main home components.
The easiest way to build forms
The easiest way to save the form information and validate our fields is to use react-hook-form.
We are going to upload a profile picture. For that, we can't use JSON-based requests. We will need to convert the JSON into a valid form-data.
So let's install them all!
_10npm install react-hook-form object-to-formdata axios --save
Create a new folder called components add a new file called Home.tsx
, and add the following code:
_111"use client";_111_111import React, { useState } from "react";_111import {FormProvider, useForm} from "react-hook-form";_111import Companies from "@/components/Companies";_111import axios from "axios";_111import {serialize} from "object-to-formdata";_111_111export type TUserDetails = {_111 firstName: string;_111 lastName: string;_111 photo: string;_111 email: string;_111 companies: TCompany[];_111};_111_111export type TCompany = {_111 companyName: string;_111 position: string;_111 workedYears: string;_111 technologies: string;_111};_111_111const Home = () => {_111 const [finished, setFinished] = useState<boolean>(false);_111 const methods = useForm<TUserDetails>()_111_111 const {_111 register,_111 handleSubmit,_111 formState: { errors },_111 } = methods;_111_111 const handleFormSubmit = async (values: TUserDetails) => {_111 axios.post('/api/create', serialize(values));_111 setFinished(true);_111 };_111_111 if (finished) {_111 return (_111 <div className="mt-10">Sent to the queue! Check your email</div>_111 )_111 }_111_111 return (_111 <div className="flex flex-col items-center justify-center p-7">_111 <div className="w-full py-3 bg-slate-500 items-center justify-center flex flex-col rounded-t-lg text-white">_111 <h1 className="font-bold text-white text-3xl">Resume Builder</h1>_111 <p className="text-gray-300">_111 Generate a resume with GPT in seconds 🚀_111 </p>_111 </div>_111 <FormProvider {...methods}>_111 <form_111 onSubmit={handleSubmit(handleFormSubmit)}_111 className="p-4 w-full flex flex-col"_111 >_111 <div className="flex flex-col lg:flex-row gap-4">_111 <div className="flex flex-col w-full">_111 <label htmlFor="firstName">First name</label>_111 <input_111 type="text"_111 required_111 id="firstName"_111 placeholder="e.g. John"_111 className="p-3 rounded-md outline-none border border-gray-500 text-white bg-transparent"_111 {...register('firstName')}_111 />_111 </div>_111 <div className="flex flex-col w-full">_111 <label htmlFor="lastName">Last name</label>_111 <input_111 type="text"_111 required_111 id="lastName"_111 placeholder="e.g. Doe"_111 className="p-3 rounded-md outline-none border border-gray-500 text-white bg-transparent"_111 {...register('lastName')}_111 />_111 </div>_111 </div>_111 <hr className="w-full h-1 mt-3" />_111 <label htmlFor="email">Email Address</label>_111 <input_111 type="email"_111 required_111 id="email"_111 placeholder="e.g. [email protected]"_111 className="p-3 rounded-md outline-none border border-gray-500 text-white bg-transparent"_111 {...register('email', {required: true, pattern: /^\S+@\S+$/i})}_111 />_111 <hr className="w-full h-1 mt-3" />_111 <label htmlFor="photo">Upload your image 😎</label>_111 <input_111 type="file"_111 id="photo"_111 accept="image/x-png"_111 className="p-3 rounded-md outline-none border border-gray-500 mb-3"_111 {...register('photo', {required: true})}_111 />_111 <Companies />_111 <button className="p-4 pointer outline-none bg-blue-500 border-none text-white text-base font-semibold rounded-lg">_111 CREATE RESUME_111 </button>_111 </form>_111 </FormProvider>_111 </div>_111 );_111};_111_111export default Home;
You can see that we start with "use client"
which basically tells our component that it should run on the client only.
Why do we want client only? React states (input changes) are available only on the client side.
We set two interfaces, TUserDetails
and TCompany
. They represent the structure of the data we are working with.
We use useForm
with react-hook-form
. It creates a local state management to our inputs and allows us to update and validate our fields easily. you can see that in every input
, there is a simple register
function that specific the input name and validation and registers it to the managed state.
This is cool as we don't need to play with things like onChange
You can also see that we use FormProvider
, that's important as we want to have the Context of react-hook-form
in children components.
We also have a method called handleFormSubmit
. That's the method that is called once we submit the form. You can see that we use the serialize
function to convert our javascript object to FormData and send a request to the server to initiate the job with axios
.
And you can see another component called Companies
. That component will let us specify all the companies we worked for.
So let's work on it.
Create a new file called Companies.tsx
And add the following code:
_104import React, { useCallback, useEffect } from "react";_104_104import { TCompany } from "./Home";_104import { useFieldArray, useFormContext } from "react-hook-form";_104_104const Companies = () => {_104 const { control, register } = We();_104 const { fields: companies, append } = useFieldArray({_104 control,_104 name: "companies",_104 });_104_104 const addCompany = useCallback(() => {_104 append({_104 companyName: "",_104 position: "",_104 workedYears: "",_104 technologies: "",_104 });_104 }, [companies]);_104_104 useEffect(() => {_104 addCompany();_104 }, []);_104_104 return (_104 <div className="mb-4">_104 {companies.length > 1 ? (_104 <h3 className="my-3 text-3xl font-bold text-white">_104 Your list of Companies:_104 </h3>_104 ) : null}_104 {companies.length > 1 &&_104 companies.slice(1).map((company, index) => (_104 <div_104 key={index}_104 className="mb-4 rounded-lg border bg-gray-800 p-4 shadow-md"_104 >_104 <div className="mb-2">_104 <label htmlFor={`companyName-${index}`} className="text-white">_104 Company Name_104 </label>_104 <input_104 type="text"_104 id={`companyName-${index}`}_104 className="w-full rounded-md border border-gray-300 bg-transparent p-2"_104 {...register(`companies.${index}.companyName`, {_104 required: true,_104 })}_104 />_104 </div>_104_104 <div className="mb-2">_104 <label htmlFor={`position-${index}`} className="text-white">_104 Position_104 </label>_104 <input_104 type="text"_104 id={`position-${index}`}_104 className="w-full rounded-md border border-gray-300 bg-transparent p-2"_104 {...register(`companies.${index}.position`, { required: true })}_104 />_104 </div>_104_104 <div className="mb-2">_104 <label htmlFor={`workedYears-${index}`} className="text-white">_104 Worked Years_104 </label>_104 <input_104 type="number"_104 id={`workedYears-${index}`}_104 className="w-full rounded-md border border-gray-300 bg-transparent p-2"_104 {...register(`companies.${index}.workedYears`, {_104 required: true,_104 })}_104 />_104 </div>_104 <div className="mb-2">_104 <label htmlFor={`workedYears-${index}`} className="text-white">_104 Technologies_104 </label>_104 <input_104 type="text"_104 id={`technologies-${index}`}_104 className="w-full rounded-md border border-gray-300 bg-transparent p-2"_104 {...register(`companies.${index}.technologies`, {_104 required: true,_104 })}_104 />_104 </div>_104 </div>_104 ))}_104 <button_104 type="button"_104 onClick={addCompany}_104 className="pointer mb-4 w-full rounded-lg border-none bg-blue-900 p-2 text-base font-semibold text-white outline-none"_104 >_104 Add Company_104 </button>_104 </div>_104 );_104};_104_104export default Companies;
We start with useFormContext
, which allows us to get the context of the parent component.
Next, we use useFieldArray
to create a new state called companies. It is an array for all the companies that we have.
In the useEffect
, we add the first item of the array to iterate over it.
When clicking on addCompany
, it will push another element to the array.
We have finished with the client 🥳
Parse the HTTP request
Remember that we send a POST
request to /api/create
?
Let's go to our app/api folder and create a new folder called create
inside that folder, create a new file called route.tsx
and paste the following code:
_32import {NextRequest, NextResponse} from "next/server";_32import {client} from "@/trigger";_32_32export async function POST(req: NextRequest) {_32 const data = await req.formData();_32 const allArr = {_32 name: data.getAll('companies[][companyName]'),_32 position: data.getAll('companies[][position]'),_32 workedYears: data.getAll('companies[][workedYears]'),_32 technologies: data.getAll('companies[][technologies]'),_32 };_32_32 const payload = {_32 firstName: data.get('firstName'),_32 lastName: data.get('lastName'),_32 photo: Buffer.from((await (data.get('photo[0]') as File).arrayBuffer())).toString('base64'),_32 email: data.get('email'),_32 companies: allArr.name.map((name, index) => ({_32 companyName: allArr.name[index],_32 position: allArr.position[index],_32 workedYears: allArr.workedYears[index],_32 technologies: allArr.technologies[index],_32 })).filter((company) => company.companyName && company.position && company.workedYears && company.technologies)_32 }_32_32 await client.sendEvent({_32 name: 'create.resume',_32 payload_32 });_32_32 return NextResponse.json({ })_32}
This code will run only with NodeJS version 20+. If you have a lower version, it will not be able to parse FormData.
That code is pretty simple.
- We parse the request as FormData using
req.formData
- We convert the FormData-based request into a JSON file.
- We extract the image and convert it to
base64
- We send everything to TriggerDev
Build the resume and send it to your email 📨
Building the resume is a long-running task we need to
- Use ChatGPT to generate the content.
- Create a PDF
- Send it to your email
We don't want to make a long-running HTTP request to make all of those for a few reasons.
- When deploying to Vercel, there is a limit of 10 seconds on serverless functions. We will never make it on time.
- We want to keep the user from hanging for a long time. It's a bad UX. If the user closes the window, the entire process will fail.
Introducing Trigger.dev!
With Trigger.dev, you can run background processes inside of your NextJS app! You don't need to create a new server. They also know how to handle long-running jobs by breaking them into short tasks seamlessly.
Sign up for a Trigger.dev account. Once registered, create an organization and choose a project name for your job.
Select Next.js as your framework and follow the process for adding Trigger.dev to an existing Next.js project.
Otherwise, click Environments & API Keys
on the sidebar menu of your project dashboard.
Copy your DEV server API key and run the code snippet below to install Trigger.dev. Follow the instructions carefully.
_10npx @trigger.dev/cli@latest init
In another terminal, run the following code snippet to establish a tunnel between Trigger.dev and your Next.js project.
_10npx @trigger.dev/cli@latest dev
Let's create our TriggerDev job!
Head over to the newly created folder jobs and create a new file called create.resume.ts
.
Add the following code:
_23client.defineJob({_23 id: "create-resume",_23 name: "Create Resume",_23 version: "0.0.1",_23 trigger: eventTrigger({_23 name: "create.resume",_23 schema: z.object({_23 firstName: z.string(),_23 lastName: z.string(),_23 photo: z.string(),_23 email: z.string().email(),_23 companies: z.array(_23 z.object({_23 companyName: z.string(),_23 position: z.string(),_23 workedYears: z.string(),_23 technologies: z.string(),_23 })_23 ),_23 }),_23 }),_23 run: async (payload, io, ctx) => {},_23});
This will create a new job for us called create-resume
.
As you can see, there is a schema validation of the request we previously sent from our route.tsx
. That will give us validation and also auto-completion
.
We are going to run three jobs here
- ChatGPT
- Pdf creation
- Email sending
Let's start with ChatGPT.
Create an OpenAI account and generate an API Key.
Click View API key
from the dropdown to create an API Key.
Next, install the OpenAI package by running the code snippet below.
_10npm install @trigger.dev/openai
Add your OpenAI API key to the .env.local
file.
_10OPENAI_API_KEY=<your_api_key>
Create a new folder in the root directory called utils
.
inside that directory, create a new file called openai.ts
Add the following code:
_25import { OpenAI } from "openai";_25_25const openai = new OpenAI({_25 apiKey: process.env.OPENAI_API_KEY!,_25});_25_25export async function generateResumeText(prompt: string) {_25 const response = await openai.completions.create({_25 model: "text-davinci-003",_25 prompt,_25 max_tokens: 250,_25 temperature: 0.7,_25 top_p: 1,_25 frequency_penalty: 1,_25 presence_penalty: 1,_25 });_25_25 return response.choices[0].text.trim();_25}_25_25export const prompts = {_25 profileSummary: (fullName: string, currentPosition: string, workingExperience: string, knownTechnologies: string) => `I am writing a resume, my details are \n name: ${fullName} \n role: ${currentPosition} (${workingExperience} years). \n I write in the technologies: ${knownTechnologies}. Can you write a 100 words description for the top of the resume(first person writing)?`,_25 jobResponsibilities: (fullName: string, currentPosition: string, workingExperience: string, knownTechnologies: string) => `I am writing a resume, my details are \n name: ${fullName} \n role: ${currentPosition} (${workingExperience} years). \n I write in the technolegies: ${knownTechnologies}. Can you write 3 points for a resume on what I am good at?`,_25 workHistory: (fullName: string, currentPosition: string, workingExperience: string, details: TCompany[]) => `I am writing a resume, my details are \n name: ${fullName} \n role: ${currentPosition} (${workingExperience} years). ${companyDetails(details)} \n Can you write me 50 words for each company seperated in numbers of my succession in the company (in first person)?`,_25};
This code basically created the infrastructure to use ChatGPT and also 3 functions, profileSummary
, workingExperience
, and workHistory
. We will use them to create the content for the sections.
Go back to our create.resume.ts
and add the new job:
_69import { client } from "@/trigger";_69import { eventTrigger } from "@trigger.dev/sdk";_69import { z } from "zod";_69import { prompts } from "@/utils/openai";_69import { TCompany, TUserDetails } from "@/components/Home";_69_69const companyDetails = (companies: TCompany[]) => {_69 let stringText = "";_69 for (let i = 1; i < companies.length; i++) {_69 stringText += ` ${companies[i].companyName} as a ${companies[i].position} on technologies ${companies[i].technologies} for ${companies[i].workedYears} years.`;_69 }_69 return stringText;_69};_69_69client.defineJob({_69 id: "create-resume",_69 name: "Create Resume",_69 version: "0.0.1",_69 integrations: {_69 resend,_69 },_69 trigger: eventTrigger({_69 name: "create.resume",_69 schema: z.object({_69 firstName: z.string(),_69 lastName: z.string(),_69 photo: z.string(),_69 email: z.string().email(),_69 companies: z.array(_69 z.object({_69 companyName: z.string(),_69 position: z.string(),_69 workedYears: z.string(),_69 technologies: z.string(),_69 })_69 ),_69 }),_69 }),_69 run: async (payload, io, ctx) => {_69 const texts = await io.runTask("openai-task", async () => {_69 return Promise.all([_69 await generateResumeText(_69 prompts.profileSummary(_69 payload.firstName,_69 payload.companies[0].position,_69 payload.companies[0].workedYears,_69 payload.companies[0].technologies_69 )_69 ),_69 await generateResumeText(_69 prompts.jobResponsibilities(_69 payload.firstName,_69 payload.companies[0].position,_69 payload.companies[0].workedYears,_69 payload.companies[0].technologies_69 )_69 ),_69 await generateResumeText(_69 prompts.workHistory(_69 payload.firstName,_69 payload.companies[0].position,_69 payload.companies[0].workedYears,_69 payload.companies_69 )_69 ),_69 ]);_69 });_69 },_69});
We created a new task called openai-task
.
Inside that task, we simultaneously run three prompts with ChatGPT, and return them.
Creating the PDF
There are many ways to create a PDF
- You can use things like HTML2CANVAS and convert HTML code into an image and then a PDF.
- You can use things like
puppeteer
to scrape a web page and convert it to a PDF. - You can use different libraries that can create PDFs on the backend side.
In our case, we are going to use a simple library called jsPdf
it's very simplistic library to create PDF over the backend. I encourage you to create some more robust PDF files with Puppeteer and more HTML.
So let's install it
_10npm install jspdf @typs/jspdf --save
Let's return to utils
and create a new file called resume.ts
. That file will basically create a PDF file that we can send to the user's email.
Add the following content:
_72import { TUserDetails } from "@/components/Home";_72import { jsPDF } from "jspdf";_72_72type ResumeProps = {_72 userDetails: TUserDetails,_72 picture: string,_72 profileSummary: string,_72 workHistory: string,_72 jobResponsibilities: string,_72};_72_72export function createResume({_72 userDetails,_72 picture,_72 workHistory,_72 jobResponsibilities,_72 profileSummary,_72}: ResumeProps) {_72 const doc = new jsPDF();_72_72 // Title block_72 doc.setFontSize(24);_72 doc.setFont("helvetica", "bold");_72_72 doc.text(userDetails.firstName + " " + userDetails.lastName, 45, 27);_72 doc.setLineWidth(0.5);_72 doc.rect(20, 15, 170, 20); // x, y, width, height_72 doc.addImage({_72 imageData: picture,_72 x: 25,_72 y: 17,_72 width: 15,_72 height: 15,_72 });_72_72 // Reset font for the rest_72 doc.setFontSize(12);_72 doc.setFont("helvetica", "normal");_72_72 // Personal Information block_72 doc.setFontSize(14);_72 doc.setFont("helvetica", "bold");_72 doc.text("Summary", 20, 50);_72 doc.setFontSize(10);_72 doc.setFont("helvetica", "normal");_72 const splitText = doc.splitTextToSize(profileSummary, 170);_72 doc.text(splitText, 20, 60);_72_72 const newY = splitText.length * 5;_72_72 // Work history block_72 doc.setFontSize(14);_72 doc.setFont("helvetica", "bold");_72 doc.text("Work History", 20, newY + 65);_72 doc.setFontSize(10);_72 doc.setFont("helvetica", "normal");_72 const splitWork = doc.splitTextToSize(workHistory, 170);_72 doc.text(splitWork, 20, newY + 75);_72_72 const newNewY = splitWork.length * 5;_72_72 // Job Responsibilities block_72 doc.setFontSize(14);_72 doc.setFont("helvetica", "bold");_72 doc.text("Job Responsibilities", 20, newY + newNewY + 75);_72 doc.setFontSize(10);_72 doc.setFont("helvetica", "normal");_72 const splitJob = doc.splitTextToSize(jobResponsibilities, 170);_72 doc.text(splitJob, 20, newY + newNewY + 85);_72_72 return doc.output("datauristring");_72}
This file contains three sections: Personal Information
, Work history
, and Job Responsibilities
block.
We calculate where each block will be and what it will be.
Everything is set up in an absolute
way.
A notable thing is the splitTextToSize
to break the text into multiple lines, so it will not go off the screen.
Now, let's create the next task: open resume.ts
again and add the following code:
_86import { client } from "@/trigger";_86import { eventTrigger } from "@trigger.dev/sdk";_86import { z } from "zod";_86import { prompts } from "@/utils/openai";_86import { TCompany, TUserDetails } from "@/components/Home";_86import { createResume } from "@/utils/resume";_86_86const companyDetails = (companies: TCompany[]) => {_86 let stringText = "";_86 for (let i = 1; i < companies.length; i++) {_86 stringText += ` ${companies[i].companyName} as a ${companies[i].position} on technologies ${companies[i].technologies} for ${companies[i].workedYears} years.`;_86 }_86 return stringText;_86};_86_86client.defineJob({_86 id: "create-resume",_86 name: "Create Resume",_86 version: "0.0.1",_86 integrations: {_86 resend,_86 },_86 trigger: eventTrigger({_86 name: "create.resume",_86 schema: z.object({_86 firstName: z.string(),_86 lastName: z.string(),_86 photo: z.string(),_86 email: z.string().email(),_86 companies: z.array(_86 z.object({_86 companyName: z.string(),_86 position: z.string(),_86 workedYears: z.string(),_86 technologies: z.string(),_86 })_86 ),_86 }),_86 }),_86 run: async (payload, io, ctx) => {_86 const texts = await io.runTask("openai-task", async () => {_86 return Promise.all([_86 await generateResumeText(_86 prompts.profileSummary(_86 payload.firstName,_86 payload.companies[0].position,_86 payload.companies[0].workedYears,_86 payload.companies[0].technologies_86 )_86 ),_86 await generateResumeText(_86 prompts.jobResponsibilities(_86 payload.firstName,_86 payload.companies[0].position,_86 payload.companies[0].workedYears,_86 payload.companies[0].technologies_86 )_86 ),_86 await generateResumeText(_86 prompts.workHistory(_86 payload.firstName,_86 payload.companies[0].position,_86 payload.companies[0].workedYears,_86 payload.companies_86 )_86 ),_86 ]);_86 });_86_86 console.log("passed chatgpt");_86_86 const pdf = await io.runTask("convert-to-html", async () => {_86 const resume = createResume({_86 userDetails: payload,_86 picture: payload.photo,_86 profileSummary: texts[0],_86 jobResponsibilities: texts[1],_86 workHistory: texts[2],_86 });_86_86 return { final: resume.split(",")[1] };_86 });_86_86 console.log("converted to pdf");_86 },_86});
You can see we have added a new task called convert-to-html
. This will create the PDF for us, convert it to base64 and return it.
Let them know 🎤
We are reaching the end! The only thing left is to share it with the user. You can use any email service you want. We will use Resend.com
Visit the Signup page, create an account and an API Key, and save it into the .env.local
file.
_10RESEND_API_KEY=<place_your_API_key>
Install the Trigger.dev Resend integration package to your Next.js project.
_10npm install @trigger.dev/resend
All that is left to do is to add our last job! Fortunately, Trigger directly integrates with Resend, so we don't need to create a new "normal" task.
Here is the final code:
_86import { client } from "@/trigger";_86import { eventTrigger } from "@trigger.dev/sdk";_86import { z } from "zod";_86import { prompt } from "@/utils/openai";_86import { TCompany, TUserDetails } from "@/components/Home";_86import { createResume } from "@/utils/resume";_86import { Resend } from "@trigger.dev/resend";_86_86const resend = new Resend({_86 id: "resend",_86 apiKey: process.env.RESEND_API_KEY!,_86});_86_86const companyDetails = (companies: TCompany[]) => {_86 let stringText = "";_86 for (let i = 1; i < companies.length; i++) {_86 stringText += ` ${companies[i].companyName} as a ${companies[i].position} on technologies ${companies[i].technologies} for ${companies[i].workedYears} years.`;_86 }_86 return stringText;_86};_86_86client.defineJob({_86 id: "create-resume",_86 name: "Create Resume",_86 version: "0.0.1",_86 integrations: {_86 resend_86 },_86 trigger: eventTrigger({_86 name: "create.resume",_86 schema: z.object({_86 firstName: z.string(),_86 lastName: z.string(),_86 photo: z.string(),_86 email: z.string().email(),_86 companies: z.array(z.object({_86 companyName: z.string(),_86 position: z.string(),_86 workedYears: z.string(),_86 technologies: z.string()_86 }))_86 }),_86 }),_86 run: async (payload, io, ctx) => {_86_86 const texts = await io.runTask("openai-task", async () => {_86 return Promise.all([_86 await generateResumeText(prompts.profileSummary(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),_86 await generateResumeText(prompts.jobResponsibilities(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies[0].technologies)),_86 await generateResumeText(prompts.workHistory(payload.firstName, payload.companies[0].position, payload.companies[0].workedYears, payload.companies))_86 ]);_86 });_86_86 console.log('passed chatgpt');_86_86 const pdf = await io.runTask('convert-to-html', async () => {_86 const resume = createResume({_86 userDetails: payload,_86 picture: payload.photo,_86 profileSummary: texts[0],_86 jobResponsibilities: texts[1],_86 workHistory: texts[2],_86 });_86_86 return {final: resume.split(',')[1]}_86 });_86_86 console.log('converted to pdf');_86_86 await io.resend.sendEmail('send-email', {_86 to: payload.email,_86 subject: 'Resume',_86 html: 'Your resume is attached!',_86 attachments: [_86 {_86 filename: 'resume.pdf',_86 content: Buffer.from(pdf.final, 'base64'),_86 contentType: 'application/pdf',_86 }_86 ],_86 from: "Nevo David <[email protected]>",_86 });_86_86 console.log('Sent email');_86 },_86});
We have the Resend
instance at the top of our file loaded with our API key from the dashboard.
We have the
_10 integrations: {_10 resend_10 },
We added it to our job to use later inside of io
.
And finally, we have our job to send the PDF io.resend.sendEmail
A notable thing is the attachment inside it with the PDF file we generated in the previous step.
And we are done 🎉
You can check and run the full source code here: https://github.com/triggerdotdev/blog
Let's connect! 🔌
As an open-source developer, you're invited to join our community to contribute and engage with maintainers. Don't hesitate to visit our GitHub repository to contribute and create issues related to Trigger.dev.
The source for this tutorial is available here: https://github.com/triggerdotdev/blog/tree/main/blog-resume-builder
Thank you for reading!