From d97cc3661cf29768d7d77727becdf3d8856d53ff Mon Sep 17 00:00:00 2001 From: Rami Abdou <38056800+ramiAbdou@users.noreply.github.com> Date: Thu, 8 Aug 2024 18:48:42 -0700 Subject: [PATCH 01/18] =?UTF-8?q?docs:=20simplify=20the=20`CONTRIBUTING.md?= =?UTF-8?q?`=20guide=20=F0=9F=8D=83=20(#438)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 327 ++++++++++++++++++++++-------------------------- README.md | 3 +- 2 files changed, 149 insertions(+), 181 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e3f0c773..34b65b1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # Contributing First off, thank you for taking the time to contribute! 🥳 ColorStack is nothing -without its community, and that certainly extends to the software that we build. +without its community and that certainly extends to the software that we build. This is a big team effort! ## First Things First @@ -10,225 +10,213 @@ The #1 reason that we decided to open source Oyster was so that ColorStack members can learn from and eventually contribute to a real-world production codebase. Everything we do is centered around our helping our members fulfuill their dreams of becoming software engineers. That being said, in order to make -space for our community, we will be prioritizing all contributions from -ColorStack members first, and then friends of ColorStack. ❤️ +space for our community, we will only accept contributions from ColorStack +members. ❤️ ## Table of Contents -- [Local Development](#local-development) - - [Prerequisites](#prerequisites) - - [Installing Node w/ `nvm`](#installing-node-w-nvm) - - [Fork and Clone Repository](#fork-and-clone-repository) - - [Project Dependencies](#project-dependencies) - - [Environment Variables](#environment-variables) - - [Database Setup](#database-setup) - - [Postgres Setup](#postgres-setup) - - [Executing Database Migrations](#executing-database-migrations) - - [Seeding the Database](#seeding-the-database) - - [Stopping the Database](#stopping-the-database) - - [Using a Database GUI (Prisma Studio)](#using-a-database-gui-prisma-studio) - - [Building the Project](#building-the-project) - - [Running the Applications](#running-the-applications) - - [Logging Into Applications](#logging-into-applications) - - [Enabling Integrations](#enabling-integrations) - - [Editor Setup](#editor-setup) -- [Making a Pull Request](#making-a-pull-request) - - [Your First PR](#your-first-pr) +- [Getting Started](#getting-started) +- [Your First PR](#your-first-pr) - [Deciding What to Work On](#deciding-what-to-work-on) - - [Proposing Ideas](#proposing-ideas) +- [Making a Pull Request](#making-a-pull-request) +- [Enabling Integrations](#enabling-integrations) - [License](#license) -## Local Development - -To get started with local development, please follow these simple steps. +## Getting Started -### Prerequisites +Follow these steps in order to get started with contributing to Oyster! -Please ensure that you have the following software on your machine: +1. Install [Docker Desktop](https://docs.docker.com/engine/install). -- [Docker](https://docs.docker.com/engine/install) -- [Node.js](https://nodejs.org/en/download/package-manager) (v20.x) -- [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (v1) +2. Install [Node.js](https://nodejs.org/en/download/package-manager) (v20.x). -#### Installing Node w/ `nvm` + 1. [Optional] Our recommendation is to use [`nvm`](https://nvm.sh) to install + Node. The main benefit of `nvm` is that it allows you to quickly install + and use different versions of Node on your machine. + 2. [Optional] If you choose to install Node.js with `nvm`, we would also + recommend setting up a + [shell integration](https://github.com/nvm-sh/nvm/blob/master/README.md#deeper-shell-integration), + which will automatically install the right Node version for any project + that you're working in, as long as there is a [`.nvmrc`](./.nvmrc) file + found in that directory. + 3. [Optional] If you choose to install Node.js with `nvm` but don't want to + set up a shell integration, you can switch to the appropriate Node version + manually by doing: -Our recommendation is to use [`nvm`](https://nvm.sh) to install Node. The main -benefit of `nvm` is that it allows you to quickly install and use different -versions of Node on your machine. + ```sh + nvm install && nvm use + ``` -If you choose to use `nvm`, we would also recommend setting up a -[shell integration](https://github.com/nvm-sh/nvm/blob/master/README.md#deeper-shell-integration), -which will automatically install the right node version for any given directory -that you're working in, as long as there is a [`.nvmrc`](./.nvmrc) file found in -that directory. +3. Install [Yarn](https://classic.yarnpkg.com/lang/en/docs/install) (v1). -If you don't want to set up a shell integration, you can switch to the -appropriate Node version manually by doing: - -```sh -nvm install && nvm use -``` - -### Fork and Clone Repository + ``` + npm install --global yarn + ``` -1. [Fork the repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) +4. [Fork the repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) to your own GitHub account. -2. [Clone the repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) + +5. [Clone the repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) to your local machine. + ``` git clone https://github.com//oyster.git ``` -3. [Configure the upstream repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/configuring-a-remote-repository-for-a-fork), + +6. Open the project in the editor of your choice and install all of our + [Recommend Extensions](https://code.visualstudio.com/docs/editor/extension-marketplace#_recommended-extensions). + You should see a popup to do this in VSCode the first time you open the + project! + +7. [Configure the upstream repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/configuring-a-remote-repository-for-a-fork), which will help you with [syncing your fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) with the Oyster codebase as new code is added to it in the future. + ``` git remote add upstream https://github.com/colorstackorg/oyster.git ``` -4. Create a new branch. - ``` - git checkout -b YOUR_BRANCH_NAME - ``` - -### Project Dependencies - -To install all project dependencies, run: - -```sh -yarn -``` - -### Environment Variables -To set up your environment variables, run: +8. Install all project dependencies: -``` -yarn env:setup -``` - -You'll now have `.env` files in all of your apps (and a couple packages)! + ```sh + yarn + ``` -You'll notice that a lot of environment variables are empty. Most of these empty -variables are tied to the 3rd party integrations we have with platforms such as -Google for authentication. If you would like to enable these integrations in -development, please see the -[How to Enable Integrations](./docs/how-to-enable-integrations.md) -documentation. +9. Set up your environment variables: -### Database Setup + ``` + yarn env:setup + ``` -You'll need to make sure that Postgres and Redis are running in the background. + You'll now have `.env` files in all of your apps (and a couple packages)! -#### Postgres Setup + You'll also notice that a lot of environment variables are empty. Most of + these empty variables are tied to the 3rd party integrations we have with + services such as Google for authentication. You shouldn't need to enable + these integrations unless you're working on a feature that touches that + service, but in case you need to enable an integration, please see the + [How to Enable Integrations](./docs/how-to-enable-integrations.md) + documentation. -To set up your Postgres databases, you can run: +10. Start your Postgres database and Redis store: -``` -yarn dx:up -``` + ``` + yarn dx:up + ``` -#### Executing Database Migrations +11. Run all the database migrations: -To execute the database migrations, run: + ```sh + yarn db:migrate + ``` -```sh -yarn db:migrate -``` +12. Seed your database with some "dummy" data: -#### Seeding the Database + ```sh + yarn db:seed + ``` -Now that we have some tables, we're ready to add some seed data in our database, -which will enable you to log into the Admin Dashboard and Member Profile. Run: + Be sure to follow the prompt to add your email to the database. -```sh -yarn db:seed -``` + This will enable you to log into both the Admin Dashboard and Member Profile + very soon! -Follow the prompt to add your email, and you will now be able to log into both -applications. +13. Build the project: -#### Stopping the Database + ```sh + yarn build + ``` -Once you are done developing, you might want to stop the database containers -from running. Keeping your containers up can eat up your battery life, so it's -recommended to take them down once you are done using them. Run: +14. Start all of the applications in development: -``` -yarn dx:down -``` + ```sh + yarn dev:apps + ``` -#### Using a Database GUI (Prisma Studio) +15. Open up the applications in the browser. -To make it easier to interact with and manage your data in the browser, you can -use [Prisma Studio](https://www.prisma.io/studio)! + 1. The Member Profile is running at http://localhost:3000. + 2. The Admin Dashboard is running at http://localhost:3001. -To get started, setup your Prisma schema file by running: +16. Log into both applications. In the development environment, you can bypass + the "real" authentication by doing the following: -``` -yarn prisma:setup -``` + 1. Click "Log In with OTP". + 2. Input the email that you seeded your database with. + 3. Input any 6-digit value (ie: 000000). -Then, start Prisma Studio locally and open your browser to the URL that gets -printed: + You should be logged in! -``` -yarn prisma:studio -``` +17. Set up [Prisma Studio](https://www.prisma.io/studio), a tool to make it + easier to interact with and manage your data in the browser: -### Building the Project + ```sh + yarn prisma:setup # Generates a Prisma schema file... + yarn prisma:studio # Starts Prisma Studio locally... + ``` -You can build the project by running: + You can now open up Prisma Studio in your browser at http://localhost:5555. -```sh -yarn build -``` +18. [Optional] Once you are done developing, you may want to stop the database + containers since they can eat up battery life. -### Running the Applications + ```sh + yarn dx:down + ``` -To run all of our _applications_, you can run: +That's it -- you've finished setting up Oyster locally! All your applications +are running properly and you're ready to get your first contribution in! -```sh -yarn dev:apps -``` +## Your First PR -To run a _specific package or application_, you can use the `--filter` flag like -this: +It's time to get your first pull request in! We love quick wins, so this first +one should only take a few minutes. Here's what we want you to do: -```sh -yarn dev --filter=api -``` +1. Create a new branch. -### Logging Into Applications + ``` + git checkout -b first-contribution + ``` -In the development environment, you can bypass any real authentication when -logging into the Member Profile and Admin Dashboard by doing the following: +2. Add your GitHub username to the [`CONTRIBUTORS.yml`](./CONTRIBUTORS.yml) + file. +3. Push this change up to GitHub (ie: `git add`, `git commit`, `git push`). +4. Create a pull request. + 1. The title can be: `chore: my first contribution 🚀` + 2. The description can be: `Added name to CONTRIBUTORS.yml!` +5. Here is an [example PR](https://github.com/colorstackorg/oyster/pull/417) in + case you want to follow one! -1. Click "Log In with OTP". -2. Input the email that you seeded your database with. -3. Input any 6-digit value. +Boom, you're all done! This should be approved and merged soon, and you'll +officially be an Oyster contributor! 🥳 -You should be logged in! +## Deciding What to Work On -### Enabling Integrations +You can start by browsing through our list of +[issues](https://github.com/colorstackorg/oyster/issues). Once you've decided on +an issue, leave a comment and wait to get approval from one of our codebase +admins - this helps avoid multiple people working on this same issue. -To enable any of our 3rd party integrations in development, please see the -[How to Enable Integrations](./docs/how-to-enable-integrations.md) -documentation. +Most of our work comes from our +[product roadmap](https://github.com/orgs/colorstackorg/projects/4) so if +there's something that interests you there that hasn't been converted into an +issue yet, feel free to ask about it. -To enable sending emails, please see the -[How to Enable Emails](./docs/how-to-enable-emails.md) documentation. +### Proposing Ideas -### Editor Setup +If you have a feature request or idea that would improve our product, please +start a thread in our +[`#oyster`](https://colorstack-family.slack.com/channels/C06S0DBFD6X) channel in +Slack! If the maintainers see value in the idea, they will add it to our +[product roadmap](https://github.com/orgs/colorstackorg/projects/4) or create an +[issue](https://github.com/colorstackorg/oyster/issues) directly. -Surprise, surprise. We use [VSCode](https://code.visualstudio.com/download) to -write code! After you download it, we recommend enabling some extensions to make -life a bit easier: +### Reporting Bugs -- [Auto Rename Tag](https://marketplace.visualstudio.com/items?itemName=formulahendry.auto-rename-tag) -- [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) -- [Live Share](https://marketplace.visualstudio.com/items?itemName=MS-vsliveshare.vsliveshare) -- [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) -- [Tailwind IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) +If you find a bug, please file a +[bug report](https://github.com/colorstackorg/oyster/issues/new?assignees=&labels=Bug+%F0%9F%90%9E&projects=&template=bug_report.md&title=) +directly! ## Making a Pull Request @@ -265,34 +253,13 @@ Some things to keep in mind when making a pull request: - All branches are up to date before merging. - All conversations are resolved. -### Your First PR - -Getting your first PR in is always the hardest. Lucky for you, we love quick -wins here at ColorStack, so we're going to reduce that barrier for you! After -you finish your [local development](#local-development) setup, your first PR can -simply be updating the [`CONTRIBUTORS.yml`](./CONTRIBUTORS.yml) file with your -GitHub username! - -You can name that PR: - -``` -chore: my first contribution ❤️ -``` - -## Deciding What to Work On - -You can start by browsing through our list of -[issues](https://github.com/colorstackorg/oyster/issues). Once you've decided on -an issue, leave a comment and wait to get approval from one of our codebase -admins - this helps avoid multiple people working on this same issue. - -### Proposing Ideas +### Enabling Integrations -If you have a feature request or idea that would improve our product, please -start a discussion in our -[GitHub Discussions](https://github.com/colorstackorg/oyster/discussions) space! -If the maintainers see value in the idea, they will create issue from that -discussion. +- To enable any of our 3rd party integrations in development, please see the + [How to Enable Integrations](./docs/how-to-enable-integrations.md) + documentation. +- To enable sending emails, please see the + [How to Enable Emails](./docs/how-to-enable-emails.md) documentation. ## License diff --git a/README.md b/README.md index 84f43a64..392f0547 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@

Oyster: The open-source software that powers the ColorStack community experience. ✊🏿✊🏾✊🏽✊🏼

- Website | + How to Contribute | + Website | Family Application

From 4063e0bbf5412038f96a0180d14133b6f6d77650 Mon Sep 17 00:00:00 2001 From: Rami Abdou <38056800+ramiAbdou@users.noreply.github.com> Date: Fri, 9 Aug 2024 07:43:00 -0700 Subject: [PATCH 02/18] =?UTF-8?q?refactor:=20rename=20`resume-book`=20to?= =?UTF-8?q?=20`resume`=20module=20=F0=9F=93=9D=20(#441)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../_dashboard.resume-books.$id.edit.tsx | 6 +- .../routes/_dashboard.resume-books.create.tsx | 4 +- .../app/routes/_dashboard.resume-books.tsx | 2 +- apps/member-profile/app/routes/_index.tsx | 2 +- .../app/routes/_profile.resume-books.$id.tsx | 4 +- apps/member-profile/app/routes/_profile.tsx | 2 +- packages/core/package.json | 6 +- packages/core/src/member-profile.ui.ts | 2 +- .../src/modules/airtable/airtable.core.ts | 2 +- .../resume.core.ts} | 664 ++++++++++-------- .../resume.types.ts} | 0 .../resume.ui.tsx} | 2 +- 12 files changed, 373 insertions(+), 323 deletions(-) rename packages/core/src/modules/{resume-book/resume-book.core.ts => resume/resume.core.ts} (53%) rename packages/core/src/modules/{resume-book/resume-book.types.ts => resume/resume.types.ts} (100%) rename packages/core/src/modules/{resume-book/resume-book.ui.tsx => resume/resume.ui.tsx} (97%) diff --git a/apps/admin-dashboard/app/routes/_dashboard.resume-books.$id.edit.tsx b/apps/admin-dashboard/app/routes/_dashboard.resume-books.$id.edit.tsx index bfd01009..0fcd44ef 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.resume-books.$id.edit.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.resume-books.$id.edit.tsx @@ -11,17 +11,17 @@ import { } from '@remix-run/react'; import dayjs from 'dayjs'; -import { getResumeBook, updateResumeBook } from '@oyster/core/resume-books'; +import { getResumeBook, updateResumeBook } from '@oyster/core/resumes'; import { RESUME_BOOK_TIMEZONE, UpdateResumeBookInput, -} from '@oyster/core/resume-books.types'; +} from '@oyster/core/resumes.types'; import { ResumeBookEndDateField, ResumeBookHiddenField, ResumeBookNameField, ResumeBookStartDateField, -} from '@oyster/core/resume-books.ui'; +} from '@oyster/core/resumes.ui'; import { Button, getErrors, Modal, validateForm } from '@oyster/ui'; import { Route } from '@/shared/constants'; diff --git a/apps/admin-dashboard/app/routes/_dashboard.resume-books.create.tsx b/apps/admin-dashboard/app/routes/_dashboard.resume-books.create.tsx index 034f265b..68773d1e 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.resume-books.create.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.resume-books.create.tsx @@ -7,13 +7,13 @@ import { import { Form as RemixForm, useActionData, useFetcher } from '@remix-run/react'; import { useEffect } from 'react'; -import { createResumeBook } from '@oyster/core/resume-books'; +import { createResumeBook } from '@oyster/core/resumes'; import { ResumeBookEndDateField, ResumeBookHiddenField, ResumeBookNameField, ResumeBookStartDateField, -} from '@oyster/core/resume-books.ui'; +} from '@oyster/core/resumes.ui'; import { Button, ComboboxPopover, diff --git a/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx b/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx index fbb2609c..c104068a 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx @@ -8,7 +8,7 @@ import dayjs from 'dayjs'; import { useState } from 'react'; import { Clipboard, Edit, ExternalLink, Menu, Plus } from 'react-feather'; -import { listResumeBooks } from '@oyster/core/resume-books'; +import { listResumeBooks } from '@oyster/core/resumes'; import { Dashboard, Dropdown, diff --git a/apps/member-profile/app/routes/_index.tsx b/apps/member-profile/app/routes/_index.tsx index f9921178..18c79631 100644 --- a/apps/member-profile/app/routes/_index.tsx +++ b/apps/member-profile/app/routes/_index.tsx @@ -1,7 +1,7 @@ import { redirect } from '@remix-run/node'; import { generatePath } from '@remix-run/react'; -import { getResumeBook } from '@oyster/core/resume-books'; +import { getResumeBook } from '@oyster/core/resumes'; import { Route } from '@/shared/constants'; diff --git a/apps/member-profile/app/routes/_profile.resume-books.$id.tsx b/apps/member-profile/app/routes/_profile.resume-books.$id.tsx index d260d0dc..62cc0098 100644 --- a/apps/member-profile/app/routes/_profile.resume-books.$id.tsx +++ b/apps/member-profile/app/routes/_profile.resume-books.$id.tsx @@ -25,13 +25,13 @@ import { getResumeBookSubmission, listResumeBookSponsors, submitResume, -} from '@oyster/core/resume-books'; +} from '@oyster/core/resumes'; import { RESUME_BOOK_CODING_LANGUAGES, RESUME_BOOK_JOB_SEARCH_STATUSES, RESUME_BOOK_ROLES, SubmitResumeInput, -} from '@oyster/core/resume-books.types'; +} from '@oyster/core/resumes.types'; import { db } from '@oyster/db'; import { FORMATTED_RACE, Race, WorkAuthorizationStatus } from '@oyster/types'; import { diff --git a/apps/member-profile/app/routes/_profile.tsx b/apps/member-profile/app/routes/_profile.tsx index 083ccb25..4bf178a8 100644 --- a/apps/member-profile/app/routes/_profile.tsx +++ b/apps/member-profile/app/routes/_profile.tsx @@ -11,7 +11,7 @@ import { User, } from 'react-feather'; -import { getResumeBook } from '@oyster/core/resume-books'; +import { getResumeBook } from '@oyster/core/resumes'; import { Dashboard, Divider } from '@oyster/ui'; import { Route } from '@/shared/constants'; diff --git a/packages/core/package.json b/packages/core/package.json index 0e89d3cb..ee233ac3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -38,9 +38,9 @@ "./remix": "./src/modules/remix.ts", "./resources": "./src/modules/resource/index.ts", "./resources.server": "./src/modules/resource/index.server.ts", - "./resume-books": "./src/modules/resume-book/resume-book.core.ts", - "./resume-books.types": "./src/modules/resume-book/resume-book.types.ts", - "./resume-books.ui": "./src/modules/resume-book/resume-book.ui.tsx", + "./resumes": "./src/modules/resume/resume.core.ts", + "./resumes.types": "./src/modules/resume/resume.types.ts", + "./resumes.ui": "./src/modules/resume/resume.ui.tsx", "./scholarships": "./src/modules/scholarship/scholarship.core.ts", "./scholarships.ui": "./src/modules/scholarship/scholarship.ui.tsx", "./slack.server": "./src/modules/slack/index.server.ts" diff --git a/packages/core/src/member-profile.ui.ts b/packages/core/src/member-profile.ui.ts index 3d1b96a3..af9d16b7 100644 --- a/packages/core/src/member-profile.ui.ts +++ b/packages/core/src/member-profile.ui.ts @@ -38,6 +38,6 @@ export { ChangePrimaryEmailInput, ListMembersInDirectoryWhere, } from './modules/member/member.types'; -export { CreateResumeBookInput } from './modules/resume-book/resume-book.types'; +export { CreateResumeBookInput } from './modules/resume/resume.types'; export { ClaimSwagPackInput } from './modules/swag-pack/swag-pack.types'; export { Environment, ListSearchParams } from './shared/types'; diff --git a/packages/core/src/modules/airtable/airtable.core.ts b/packages/core/src/modules/airtable/airtable.core.ts index 4a0ca86b..7895746a 100644 --- a/packages/core/src/modules/airtable/airtable.core.ts +++ b/packages/core/src/modules/airtable/airtable.core.ts @@ -295,7 +295,7 @@ type AirtableFieldOptions = { }[]; }; -type AirtableField = { name: string } & ( +export type AirtableField = { name: string } & ( | { type: 'email' } | { type: 'multipleAttachments' } | { type: 'multipleSelects'; options: AirtableFieldOptions } diff --git a/packages/core/src/modules/resume-book/resume-book.core.ts b/packages/core/src/modules/resume/resume.core.ts similarity index 53% rename from packages/core/src/modules/resume-book/resume-book.core.ts rename to packages/core/src/modules/resume/resume.core.ts index 94118c31..317ac6e8 100644 --- a/packages/core/src/modules/resume-book/resume-book.core.ts +++ b/packages/core/src/modules/resume/resume.core.ts @@ -4,10 +4,11 @@ import { match } from 'ts-pattern'; import { type DB, db, point } from '@oyster/db'; import { FORMATTED_RACE, Race } from '@oyster/types'; -import { id, iife } from '@oyster/utils'; +import { id, run } from '@oyster/utils'; import { job } from '@/infrastructure/bull/use-cases/job'; import { + type AirtableField, createAirtableRecord, createAirtableTable, updateAirtableRecord, @@ -25,8 +26,9 @@ import { RESUME_BOOK_ROLES, type SubmitResumeInput, type UpdateResumeBookInput, -} from '@/modules/resume-book/resume-book.types'; +} from '@/modules/resume/resume.types'; import { ColorStackError } from '@/shared/errors'; +import { success } from '@/shared/utils/core.utils'; // Environment Variables @@ -125,7 +127,7 @@ export async function listResumeBookSponsors({ return sponsors; } -// Mutations +// Use Cases /** * Creates a new resume book as well as the sponsors (companies) of the @@ -140,221 +142,10 @@ export async function createResumeBook({ startDate, }: CreateResumeBookInput) { const [airtableTableId, googleDriveFolderId] = await Promise.all([ - iife(async () => { - const companies = await db - .selectFrom('companies') - .select(['name']) - .where('id', 'in', sponsors) - .orderBy('name', 'asc') - .execute(); - - const sponsorOptions = companies.map((company) => { - return { name: company.name }; - }); - - const locationOptions = [ - { name: 'International' }, - { name: 'Canada' }, - { name: 'N/A' }, - { name: 'AL' }, - { name: 'AK' }, - { name: 'AR' }, - { name: 'AZ' }, - { name: 'CA' }, - { name: 'CO' }, - { name: 'CT' }, - { name: 'DC' }, - { name: 'DE' }, - { name: 'FL' }, - { name: 'GA' }, - { name: 'HI' }, - { name: 'IA' }, - { name: 'ID' }, - { name: 'IL' }, - { name: 'IN' }, - { name: 'KS' }, - { name: 'KY' }, - { name: 'LA' }, - { name: 'MA' }, - { name: 'MD' }, - { name: 'ME' }, - { name: 'MI' }, - { name: 'MN' }, - { name: 'MO' }, - { name: 'MS' }, - { name: 'MT' }, - { name: 'NC' }, - { name: 'ND' }, - { name: 'NE' }, - { name: 'NH' }, - { name: 'NJ' }, - { name: 'NM' }, - { name: 'NV' }, - { name: 'NY' }, - { name: 'OH' }, - { name: 'OK' }, - { name: 'OR' }, - { name: 'PA' }, - { name: 'PR' }, - { name: 'RI' }, - { name: 'SC' }, - { name: 'SD' }, - { name: 'TN' }, - { name: 'TX' }, - { name: 'UT' }, - { name: 'VA' }, - { name: 'VT' }, - { name: 'WA' }, - { name: 'WI' }, - { name: 'WV' }, - { name: 'WY' }, - ]; - - return createAirtableTable({ - baseId: AIRTABLE_RESUME_BOOKS_BASE_ID, - name, - fields: [ - { - name: 'Email', - type: 'email', - }, - { - name: 'First Name', - type: 'singleLineText', - }, - { - name: 'Last Name', - type: 'singleLineText', - }, - { - name: 'Race', - options: { - choices: [ - Race.BLACK, - Race.HISPANIC, - Race.NATIVE_AMERICAN, - Race.MIDDLE_EASTERN, - Race.ASIAN, - Race.WHITE, - Race.OTHER, - ].map((race) => { - return { name: FORMATTED_RACE[race] }; - }), - }, - type: 'multipleSelects', - }, - { - name: 'Education Level', - options: { - choices: [ - { name: 'Undergraduate' }, - { name: 'Masters' }, - { name: 'PhD' }, - { name: 'Early Career Professional' }, - ], - }, - type: 'singleSelect', - }, - { - name: 'Graduation Season', - options: { - choices: [{ name: 'Spring' }, { name: 'Fall' }], - }, - type: 'singleSelect', - }, - { - name: 'Graduation Year', - options: { - choices: [ - { name: '2020' }, - { name: '2021' }, - { name: '2022' }, - { name: '2023' }, - { name: '2024' }, - { name: '2025' }, - { name: '2026' }, - { name: '2027' }, - { name: '2028' }, - { name: '2029' }, - { name: '2030' }, - ], - }, - type: 'singleSelect', - }, - { - name: 'Location (University)', - options: { choices: locationOptions }, - type: 'singleSelect', - }, - { - name: 'Hometown', - options: { choices: locationOptions }, - type: 'singleSelect', - }, - { - name: 'Role Interest', - options: { - choices: RESUME_BOOK_ROLES.map((role) => { - return { name: role }; - }), - }, - type: 'multipleSelects', - }, - { - name: 'Proficient Language(s)', - options: { - choices: RESUME_BOOK_CODING_LANGUAGES.map((language) => { - return { name: language }; - }), - }, - type: 'multipleSelects', - }, - { - name: 'Employment Search Status', - options: { - choices: RESUME_BOOK_JOB_SEARCH_STATUSES.map((status) => { - return { name: status }; - }), - }, - type: 'singleSelect', - }, - { - name: 'Sponsor Interest #1', - options: { choices: sponsorOptions }, - type: 'singleSelect', - }, - { - name: 'Sponsor Interest #2', - options: { choices: sponsorOptions }, - type: 'singleSelect', - }, - { - name: 'Sponsor Interest #3', - options: { choices: sponsorOptions }, - type: 'singleSelect', - }, - { - name: 'Resume', - type: 'multipleAttachments', - }, - { - name: 'LinkedIn', - type: 'url', - }, - { - name: 'Are you authorized to work in the US or Canada?', - options: { - choices: [ - { name: 'Yes' }, - { name: 'Yes, with visa sponsorship' }, - { name: 'No' }, - { name: "I'm not sure" }, - ], - }, - type: 'singleSelect', - }, - ], - }); + createAirtableTable({ + baseId: AIRTABLE_RESUME_BOOKS_BASE_ID, + fields: await getResumeBookAirtableFields({ sponsors }), + name, }), createGoogleDriveFolder({ @@ -392,8 +183,243 @@ export async function createResumeBook({ ) .execute(); }); + + return success({}); } +/** + * Returns all of the fields that are required for the resume book's Airtable + * table. This includes options for single select, multiple select, and + * multiple attachments fields. + * + * The only thing that changes for each resume book is the list of sponsors + * (companies) that are associated with the resume book, so that is the only + * input required. + */ +async function getResumeBookAirtableFields({ + sponsors, +}: Pick): Promise { + const companies = await db + .selectFrom('companies') + .select(['name']) + .where('id', 'in', sponsors) + .orderBy('name', 'asc') + .execute(); + + const sponsorOptions = companies.map((company) => { + return { name: company.name }; + }); + + const locationOptions = [ + { name: 'International' }, + { name: 'Canada' }, + { name: 'N/A' }, + { name: 'AL' }, + { name: 'AK' }, + { name: 'AR' }, + { name: 'AZ' }, + { name: 'CA' }, + { name: 'CO' }, + { name: 'CT' }, + { name: 'DC' }, + { name: 'DE' }, + { name: 'FL' }, + { name: 'GA' }, + { name: 'HI' }, + { name: 'IA' }, + { name: 'ID' }, + { name: 'IL' }, + { name: 'IN' }, + { name: 'KS' }, + { name: 'KY' }, + { name: 'LA' }, + { name: 'MA' }, + { name: 'MD' }, + { name: 'ME' }, + { name: 'MI' }, + { name: 'MN' }, + { name: 'MO' }, + { name: 'MS' }, + { name: 'MT' }, + { name: 'NC' }, + { name: 'ND' }, + { name: 'NE' }, + { name: 'NH' }, + { name: 'NJ' }, + { name: 'NM' }, + { name: 'NV' }, + { name: 'NY' }, + { name: 'OH' }, + { name: 'OK' }, + { name: 'OR' }, + { name: 'PA' }, + { name: 'PR' }, + { name: 'RI' }, + { name: 'SC' }, + { name: 'SD' }, + { name: 'TN' }, + { name: 'TX' }, + { name: 'UT' }, + { name: 'VA' }, + { name: 'VT' }, + { name: 'WA' }, + { name: 'WI' }, + { name: 'WV' }, + { name: 'WY' }, + ]; + + return [ + { + name: 'Email', + type: 'email', + }, + { + name: 'First Name', + type: 'singleLineText', + }, + { + name: 'Last Name', + type: 'singleLineText', + }, + { + name: 'Race', + options: { + choices: [ + Race.BLACK, + Race.HISPANIC, + Race.NATIVE_AMERICAN, + Race.MIDDLE_EASTERN, + Race.ASIAN, + Race.WHITE, + Race.OTHER, + ].map((race) => { + return { name: FORMATTED_RACE[race] }; + }), + }, + type: 'multipleSelects', + }, + { + name: 'Education Level', + options: { + choices: [ + { name: 'Undergraduate' }, + { name: 'Masters' }, + { name: 'PhD' }, + { name: 'Early Career Professional' }, + ], + }, + type: 'singleSelect', + }, + { + name: 'Graduation Season', + options: { + choices: [{ name: 'Spring' }, { name: 'Fall' }], + }, + type: 'singleSelect', + }, + { + name: 'Graduation Year', + options: { + choices: [ + { name: '2020' }, + { name: '2021' }, + { name: '2022' }, + { name: '2023' }, + { name: '2024' }, + { name: '2025' }, + { name: '2026' }, + { name: '2027' }, + { name: '2028' }, + { name: '2029' }, + { name: '2030' }, + ], + }, + type: 'singleSelect', + }, + { + name: 'Location (University)', + options: { choices: locationOptions }, + type: 'singleSelect', + }, + { + name: 'Hometown', + options: { choices: locationOptions }, + type: 'singleSelect', + }, + { + name: 'Role Interest', + options: { + choices: RESUME_BOOK_ROLES.map((role) => { + return { name: role }; + }), + }, + type: 'multipleSelects', + }, + { + name: 'Proficient Language(s)', + options: { + choices: RESUME_BOOK_CODING_LANGUAGES.map((language) => { + return { name: language }; + }), + }, + type: 'multipleSelects', + }, + { + name: 'Employment Search Status', + options: { + choices: RESUME_BOOK_JOB_SEARCH_STATUSES.map((status) => { + return { name: status }; + }), + }, + type: 'singleSelect', + }, + { + name: 'Sponsor Interest #1', + options: { choices: sponsorOptions }, + type: 'singleSelect', + }, + { + name: 'Sponsor Interest #2', + options: { choices: sponsorOptions }, + type: 'singleSelect', + }, + { + name: 'Sponsor Interest #3', + options: { choices: sponsorOptions }, + type: 'singleSelect', + }, + { + name: 'Resume', + type: 'multipleAttachments', + }, + { + name: 'LinkedIn', + type: 'url', + }, + { + name: 'Are you authorized to work in the US or Canada?', + options: { + choices: [ + { name: 'Yes' }, + { name: 'Yes, with visa sponsorship' }, + { name: 'No' }, + { name: "I'm not sure" }, + ], + }, + type: 'singleSelect', + }, + ]; +} + +/** + * Updates the resume book information. + * + * This will mainly be used to update the start/end date of the resume book, + * as well as the name and whether the resume book is hidden or not. + * + * @todo Implement the ability to update the sponsors of the resume book. + * @todo Implement the "edit table" functionality to Airtable. + */ export async function updateResumeBook({ endDate, hidden, @@ -413,8 +439,29 @@ export async function updateResumeBook({ .where('id', '=', id) .execute(); }); + + return success({}); } +/** + * Submits a resume to the resume book. Note that this same function is used + * for both the initial submission as well as any subsequent edits to the + * submission. + * + * This function is quite complex because it involves multiple steps and + * external services. The following is a high-level overview of the steps: + * - Upload the resume to object storage. + * - Upload the resume to Google Drive. + * - Create or update the Airtable record. In order to send the resume file to + * Airtable, we first need to generate a presigned URL that allows Airtable to + * access the resume file in object storage. Then, Airtable will create its + * own copy of the file. + * - Update the member's information in the database (ie: name, LinkedIn). + * - Upsert the resume book submission record. + * - Send an email notification to the student. + * - If this is the student's first submission, we'll emit a job to grant them + * points. + */ export async function submitResume({ codingLanguages, educationId, @@ -499,13 +546,15 @@ export async function submitResume({ ]); const isFirstSubmission = !submission; + + // If the resume is present, it can either be a File object or a string. const isResumeFile = !!resume && typeof resume !== 'string'; // Upload the resume to object storage and get a presigned URL which allows // the resume to be accessed by Airtable, who will copy the file to their // own storage. const resumeLink = isResumeFile - ? await iife(async function uploadResume() { + ? await run(async () => { const attachmentKey = `resume-books/${resumeBookId}/${memberId}`; const arrayBuffer = await resume.arrayBuffer(); @@ -531,100 +580,99 @@ export async function submitResume({ // We need to do a little massaging/formatting of the data before we sent it // over to Airtable. - const airtableData = iife(function formatAirtableRecord() { - return { - 'Education Level': iife(() => { - const graduated = dayjs().isAfter(education.endDate); - - if (graduated) { - return 'Early Career Professional'; - } - - return match(education.degreeType as DegreeType) - .with('associate', 'bachelors', 'certificate', () => 'Undergraduate') - .with('doctoral', 'professional', () => 'PhD') - .with('masters', () => 'Masters') - .exhaustive(); - }), + const airtableData = { + 'Education Level': run(() => { + const graduated = dayjs().isAfter(education.endDate); + + if (graduated) { + return 'Early Career Professional'; + } + + return match(education.degreeType as DegreeType) + .with('associate', 'bachelors', 'certificate', () => 'Undergraduate') + .with('doctoral', 'professional', () => 'PhD') + .with('masters', () => 'Masters') + .exhaustive(); + }), - Email: member.email, - 'Employment Search Status': employmentSearchStatus, - 'First Name': firstName, - 'Graduation Season': iife(() => { - return education.endDate.getMonth() <= 6 ? 'Spring' : 'Fall'; - }), + Email: member.email, + 'Employment Search Status': employmentSearchStatus, + 'First Name': firstName, - // We need to convert to a string because Airtable expects strings for - // their "Single Select" fields, which we're using instead of a "Number" - // field. - 'Graduation Year': graduationYear.toString(), - - Hometown: iife(() => { - // The hometown is a formatted string that includes a minimum of city - // and country, and potentially state, neighborhood, etc. - // Example (1): "Ethiopia" - // Example (2): "Cairo, Egypt" - // Example (3): "New York City, NY, USA" - // Example (4): "Bedford-Stuyvesant, Brooklyn, NY, USA" - // Example (5): "Harlem, Manhattan, New York, NY, USA" - const parts = hometown.split(', '); - - // The country is always the last "part". - const country = parts[parts.length - 1]; - - return match(parts.length) - .with(1, 2, () => { - return country === 'Puerto Rico' ? 'PR' : 'International'; - }) - .with(3, 4, 5, () => { - if (country === 'USA') { - return parts[parts.length - 2]; // This is the state. - } - - if (country === 'Canada') { - return 'Canada'; - } - - return 'International'; - }) - .otherwise(() => { - return country; - }); - }), + 'Graduation Season': run(() => { + return education.endDate.getMonth() <= 6 ? 'Spring' : 'Fall'; + }), - 'Last Name': lastName, - LinkedIn: linkedInUrl, - 'Location (University)': education.addressState || 'N/A', - 'Proficient Language(s)': codingLanguages, + // We need to convert to a string because Airtable expects strings for + // their "Single Select" fields, which we're using instead of a "Number" + // field. + 'Graduation Year': graduationYear.toString(), + + Hometown: run(() => { + // The hometown is a formatted string that includes a minimum of city + // and country, and potentially state, neighborhood, etc. + // Example (1): "Ethiopia" + // Example (2): "Cairo, Egypt" + // Example (3): "New York City, NY, USA" + // Example (4): "Bedford-Stuyvesant, Brooklyn, NY, USA" + // Example (5): "Harlem, Manhattan, New York, NY, USA" + const parts = hometown.split(', '); + + // The country is always the last "part". + const country = parts[parts.length - 1]; + + return match(parts.length) + .with(1, 2, () => { + return country === 'Puerto Rico' ? 'PR' : 'International'; + }) + .with(3, 4, 5, () => { + if (country === 'USA') { + return parts[parts.length - 2]; // This is the state. + } - Race: race.map((value) => { - return FORMATTED_RACE[value]; - }), + if (country === 'Canada') { + return 'Canada'; + } - ...(!!resumeLink && { - // See the following Airtable API documentation to understand the format - // for upload attachments/files: - // https://airtable.com/developers/web/api/field-model#multipleattachment - Resume: iife(() => { - return [{ filename: fileName, url: resumeLink }]; - }), - }), + return 'International'; + }) + .otherwise(() => { + return country; + }); + }), - 'Role Interest': preferredRoles, - 'Sponsor Interest #1': company1.name, - 'Sponsor Interest #2': company2.name, - 'Sponsor Interest #3': company3.name, + 'Last Name': lastName, + LinkedIn: linkedInUrl, + 'Location (University)': education.addressState || 'N/A', + 'Proficient Language(s)': codingLanguages, - 'Are you authorized to work in the US or Canada?': match( - workAuthorizationStatus - ) - .with('authorized', () => 'Yes') - .with('needs_sponsorship', () => 'Yes, with visa sponsorship') - .with('unauthorized', () => 'No') - .with('unsure', () => "I'm not sure") - .exhaustive(), - }; - }); + Race: race.map((value) => { + return FORMATTED_RACE[value]; + }), + + ...(!!resumeLink && { + // See the following Airtable API documentation to understand the format + // for upload attachments/files: + // https://airtable.com/developers/web/api/field-model#multipleattachment + Resume: run(() => { + return [{ filename: fileName, url: resumeLink }]; + }), + }), + + 'Role Interest': preferredRoles, + 'Sponsor Interest #1': company1.name, + 'Sponsor Interest #2': company2.name, + 'Sponsor Interest #3': company3.name, + + 'Are you authorized to work in the US or Canada?': match( + workAuthorizationStatus + ) + .with('authorized', () => 'Yes') + .with('needs_sponsorship', () => 'Yes, with visa sponsorship') + .with('unauthorized', () => 'No') + .with('unsure', () => "I'm not sure") + .exhaustive(), + }; const airtableRecordId = isFirstSubmission ? await createAirtableRecord({ @@ -639,7 +687,7 @@ export async function submitResume({ data: airtableData, }); - const googleDriveFileId = await iife(async () => { + const googleDriveFileId = await run(async () => { if (!isResumeFile) { return ''; } @@ -737,4 +785,6 @@ export async function submitResume({ type: 'submit_resume', }); } + + return success({}); } diff --git a/packages/core/src/modules/resume-book/resume-book.types.ts b/packages/core/src/modules/resume/resume.types.ts similarity index 100% rename from packages/core/src/modules/resume-book/resume-book.types.ts rename to packages/core/src/modules/resume/resume.types.ts diff --git a/packages/core/src/modules/resume-book/resume-book.ui.tsx b/packages/core/src/modules/resume/resume.ui.tsx similarity index 97% rename from packages/core/src/modules/resume-book/resume-book.ui.tsx rename to packages/core/src/modules/resume/resume.ui.tsx index 2b5f7c7b..ccce2005 100644 --- a/packages/core/src/modules/resume-book/resume-book.ui.tsx +++ b/packages/core/src/modules/resume/resume.ui.tsx @@ -1,6 +1,6 @@ import { DatePicker, type FieldProps, Form, Input, Radio } from '@oyster/ui'; -import { ResumeBook } from '@/modules/resume-book/resume-book.types'; +import { ResumeBook } from '@/modules/resume/resume.types'; const keys = ResumeBook.keyof().enum; From 94665cb3e0ade317a34615dcf5f1dc0af834c4ee Mon Sep 17 00:00:00 2001 From: Rami Abdou <38056800+ramiAbdou@users.noreply.github.com> Date: Wed, 14 Aug 2024 07:15:39 -0700 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20ai=20resume=20review=20?= =?UTF-8?q?=F0=9F=92=AF=20(#445)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 53 ++-- .../app/routes/_profile.resume-books.$id.tsx | 14 +- .../app/routes/_profile.resume.review.tsx | 293 ++++++++++++++++++ apps/member-profile/app/routes/_profile.tsx | 10 +- .../app/shared/constants.server.ts | 2 + apps/member-profile/app/shared/constants.ts | 1 + packages/core/package.json | 4 +- packages/core/src/infrastructure/redis.ts | 84 +++-- packages/core/src/member-profile.server.ts | 1 + .../queries/get-active-streak-leaderboard.ts | 103 +++--- .../shared/active-status.shared.ts | 2 + packages/core/src/modules/ai/ai.core.ts | 115 +++++++ .../search-crunchbase-organizations.ts | 98 +++--- .../{mixpanel/index.ts => mixpanel.ts} | 1 + .../core/src/modules/resume/resume.core.ts | 152 +++++++++ .../core/src/modules/resume/resume.types.ts | 6 + packages/core/src/shared/utils/file.utils.ts | 38 +++ packages/ui/package.json | 2 + packages/ui/src/components/button.tsx | 14 +- packages/ui/src/components/dashboard.tsx | 6 +- packages/ui/src/components/progress.tsx | 72 +++++ yarn.lock | 153 ++++++++- 22 files changed, 1038 insertions(+), 186 deletions(-) create mode 100644 apps/member-profile/app/routes/_profile.resume.review.tsx create mode 100644 packages/core/src/modules/ai/ai.core.ts rename packages/core/src/modules/{mixpanel/index.ts => mixpanel.ts} (99%) create mode 100644 packages/core/src/shared/utils/file.utils.ts create mode 100644 packages/ui/src/components/progress.tsx diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34b65b1f..c588d5a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,41 +77,50 @@ Follow these steps in order to get started with contributing to Oyster! git remote add upstream https://github.com/colorstackorg/oyster.git ``` -8. Install all project dependencies: +8. If you're not on macOS M1/M2/M3, please skip this step! If you are, you'll + need to install some native dependencies on your machine in order to support + [`node-canvas`](https://www.npmjs.com/package/canvas), which is the library + we need in order to convert PDFs to PNGs in our _AI Resume Review_ feature: ```sh - yarn + brew install pkg-config cairo pango ``` -9. Set up your environment variables: +9. Install all project dependencies: + ```sh + yarn ``` - yarn env:setup - ``` - You'll now have `.env` files in all of your apps (and a couple packages)! +10. Set up your environment variables: + + ``` + yarn env:setup + ``` + + You'll now have `.env` files in all of your apps (and a couple packages)! - You'll also notice that a lot of environment variables are empty. Most of - these empty variables are tied to the 3rd party integrations we have with - services such as Google for authentication. You shouldn't need to enable - these integrations unless you're working on a feature that touches that - service, but in case you need to enable an integration, please see the - [How to Enable Integrations](./docs/how-to-enable-integrations.md) - documentation. + You'll also notice that a lot of environment variables are empty. Most of + these empty variables are tied to the 3rd party integrations we have with + services such as Google for authentication. You shouldn't need to enable + these integrations unless you're working on a feature that touches that + service, but in case you need to enable an integration, please see the + [How to Enable Integrations](./docs/how-to-enable-integrations.md) + documentation. -10. Start your Postgres database and Redis store: +11. Start your Postgres database and Redis store: ``` yarn dx:up ``` -11. Run all the database migrations: +12. Run all the database migrations: ```sh yarn db:migrate ``` -12. Seed your database with some "dummy" data: +13. Seed your database with some "dummy" data: ```sh yarn db:seed @@ -122,24 +131,24 @@ Follow these steps in order to get started with contributing to Oyster! This will enable you to log into both the Admin Dashboard and Member Profile very soon! -13. Build the project: +14. Build the project: ```sh yarn build ``` -14. Start all of the applications in development: +15. Start all of the applications in development: ```sh yarn dev:apps ``` -15. Open up the applications in the browser. +16. Open up the applications in the browser. 1. The Member Profile is running at http://localhost:3000. 2. The Admin Dashboard is running at http://localhost:3001. -16. Log into both applications. In the development environment, you can bypass +17. Log into both applications. In the development environment, you can bypass the "real" authentication by doing the following: 1. Click "Log In with OTP". @@ -148,7 +157,7 @@ Follow these steps in order to get started with contributing to Oyster! You should be logged in! -17. Set up [Prisma Studio](https://www.prisma.io/studio), a tool to make it +18. Set up [Prisma Studio](https://www.prisma.io/studio), a tool to make it easier to interact with and manage your data in the browser: ```sh @@ -158,7 +167,7 @@ Follow these steps in order to get started with contributing to Oyster! You can now open up Prisma Studio in your browser at http://localhost:5555. -18. [Optional] Once you are done developing, you may want to stop the database +19. [Optional] Once you are done developing, you may want to stop the database containers since they can eat up battery life. ```sh diff --git a/apps/member-profile/app/routes/_profile.resume-books.$id.tsx b/apps/member-profile/app/routes/_profile.resume-books.$id.tsx index 62cc0098..f24cc344 100644 --- a/apps/member-profile/app/routes/_profile.resume-books.$id.tsx +++ b/apps/member-profile/app/routes/_profile.resume-books.$id.tsx @@ -629,7 +629,19 @@ function ResumeBookForm() { + Before you submit your resume, you can get feedback from our{' '} + + Resume Review + {' '} + tool in the Member Profile! + + } error={errors.resume} label="Resume" labelFor={keys.resume} diff --git a/apps/member-profile/app/routes/_profile.resume.review.tsx b/apps/member-profile/app/routes/_profile.resume.review.tsx new file mode 100644 index 00000000..b9ee4e01 --- /dev/null +++ b/apps/member-profile/app/routes/_profile.resume.review.tsx @@ -0,0 +1,293 @@ +import { + type ActionFunctionArgs, + unstable_composeUploadHandlers as composeUploadHandlers, + unstable_createFileUploadHandler as createFileUploadHandler, + unstable_createMemoryUploadHandler as createMemoryUploadHandler, + json, + type LoaderFunctionArgs, + type MetaFunction, + unstable_parseMultipartFormData as parseMultipartFormData, +} from '@remix-run/node'; +import { + Form as RemixForm, + useActionData, + useLoaderData, + useNavigation, +} from '@remix-run/react'; +import { type PropsWithChildren } from 'react'; +import { FileText } from 'react-feather'; +import { match } from 'ts-pattern'; + +import { cache, ONE_WEEK_IN_SECONDS } from '@oyster/core/member-profile.server'; +import { buildMeta } from '@oyster/core/remix'; +import { type ResumeFeedback, reviewResume } from '@oyster/core/resumes'; +import { Button, cx, FileUploader, Form, MB_IN_BYTES, Text } from '@oyster/ui'; +import { Progress, useProgress } from '@oyster/ui/progress'; + +import { + EmptyState, + EmptyStateContainer, + EmptyStateDescription, +} from '@/shared/components/empty-state'; +import { ensureUserAuthenticated, user } from '@/shared/session.server'; + +export const meta: MetaFunction = () => { + return buildMeta({ + description: 'Get the first round of feedback on your resume!', + title: 'Resume Review', + }); +}; + +// Cache key for the feedback data. +const keyPrefix = 'resume_feedback:'; + +export async function loader({ request }: LoaderFunctionArgs) { + const session = await ensureUserAuthenticated(request); + + const feedback = await cache.get(keyPrefix + user(session)); + + return json({ + experiences: feedback?.experiences, + projects: feedback?.projects, + }); +} + +const RESUME_MAX_FILE_SIZE = MB_IN_BYTES * 1; + +export async function action({ request }: ActionFunctionArgs) { + const session = await ensureUserAuthenticated(request); + + const memberId = user(session); + + const uploadHandler = composeUploadHandlers( + createFileUploadHandler({ maxPartSize: RESUME_MAX_FILE_SIZE }), + createMemoryUploadHandler() + ); + + const form = await parseMultipartFormData(request, uploadHandler); + + const feedback = await reviewResume({ + memberId, + resume: form.get('resume') as File, + }); + + // We'll cache the feedback for a week so that we don't have to re-run the + // review process every time the user refreshes the page. + await cache.set( + keyPrefix + memberId, + feedback, + ONE_WEEK_IN_SECONDS + ); + + return json(feedback); +} + +export default function ReviewResume() { + return ( +
+ + +
+ ); +} + +// Upload + +function UploadSection() { + const navigation = useNavigation(); + + return ( +
+ Resume Review + + + Currently, the resume review tool will only give feedback on your bullet + points for experiences and projects. This does not serve as a complete + resume review, so you should still seek feedback from peers. + Additionally, this tool relies on AI and may not always provide the best + feedback, so take it with a grain of salt. + + + {navigation.state === 'submitting' && !!navigation.formMethod ? ( +
+ +
+ ) : ( + + )} +
+ ); +} + +function UploadForm() { + return ( + + + + + + + Get Feedback + + + ); +} + +function UploadProgress() { + const progress = useProgress(); + + return ( +
+ {Math.floor(progress)}% + + + + + This could take a minute or two -- our reviewer is hard at work! 😜 + +
+ ); +} + +// Feedback + +function FeedbackSection() { + const loaderData = useLoaderData(); + const actionData = useActionData(); + + const experiences = actionData?.experiences || loaderData.experiences || []; + const projects = actionData?.projects || loaderData.projects || []; + + return ( +
+ Feedback + + {!!experiences.length || !!projects.length ? ( + <> +
+ Experiences ({experiences.length}) + + + {experiences.map((experience) => { + const title = `${experience.role}, ${experience.company}`; + + return ( + + ); + })} + +
+ +
+ Projects ({projects.length}) + + + {projects.map((project) => { + return ( + + ); + })} + +
+ + ) : ( + + } /> + + After you upload your resume, we'll provide feedback on your resume, + specifically on the bullet points for your experiences and projects. + + + )} +
+ ); +} + +function ExperienceList({ children }: PropsWithChildren) { + return
    {children}
; +} + +type ExperienceProps = { + bullets: ResumeFeedback['experiences'][number]['bullets']; + title: string; +}; + +function Experience({ bullets, title }: ExperienceProps) { + return ( +
  • +
    + {title} +
    + +
      + {bullets.map((bullet, i) => { + return ; + })} +
    +
  • + ); +} + +type BulletPointProps = + ResumeFeedback['experiences'][number]['bullets'][number]; + +function BulletPoint({ content, feedback, rewrites, score }: BulletPointProps) { + return ( +
  • +
    + + {content} + + + 'bg-red-100 text-red-700') + .with(6, () => 'bg-yellow-100 text-yellow-700') + .with(7, 8, () => 'bg-cyan-100 text-cyan-700') + .with(9, 10, () => 'bg-lime-100 text-lime-700') + .otherwise(() => '') + )} + > + {score} + +
    + + {feedback} + +
      + {rewrites.map((rewrite) => { + return ( +
    • + Suggestion: {rewrite} +
    • + ); + })} +
    +
  • + ); +} diff --git a/apps/member-profile/app/routes/_profile.tsx b/apps/member-profile/app/routes/_profile.tsx index 4bf178a8..67697357 100644 --- a/apps/member-profile/app/routes/_profile.tsx +++ b/apps/member-profile/app/routes/_profile.tsx @@ -6,6 +6,7 @@ import { BookOpen, Briefcase, Calendar, + FileText, Folder, Home, User, @@ -58,7 +59,7 @@ export default function ProfileLayout() { prefetch="intent" /> - + )} + } + isNew + label="Resume Review" + pathname={Route['/resume/review']} + prefetch="intent" + /> } label="Profile" diff --git a/apps/member-profile/app/shared/constants.server.ts b/apps/member-profile/app/shared/constants.server.ts index b163f907..13d3cf3d 100644 --- a/apps/member-profile/app/shared/constants.server.ts +++ b/apps/member-profile/app/shared/constants.server.ts @@ -8,6 +8,7 @@ const BaseEnvironmentConfig = z.object({ AIRTABLE_API_KEY: EnvironmentVariable, AIRTABLE_FAMILY_BASE_ID: EnvironmentVariable, AIRTABLE_MEMBERS_TABLE_ID: EnvironmentVariable, + ANTHROPIC_API_KEY: EnvironmentVariable, API_URL: EnvironmentVariable, CRUNCHBASE_BASIC_API_KEY: EnvironmentVariable, DATABASE_URL: EnvironmentVariable, @@ -39,6 +40,7 @@ const EnvironmentConfig = z.discriminatedUnion('ENVIRONMENT', [ AIRTABLE_API_KEY: true, AIRTABLE_FAMILY_BASE_ID: true, AIRTABLE_MEMBERS_TABLE_ID: true, + ANTHROPIC_API_KEY: true, CRUNCHBASE_BASIC_API_KEY: true, GITHUB_OAUTH_CLIENT_ID: true, GITHUB_OAUTH_CLIENT_SECRET: true, diff --git a/apps/member-profile/app/shared/constants.ts b/apps/member-profile/app/shared/constants.ts index f85ef35e..491e0561 100644 --- a/apps/member-profile/app/shared/constants.ts +++ b/apps/member-profile/app/shared/constants.ts @@ -55,6 +55,7 @@ const ROUTES = [ '/resources', '/resources/add', '/resources/:id/edit', + '/resume/review', '/resume-books/:id', ] as const; diff --git a/packages/core/package.json b/packages/core/package.json index ee233ac3..6f973c2d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -31,7 +31,7 @@ "./location": "./src/modules/location/location.core.ts", "./location.types": "./src/modules/location/location.types.ts", "./location.ui": "./src/modules/location/location.ui.tsx", - "./mixpanel": "./src/modules/mixpanel/index.ts", + "./mixpanel": "./src/modules/mixpanel.ts", "./object-storage": "./src/modules/object-storage/index.ts", "./referrals": "./src/modules/referral/referral.core.ts", "./referrals.ui": "./src/modules/referral/referral.ui.tsx", @@ -63,6 +63,7 @@ "@react-email/render": "^0.0.6", "@slack/web-api": "^6.7.2", "bullmq": "^4.12.2", + "canvas": "^2.11.2", "dayjs": "^1.11.5", "dedent": "^0.7.0", "googleapis": "^126.0.1", @@ -72,6 +73,7 @@ "mixpanel": "^0.18.0", "nanoid": "^3.0.0", "nodemailer": "^6.9.13", + "pdfjs-dist": "^4.5.136", "postmark": "^3.0.15", "ua-parser-js": "^1.0.36", "zod": "^3.20.2" diff --git a/packages/core/src/infrastructure/redis.ts b/packages/core/src/infrastructure/redis.ts index b11225fe..18e6d2cd 100644 --- a/packages/core/src/infrastructure/redis.ts +++ b/packages/core/src/infrastructure/redis.ts @@ -1,5 +1,4 @@ import { Redis } from 'ioredis'; -import { type z } from 'zod'; import { type ExtractValue } from '@oyster/types'; @@ -26,53 +25,48 @@ export const RedisKey = { export type RedisKey = ExtractValue; -// Utils +// Constants -/** - * Returns a cache object with `get` and `set` methods. - * - * The `get` method will return the cached data if it exists and is valid. - * Otherwise, it will return `null` and delete the key. - * - * The `set` method will store the data in Redis. - * - * @param key - Key to store the data in Redis. - * @param schema - Zod schema to validate any cached data. - * - * @deprecated Use `withCache` instead. - */ -export function cache(key: string, schema: z.ZodType) { - async function get() { - const stringifiedData = await redis.get(key); +export const ONE_MINUTE_IN_SECONDS = 60; +export const ONE_HOUR_IN_SECONDS = ONE_MINUTE_IN_SECONDS * 60; +export const ONE_DAY_IN_SECONDS = ONE_HOUR_IN_SECONDS * 24; +export const ONE_WEEK_IN_SECONDS = ONE_DAY_IN_SECONDS * 7; - if (!stringifiedData) { - return null; - } - - const data = stringifiedData ? JSON.parse(stringifiedData) : null; - - const result = schema.safeParse(data); +// Utils - if (result.success) { - return result.data; +export const cache = { + /** + * Gets the value stored in Redis and parses it as JSON. If the key does not + * exist, it will return null. + * + * @param key - Key to retrieve the value from. + */ + async get(key: string) { + const value = await redis.get(key); + + if (!value) { + return null; } - await redis.del(key); + return JSON.parse(value) as T; + }, - return null; - } + /** + * Stringifies the value and stores it in Redis. If an expiration time is + * provided, the key will expire after that time. + * + * @param key - Key to store the value in. + * @param data - JSON data to store in Redis. + * @param expires - Time (in seconds) for the key to expire. + */ + async set(key: string, data: T, expires?: number) { + const value = JSON.stringify(data); - async function set(data: T, expires?: number) { return expires - ? redis.set(key, JSON.stringify(data), 'EX', expires) - : redis.set(key, JSON.stringify(data)); - } - - return { - get, - set, - }; -} + ? redis.set(key, value, 'EX', expires) + : redis.set(key, value); + }, +}; /** * Returns the cached data if it exists and is valid. Otherwise, it will call @@ -88,10 +82,10 @@ export async function withCache( expires: number | null, fn: () => T | Promise ): Promise { - const data = await redis.get(key); + const data = await cache.get(key); if (data) { - return JSON.parse(data); + return data; } const result = await fn(); @@ -100,11 +94,7 @@ export async function withCache( return result; } - if (expires) { - await redis.set(key, JSON.stringify(result), 'EX', expires); - } else { - await redis.set(key, JSON.stringify(result)); - } + await cache.set(key, result, expires || undefined); return result; } diff --git a/packages/core/src/member-profile.server.ts b/packages/core/src/member-profile.server.ts index a719fe9c..e7466a10 100644 --- a/packages/core/src/member-profile.server.ts +++ b/packages/core/src/member-profile.server.ts @@ -1,4 +1,5 @@ export { job } from './infrastructure/bull/use-cases/job'; +export { cache, ONE_WEEK_IN_SECONDS } from './infrastructure/redis'; export { getActiveStreak } from './modules/active-status/queries/get-active-streak'; export { getActiveStreakLeaderboard } from './modules/active-status/queries/get-active-streak-leaderboard'; export { getGithubProfile } from './modules/authentication/queries/get-github-profile'; diff --git a/packages/core/src/modules/active-status/queries/get-active-streak-leaderboard.ts b/packages/core/src/modules/active-status/queries/get-active-streak-leaderboard.ts index e0bad716..adb4e006 100644 --- a/packages/core/src/modules/active-status/queries/get-active-streak-leaderboard.ts +++ b/packages/core/src/modules/active-status/queries/get-active-streak-leaderboard.ts @@ -2,69 +2,64 @@ import { sql } from 'kysely'; import { db } from '@oyster/db'; -import { cache } from '@/infrastructure/redis'; +import { ONE_HOUR_IN_SECONDS, withCache } from '@/infrastructure/redis'; import { LeaderboardPosition } from '../shared/active-status.shared'; export async function getActiveStreakLeaderboard() { - const { get, set } = cache( + const leaderboard = await withCache( 'get-active-streak-leaderboard', - LeaderboardPosition.array() - ); - - const cachedData = await get(); - - if (cachedData !== null) { - return cachedData; - } + ONE_HOUR_IN_SECONDS * 12, + async () => { + const rows = await db + .with('streakGroups', (db) => { + return db.selectFrom('studentActiveStatuses').select([ + 'date', + 'status', + 'studentId', + sql` + row_number() over (partition by student_id order by date desc) - + row_number() over (partition by student_id, status order by date desc) + `.as('streakGroup'), + ]); + }) + .with('streaks', (db) => { + return db + .selectFrom('streakGroups') + .select([ + 'studentId', + (eb) => eb.fn.countAll().as('streak'), + sql`rank() over (order by count(*) desc)`.as('position'), + ]) + .where('streakGroup', '=', 0) + .where('status', '=', 'active') + .groupBy(['studentId']) + .orderBy('streak', 'desc') + .limit(10); + }) - const rows = await db - .with('streakGroups', (db) => { - return db.selectFrom('studentActiveStatuses').select([ - 'date', - 'status', - 'studentId', - sql` - row_number() over (partition by student_id order by date desc) - - row_number() over (partition by student_id, status order by date desc) - `.as('streakGroup'), - ]); - }) - .with('streaks', (db) => { - return db - .selectFrom('streakGroups') + .selectFrom('streaks') + .leftJoin('students', 'students.id', 'streaks.studentId') .select([ - 'studentId', - (eb) => eb.fn.countAll().as('streak'), - sql`rank() over (order by count(*) desc)`.as('position'), + 'students.id', + 'students.firstName', + 'students.lastName', + 'students.profilePicture', + 'streaks.position', + 'streaks.streak', ]) - .where('streakGroup', '=', 0) - .where('status', '=', 'active') - .groupBy(['studentId']) .orderBy('streak', 'desc') - .limit(10); - }) + .execute(); - .selectFrom('streaks') - .leftJoin('students', 'students.id', 'streaks.studentId') - .select([ - 'students.id', - 'students.firstName', - 'students.lastName', - 'students.profilePicture', - 'streaks.position', - 'streaks.streak', - ]) - .orderBy('streak', 'desc') - .execute(); + const positions = rows.map((row) => { + return LeaderboardPosition.parse({ + ...row, + value: row?.streak, + }); + }); - const positions = rows.map((row) => { - return LeaderboardPosition.parse({ - ...row, - value: row?.streak, - }); - }); - - set(positions, 60 * 60 * 6); + return positions; + } + ); - return positions; + return leaderboard; } diff --git a/packages/core/src/modules/active-status/shared/active-status.shared.ts b/packages/core/src/modules/active-status/shared/active-status.shared.ts index 516aba26..d75352a3 100644 --- a/packages/core/src/modules/active-status/shared/active-status.shared.ts +++ b/packages/core/src/modules/active-status/shared/active-status.shared.ts @@ -11,3 +11,5 @@ export const LeaderboardPosition = Student.pick({ position: z.coerce.number().min(1), value: z.coerce.number().min(0), }); + +export type LeaderboardPosition = z.infer; diff --git a/packages/core/src/modules/ai/ai.core.ts b/packages/core/src/modules/ai/ai.core.ts new file mode 100644 index 00000000..fc91d621 --- /dev/null +++ b/packages/core/src/modules/ai/ai.core.ts @@ -0,0 +1,115 @@ +import { z } from 'zod'; + +import { ColorStackError } from '@/shared/errors'; + +// Environment Variables + +const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY as string; + +// Core + +type ContentBlock = + | { + type: 'image'; + source: { + data: string; + media_type: 'image/jpeg' | 'image/png'; + type: 'base64'; + }; + } + | { + type: 'text'; + text: string; + }; + +type ChatMessage = { + content: string | ContentBlock[]; + role: 'assistant' | 'user'; +}; + +type GetChatCompletionInput = { + /** + * The maximum number of tokens to generate. The maximum value is 8192. + * + * @example 1000 + * @example 8192 + */ + maxTokens: number; + + /** + * The messages to use as context for the completion. The last message should + * be the user's message. + */ + messages: ChatMessage[]; + + /** + * The system prompt to use for the completion. This can be used to provide + * additional context to the AI model, such as the role of the assistant. + */ + system?: string; + + /** + * The temperature to use for the completion. This controls the randomness of + * the output. The higher the temperature, the more random the output. The + * default value is 0.5. + * + * @example 0.1 + * @example 0.5 + * @example 1 + */ + temperature?: number; +}; + +const AnthropicResponse = z.object({ + content: z.object({ text: z.string().trim().min(1) }).array(), + id: z.string().trim().min(1), +}); + +/** + * Returns a chat completion using AI. For now, we're using the Anthropic API, + * but that is subject to change in the future. + * + * We should also explore streaming completions in order to reduce the latency + * of the chat completions. + * + * @see https://docs.anthropic.com/en/api/messages + */ +export async function getChatCompletion({ + maxTokens, + messages, + system, + temperature = 0.5, +}: GetChatCompletionInput): Promise { + const response = await fetch('https://api.anthropic.com/v1/messages', { + body: JSON.stringify({ + messages, + model: 'claude-3-5-sonnet-20240620', + max_tokens: maxTokens, + system, + temperature, + }), + headers: { + // This allows us to use up to 8192 tokens for a single completion. + 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15', + 'anthropic-version': '2023-06-01', + 'content-type': 'application/json', + 'x-api-key': ANTHROPIC_API_KEY, + }, + method: 'post', + }); + + const json = await response.json(); + + if (!response.ok) { + throw new ColorStackError() + .withMessage('Failed to fetch chat completion from Anthropic.') + .withContext({ response: json, status: response.status }) + .report(); + } + + const result = AnthropicResponse.parse(json); + + const message = result.content[0].text; + + return message; +} diff --git a/packages/core/src/modules/employment/queries/search-crunchbase-organizations.ts b/packages/core/src/modules/employment/queries/search-crunchbase-organizations.ts index 6e8aa365..1c714464 100644 --- a/packages/core/src/modules/employment/queries/search-crunchbase-organizations.ts +++ b/packages/core/src/modules/employment/queries/search-crunchbase-organizations.ts @@ -1,4 +1,4 @@ -import { cache } from '@/infrastructure/redis'; +import { ONE_WEEK_IN_SECONDS, withCache } from '@/infrastructure/redis'; import { BaseCompany } from '../employment.types'; import { crunchbaseRateLimiter, @@ -38,58 +38,54 @@ export async function searchCrunchbaseOrganizations( ): Promise { const key = getCrunchbaseKey(); - const { get, set } = cache( + const companies = await withCache( `search-crunchbase-organizations:${search}`, - BaseCompany.array() - ); - - const dataFromCache = await get(); - - if (dataFromCache !== null) { - return dataFromCache; - } - - const pathname = getCrunchbasePathname('/autocompletes'); - const url = new URL(pathname); - - url.searchParams.set('collection_ids', 'organizations'); - url.searchParams.set('query', search); - url.searchParams.set('user_key', key); - - await crunchbaseRateLimiter.process(); - - const response = await fetch(url); - - if (!response.ok) { - throw new Error( - 'Failed to search for organizations from the Crunchbase API.' - ); - } - - // TODO: Should actually validate this data in the future... - const data: CrunchbaseAutocompleteData = await response.json(); - - const companies = data.entities.map(({ identifier, short_description }) => { - const imageUrl = identifier.image_id - ? getCrunchbaseLogoUri(identifier.image_id) - : undefined; - - const result = BaseCompany.safeParse({ - crunchbaseId: identifier.uuid, - description: short_description, - imageUrl, - name: identifier.value, - }); - - if (!result.success) { - throw new Error('Failed to validate Crunchbase organization data.'); + ONE_WEEK_IN_SECONDS, + async () => { + const pathname = getCrunchbasePathname('/autocompletes'); + const url = new URL(pathname); + + url.searchParams.set('collection_ids', 'organizations'); + url.searchParams.set('query', search); + url.searchParams.set('user_key', key); + + await crunchbaseRateLimiter.process(); + + const response = await fetch(url); + + if (!response.ok) { + throw new Error( + 'Failed to search for organizations from the Crunchbase API.' + ); + } + + // TODO: Should actually validate this data in the future... + const data: CrunchbaseAutocompleteData = await response.json(); + + const companies = data.entities.map( + ({ identifier, short_description }) => { + const imageUrl = identifier.image_id + ? getCrunchbaseLogoUri(identifier.image_id) + : undefined; + + const result = BaseCompany.safeParse({ + crunchbaseId: identifier.uuid, + description: short_description, + imageUrl, + name: identifier.value, + }); + + if (!result.success) { + throw new Error('Failed to validate Crunchbase organization data.'); + } + + return result.data; + } + ); + + return companies; } - - return result.data; - }); - - // Cache for 7 days. - set(companies, 60 * 60 * 24 * 7); + ); return companies; } diff --git a/packages/core/src/modules/mixpanel/index.ts b/packages/core/src/modules/mixpanel.ts similarity index 99% rename from packages/core/src/modules/mixpanel/index.ts rename to packages/core/src/modules/mixpanel.ts index 6712662b..089cab24 100644 --- a/packages/core/src/modules/mixpanel/index.ts +++ b/packages/core/src/modules/mixpanel.ts @@ -57,6 +57,7 @@ export type MixpanelEvent = { 'Resource Tag Added': undefined; 'Resource Upvoted': undefined; 'Resource Viewed': undefined; + 'Resume Reviewed': undefined; }; export type TrackInput = { diff --git a/packages/core/src/modules/resume/resume.core.ts b/packages/core/src/modules/resume/resume.core.ts index 317ac6e8..c942eb10 100644 --- a/packages/core/src/modules/resume/resume.core.ts +++ b/packages/core/src/modules/resume/resume.core.ts @@ -1,12 +1,15 @@ import dayjs from 'dayjs'; +import dedent from 'dedent'; import { type SelectExpression } from 'kysely'; import { match } from 'ts-pattern'; +import { z } from 'zod'; import { type DB, db, point } from '@oyster/db'; import { FORMATTED_RACE, Race } from '@oyster/types'; import { id, run } from '@oyster/utils'; import { job } from '@/infrastructure/bull/use-cases/job'; +import { getChatCompletion } from '@/modules/ai/ai.core'; import { type AirtableField, createAirtableRecord, @@ -18,17 +21,20 @@ import { createGoogleDriveFolder, uploadFileToGoogleDrive, } from '@/modules/google-drive'; +import { track } from '@/modules/mixpanel'; import { getPresignedURL, putObject } from '@/modules/object-storage'; import { type CreateResumeBookInput, RESUME_BOOK_CODING_LANGUAGES, RESUME_BOOK_JOB_SEARCH_STATUSES, RESUME_BOOK_ROLES, + type ReviewResumeInput, type SubmitResumeInput, type UpdateResumeBookInput, } from '@/modules/resume/resume.types'; import { ColorStackError } from '@/shared/errors'; import { success } from '@/shared/utils/core.utils'; +import { convertPdfToImage } from '@/shared/utils/file.utils'; // Environment Variables @@ -411,6 +417,152 @@ async function getResumeBookAirtableFields({ ]; } +// Review Resume + +const ResumeBullet = z.object({ + content: z.string(), + feedback: z.string(), + rewrites: z.string().array().min(0).max(2), + score: z.number().min(1).max(10), +}); + +const ResumeFeedback = z.object({ + experiences: z + .object({ + bullets: ResumeBullet.array(), + company: z.string(), + role: z.string(), + }) + .array(), + + projects: z + .object({ + bullets: ResumeBullet.array(), + title: z.string(), + }) + .array(), +}); + +export type ResumeFeedback = z.infer; + +/** + * Reviews a resume using AI and returns feedback in the form of JSON that + * adheres to a specific schema. For now, this feedback is focused on the + * bullet points of the resume. + * + * If there is an issue parsing the AI response, it will throw an error. + * + * @todo Implement the ability to review the rest of the resume. + */ +export async function reviewResume({ memberId, resume }: ReviewResumeInput) { + const systemPrompt = dedent` + You are the best resume reviewer in the world, specifically for resumes + aimed at getting a software engineering internship/new grad role. + + Here are your guidelines for a great bullet point: + - It starts with a strong action verb. + - It is specific. + - It talks about achievements. + - It is concise. No fluff. + - If possible, it quantifies impact. Don't be as critical about this for + projects as you are for work experiences. Also, not every single bullet + point needs to quantify impact, but you should be able to quantify at + least 1-2 bullet points per experience. + + Here are your guidelines for giving feedback: + - Be kind. + - Be specific. + - Be actionable. + - Ask questions (ie: "how many...", "how much...", "what was the impact..."). + - Don't be overly nit-picky. + - If the bullet point is NOT a 10/10, then the last sentence of your + feedback MUST be an actionable improvement item. + + Here are your guidelines for rewriting bullet points: + - If the original bullet point is a 10/10, do NOT suggest any rewrites. + - If the original bullet point is not a 10/10, suggest 1-2 rewrite + options. Those rewrite options should be at minimum 9/10. + - Be 1000% certain that the rewrites address all of your feedback. If + it doesn't, you're not done yet. + - Use letters (ie: "x") instead of arbitrary numbers. + - If details about the "what" are missing, you can use placeholders to + encourage the user to be more specific (ie: "insert xyz here..."). + `; + + const userPrompt = dedent` + Please review this resume. Only return JSON that respects the following Zod + schema: + + const ResumeBullet = z.object({ + content: z.string(), + feedback: z.string(), + rewrites: z.string().array().min(0).max(2), + score: z.number().min(1).max(10), + }); + + z.object({ + experiences: z + .object({ + bullets: ResumeBullet.array(), + company: z.string(), + role: z.string(), + }) + .array(), + + projects: z + .object({ + bullets: ResumeBullet.array(), + title: z.string(), + }) + .array(), + }); + `; + + const imageBase64 = await convertPdfToImage(resume); + + const completion = await getChatCompletion({ + maxTokens: 8192, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: userPrompt, + }, + { + type: 'image', + source: { + data: imageBase64, + media_type: 'image/png', + type: 'base64', + }, + }, + ], + }, + ], + system: systemPrompt, + temperature: 0.25, + }); + + track({ + event: 'Resume Reviewed', + properties: undefined, + user: memberId, + }); + + const object = JSON.parse(completion); + const result = ResumeFeedback.safeParse(object); + + if (!result.success) { + throw new ColorStackError() + .withMessage('There was an issue parsing your resume.') + .report(); + } + + return result.data; +} + /** * Updates the resume book information. * diff --git a/packages/core/src/modules/resume/resume.types.ts b/packages/core/src/modules/resume/resume.types.ts index 39a3773d..9cebcf59 100644 --- a/packages/core/src/modules/resume/resume.types.ts +++ b/packages/core/src/modules/resume/resume.types.ts @@ -80,6 +80,11 @@ export const CreateResumeBookInput = ResumeBook.pick({ .transform((value) => value.split(',')), }); +export const ReviewResumeInput = z.object({ + memberId: z.string().trim().min(1), + resume: FileLike, +}); + export const SubmitResumeInput = Student.pick({ firstName: true, lastName: true, @@ -116,5 +121,6 @@ export const UpdateResumeBookInput = ResumeBook.pick({ }); export type CreateResumeBookInput = z.infer; +export type ReviewResumeInput = z.infer; export type SubmitResumeInput = z.infer; export type UpdateResumeBookInput = z.infer; diff --git a/packages/core/src/shared/utils/file.utils.ts b/packages/core/src/shared/utils/file.utils.ts new file mode 100644 index 00000000..4c2fe15e --- /dev/null +++ b/packages/core/src/shared/utils/file.utils.ts @@ -0,0 +1,38 @@ +import { createCanvas } from 'canvas'; +import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs'; + +/** + * Converts a PDF file to a base64 encoded "image/png" string. This is meant + * only to be used in a server-side environment. + * + * This function uses the "pdf.js" library by Mozilla to render the PDF + * and the converts that to a canvas, then to an image buffer. + * + * Note: This function only converts the first page of the PDF. + * Note: This relies on the "canvas" package to create a canvas, which for some + * machines may require additional dependencies to be installed. + * + * @see https://github.com/mozilla/pdf.js + */ +export async function convertPdfToImage(file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + const data = new Uint8Array(arrayBuffer); + + const document = await getDocument({ data }).promise; + const page = await document.getPage(1); + const viewport = page.getViewport({ scale: 1.0 }); + + const canvas = createCanvas(viewport.width, viewport.height); + const canvasContext = canvas.getContext('2d'); + + await page.render({ + // @ts-expect-error b/c this seems to be working and also the right type... + canvasContext, + viewport, + }).promise; + + const buffer = canvas.toBuffer('image/png'); + const base64 = buffer.toString('base64'); + + return base64; +} diff --git a/packages/ui/package.json b/packages/ui/package.json index 36d3b2f7..807ed57c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -7,6 +7,7 @@ "exports": { ".": "./src/index.ts", "./index.css": "./src/index.css", + "./progress": "./src/components/progress.tsx", "./tooltip": "./src/components/tooltip.tsx" }, "license": "MIT", @@ -16,6 +17,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-tooltip": "^1.0.7" }, "devDependencies": { diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 44b626c7..3e27c344 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -44,9 +44,19 @@ export const Button = ({ Button.Submit = function SubmitButton( props: Omit ) { - const submitting = useNavigation().state === 'submitting'; + const { formMethod, state } = useNavigation(); - return ) : ( - + <> + + + ))} ); } +function ResumeBookSponsors() { + const { sponsors } = useLoaderData(); + + return ( +
    + Sponsors + +
    + + A list of our incredible partner companies who are sponsoring this + resume book and looking to hire YOU! 👀 + +
    + +
      + {sponsors.map((sponsor) => { + return ( +
    • + + + +
      + +
      +
      +
      + + + {sponsor.name} + +
      +
    • + ); + })} +
    +
    + ); +} + function ResumeBookForm() { const { educations, member, submission } = useLoaderData(); const { error, errors } = getErrors(useActionData()); @@ -378,8 +434,6 @@ function ResumeBookForm() { />
    - - { }); }; -// Cache key for the feedback data. -const keyPrefix = 'resume_feedback:'; - export async function loader({ request }: LoaderFunctionArgs) { const session = await ensureUserAuthenticated(request); - const feedback = await cache.get(keyPrefix + user(session)); + const feedback = await getLastResumeFeedback(user(session)); return json({ experiences: feedback?.experiences, @@ -57,8 +57,6 @@ const RESUME_MAX_FILE_SIZE = MB_IN_BYTES * 1; export async function action({ request }: ActionFunctionArgs) { const session = await ensureUserAuthenticated(request); - const memberId = user(session); - const uploadHandler = composeUploadHandlers( createFileUploadHandler({ maxPartSize: RESUME_MAX_FILE_SIZE }), createMemoryUploadHandler() @@ -67,7 +65,7 @@ export async function action({ request }: ActionFunctionArgs) { const form = await parseMultipartFormData(request, uploadHandler); const result = await reviewResume({ - memberId, + memberId: user(session), resume: form.get('resume') as File, }); @@ -75,14 +73,6 @@ export async function action({ request }: ActionFunctionArgs) { return json(result, { status: result.code }); } - // We'll cache the feedback for a week so that we don't have to re-run the - // review process every time the user refreshes the page. - await cache.set( - keyPrefix + memberId, - result.data, - ONE_WEEK_IN_SECONDS - ); - return json(result); } diff --git a/packages/core/src/member-profile.server.ts b/packages/core/src/member-profile.server.ts index e7466a10..a719fe9c 100644 --- a/packages/core/src/member-profile.server.ts +++ b/packages/core/src/member-profile.server.ts @@ -1,5 +1,4 @@ export { job } from './infrastructure/bull/use-cases/job'; -export { cache, ONE_WEEK_IN_SECONDS } from './infrastructure/redis'; export { getActiveStreak } from './modules/active-status/queries/get-active-streak'; export { getActiveStreakLeaderboard } from './modules/active-status/queries/get-active-streak-leaderboard'; export { getGithubProfile } from './modules/authentication/queries/get-github-profile'; diff --git a/packages/core/src/modules/resume/resume.core.ts b/packages/core/src/modules/resume/resume.core.ts index 534359fb..32a5c1fa 100644 --- a/packages/core/src/modules/resume/resume.core.ts +++ b/packages/core/src/modules/resume/resume.core.ts @@ -9,6 +9,7 @@ import { FORMATTED_RACE, Race } from '@oyster/types'; import { id, run } from '@oyster/utils'; import { job } from '@/infrastructure/bull/use-cases/job'; +import { cache, ONE_WEEK_IN_SECONDS } from '@/infrastructure/redis'; import { getChatCompletion } from '@/modules/ai/ai.core'; import { type AirtableField, @@ -44,8 +45,24 @@ const AIRTABLE_RESUME_BOOKS_BASE_ID = process.env const GOOGLE_DRIVE_RESUME_BOOKS_FOLDER_ID = process.env .GOOGLE_DRIVE_RESUME_BOOKS_FOLDER_ID as string; +// Constants + +const RESUME_FEEDBACK_REDIS_PREFIX = 'resume_feedback:'; + // Queries +/** + * Retrieves the last feedback that the member received on their resume. This + * feedback is temporarily stored in Redis, not longer-term storage. + */ +export async function getLastResumeFeedback(memberId: string) { + const feedback = await cache.get( + RESUME_FEEDBACK_REDIS_PREFIX + memberId + ); + + return feedback; +} + type GetResumeBookOptions = { select: Selection[]; where: Partial<{ @@ -126,8 +143,14 @@ export async function listResumeBookSponsors({ const sponsors = await db .selectFrom('resumeBookSponsors') .leftJoin('companies', 'companies.id', 'resumeBookSponsors.companyId') - .select(['companies.id', 'companies.name']) + .select([ + 'companies.domain', + 'companies.id', + 'companies.imageUrl', + 'companies.name', + ]) .where('resumeBookId', '=', where.resumeBookId) + .orderBy('companies.name', 'asc') .execute(); return sponsors; @@ -568,6 +591,14 @@ export async function reviewResume({ }); } + // We'll cache the feedback for a week so that the user can view the + // feedback without having to constantly re-run the review. + await cache.set( + RESUME_FEEDBACK_REDIS_PREFIX + memberId, + result.data, + ONE_WEEK_IN_SECONDS + ); + return success(result.data); } From 7280bb6d7f5c88120100e7cdd0c07b4a426a5205 Mon Sep 17 00:00:00 2001 From: Rami Abdou <38056800+ramiAbdou@users.noreply.github.com> Date: Thu, 15 Aug 2024 11:58:50 -0700 Subject: [PATCH 06/18] =?UTF-8?q?feat:=20update=20point=20totals=20in=20ai?= =?UTF-8?q?rtable=20weekly=20=F0=9F=95=B9=EF=B8=8F=20(#451)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/infrastructure/bull/bull.types.ts | 17 +++++ .../src/infrastructure/bull/use-cases/job.ts | 2 + .../src/modules/airtable/airtable.core.ts | 56 ++++++++++++++++ .../core/src/modules/member/member.worker.ts | 65 +++++++++++++++++++ .../src/migrations/20240815163729_points.ts | 14 ++++ packages/utils/src/index.ts | 25 +++++++ 6 files changed, 179 insertions(+) create mode 100644 packages/db/src/migrations/20240815163729_points.ts diff --git a/packages/core/src/infrastructure/bull/bull.types.ts b/packages/core/src/infrastructure/bull/bull.types.ts index 5a6a3aec..30ab20b9 100644 --- a/packages/core/src/infrastructure/bull/bull.types.ts +++ b/packages/core/src/infrastructure/bull/bull.types.ts @@ -80,6 +80,19 @@ export const AirtableBullJob = z.discriminatedUnion('name', [ data: z.any(), }), }), + z.object({ + name: z.literal('airtable.record.update.bulk'), + data: z.object({ + airtableBaseId: z.string().trim().min(1), + airtableTableId: z.string().trim().min(1), + records: z.array( + z.object({ + id: z.string().trim().min(1), + data: z.any(), + }) + ), + }), + }), ]); export const ApplicationBullJob = z.discriminatedUnion('name', [ @@ -525,6 +538,10 @@ export const StudentBullJob = z.discriminatedUnion('name', [ studentId: Student.shape.id, }), }), + z.object({ + name: z.literal('student.points.recurring'), + data: z.object({}), + }), z.object({ name: z.literal('student.profile.viewed'), data: z.object({ diff --git a/packages/core/src/infrastructure/bull/use-cases/job.ts b/packages/core/src/infrastructure/bull/use-cases/job.ts index ea0b31c8..fb75e085 100644 --- a/packages/core/src/infrastructure/bull/use-cases/job.ts +++ b/packages/core/src/infrastructure/bull/use-cases/job.ts @@ -33,6 +33,7 @@ const QueueNameFromJobName: Record = { 'airtable.record.create.member': 'airtable', 'airtable.record.delete': 'airtable', 'airtable.record.update': 'airtable', + 'airtable.record.update.bulk': 'airtable', 'application.review': 'application', 'education.added': 'education_history', 'education.deleted': 'education_history', @@ -80,6 +81,7 @@ const QueueNameFromJobName: Record = { 'student.birthdate.daily': 'student', 'student.created': 'student', 'student.engagement.backfill': 'student', + 'student.points.recurring': 'student', 'student.profile.viewed': 'student', 'student.removed': 'student', 'student.statuses.backfill': 'student', diff --git a/packages/core/src/modules/airtable/airtable.core.ts b/packages/core/src/modules/airtable/airtable.core.ts index 7895746a..828d232c 100644 --- a/packages/core/src/modules/airtable/airtable.core.ts +++ b/packages/core/src/modules/airtable/airtable.core.ts @@ -71,6 +71,9 @@ export const airtableWorker = registerWorker( .with({ name: 'airtable.record.update' }, ({ data }) => { return updateAirtableRecord(data); }) + .with({ name: 'airtable.record.update.bulk' }, ({ data }) => { + return bulkUpdateAirtableRecord(data); + }) .exhaustive(); } ); @@ -440,3 +443,56 @@ export async function updateAirtableRecord({ return json.id as string; } + +/** + * @see https://airtable.com/developers/web/api/update-multiple-records + */ +export async function bulkUpdateAirtableRecord({ + airtableBaseId, + airtableTableId, + records, +}: GetBullJobData<'airtable.record.update.bulk'>) { + if (!IS_PRODUCTION) { + return; + } + + await airtableRateLimiter.process(); + + const body = JSON.stringify({ + records: records.map((record) => { + return { + id: record.id, + fields: record.data, + }; + }), + + typecast: true, + }); + + const response = await fetch( + `${AIRTABLE_API_URI}/${airtableBaseId}/${airtableTableId}`, + { + body, + headers: getAirtableHeaders({ includeContentType: true }), + method: 'PATCH', + } + ); + + const json = await response.json(); + + if (!response.ok) { + throw new ColorStackError() + .withMessage('Failed to bulk update records in Airtable.') + .withContext({ json, records }) + .report(); + } + + console.log({ + code: 'airtable_record_bulk_updated', + message: 'Airtable records were bulk updated.', + data: { + airtableBaseId, + airtableTableId, + }, + }); +} diff --git a/packages/core/src/modules/member/member.worker.ts b/packages/core/src/modules/member/member.worker.ts index c9bfe2ed..db0a85dc 100644 --- a/packages/core/src/modules/member/member.worker.ts +++ b/packages/core/src/modules/member/member.worker.ts @@ -1,9 +1,18 @@ import { match } from 'ts-pattern'; +import { db } from '@oyster/db'; +import { splitArray } from '@oyster/utils'; + import { StudentBullJob } from '@/infrastructure/bull/bull.types'; +import { job } from '@/infrastructure/bull/use-cases/job'; import { registerWorker } from '@/infrastructure/bull/use-cases/register-worker'; import { backfillActiveStatuses } from '@/modules/active-status/use-cases/backfill-active-statuses'; import { createNewActiveStatuses } from '@/modules/active-status/use-cases/create-new-active-statuses'; +import { + AIRTABLE_FAMILY_BASE_ID, + AIRTABLE_MEMBERS_TABLE_ID, +} from '@/modules/airtable/airtable.core'; +import { success } from '@/shared/utils/core.utils'; import { onActivationStepCompleted } from './events/activation-step-completed'; import { onMemberActivated } from './events/member-activated'; import { onMemberCreated } from './events/member-created'; @@ -35,6 +44,9 @@ export const memberWorker = registerWorker( .with({ name: 'student.engagement.backfill' }, ({ data }) => { return backfillEngagementRecords(data); }) + .with({ name: 'student.points.recurring' }, ({ data: _ }) => { + return updatePointTotals(); + }) .with({ name: 'student.profile.viewed' }, ({ data }) => { return viewMemberProfile(data); }) @@ -50,3 +62,56 @@ export const memberWorker = registerWorker( .exhaustive(); } ); + +/** + * This is a weekly job that runs and updates the point totals for all members. + * The query only updates the points for a member if they've actually changed, + * to avoid unnecessary updates to the database. + * + * For any member that has had their points updated, we also update their + * Airtable record with the new point total. + */ +async function updatePointTotals() { + const members = await db + .with('updatedPoints', (db) => { + return db + .selectFrom('completedActivities') + .select(['studentId', (eb) => eb.fn.sum('points').as('points')]) + .groupBy('studentId'); + }) + .updateTable('students') + .from('updatedPoints') + .set((eb) => { + return { + points: eb.ref('updatedPoints.points'), + }; + }) + .whereRef('students.id', '=', 'updatedPoints.studentId') + .whereRef('students.points', '!=', 'updatedPoints.points') + .returning(['students.airtableId', 'students.id', 'students.points']) + .execute(); + + // The Airtable API only allows us to update 10 records at a time, so we need + // to chunk the members into smaller groups. + const memberChunks = splitArray( + members.filter((member) => !!member.airtableId), + 10 + ); + + memberChunks.forEach((members) => { + job('airtable.record.update.bulk', { + airtableBaseId: AIRTABLE_FAMILY_BASE_ID as string, + airtableTableId: AIRTABLE_MEMBERS_TABLE_ID as string, + records: members.map((member) => { + return { + id: member.id, + data: { + Points: member.points, + }, + }; + }), + }); + }); + + return success(members.length); +} diff --git a/packages/db/src/migrations/20240815163729_points.ts b/packages/db/src/migrations/20240815163729_points.ts new file mode 100644 index 00000000..3d24059c --- /dev/null +++ b/packages/db/src/migrations/20240815163729_points.ts @@ -0,0 +1,14 @@ +import { type Kysely } from 'kysely'; + +export async function up(db: Kysely) { + await db.schema + .alterTable('students') + .addColumn('points', 'integer', (column) => { + return column.defaultTo(0).notNull(); + }) + .execute(); +} + +export async function down(db: Kysely) { + await db.schema.alterTable('students').dropColumn('points').execute(); +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 8f08619e..05890f25 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -151,6 +151,31 @@ export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Splits an array into multiple smaller arrays of a specified maximum size. + * + * @param array - The array to be split. + * @param size - The maximum size of each sub-array. + * @returns An array of sub-arrays, each with a maximum length of `size`. + * + * @example + * ```ts + * splitArray([1, 2, 3, 4, 5], 2); // => [[1, 2], [3, 4], [5]] + * splitArray([1, 2, 3, 4, 5], 3); // => [[1, 2, 3], [4, 5]] + * splitArray([1, 2, 3, 4, 5], 5); // => [[1, 2, 3, 4, 5]] + * splitArray([1, 2, 3, 4, 5], 10); // => [[1, 2, 3, 4, 5]] + * ``` + */ +export function splitArray(array: T[], size: number): T[][] { + const result: T[][] = []; + + for (let i = 0; i < array.length; i += size) { + result.push(array.slice(i, i + size)); + } + + return result; +} + /** * Returns the string with all special characters escaped. * From 9df7e9191aa29437accac98a62623866841903a4 Mon Sep 17 00:00:00 2001 From: Rami Abdou <38056800+ramiAbdou@users.noreply.github.com> Date: Thu, 15 Aug 2024 12:52:56 -0700 Subject: [PATCH 07/18] =?UTF-8?q?fix:=20use=20airtable=20id=20when=20bulk?= =?UTF-8?q?=20updating=20=E2=9D=97=EF=B8=8F=20(#452)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/modules/airtable/airtable.core.ts | 2 +- packages/core/src/modules/member/member.worker.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/modules/airtable/airtable.core.ts b/packages/core/src/modules/airtable/airtable.core.ts index 828d232c..e843d495 100644 --- a/packages/core/src/modules/airtable/airtable.core.ts +++ b/packages/core/src/modules/airtable/airtable.core.ts @@ -483,7 +483,7 @@ export async function bulkUpdateAirtableRecord({ if (!response.ok) { throw new ColorStackError() .withMessage('Failed to bulk update records in Airtable.') - .withContext({ json, records }) + .withContext({ json, records, status: response.status }) .report(); } diff --git a/packages/core/src/modules/member/member.worker.ts b/packages/core/src/modules/member/member.worker.ts index db0a85dc..5d5bb2f1 100644 --- a/packages/core/src/modules/member/member.worker.ts +++ b/packages/core/src/modules/member/member.worker.ts @@ -104,7 +104,7 @@ async function updatePointTotals() { airtableTableId: AIRTABLE_MEMBERS_TABLE_ID as string, records: members.map((member) => { return { - id: member.id, + id: member.airtableId as string, data: { Points: member.points, }, From 22cd1ddbef63c6469386456bcc55a7ed4db30616 Mon Sep 17 00:00:00 2001 From: EmmanuelKey <88858595+EmmanuelKey@users.noreply.github.com> Date: Thu, 15 Aug 2024 14:28:12 -0700 Subject: [PATCH 08/18] =?UTF-8?q?chore:=20my=20first=20contribution=20?= =?UTF-8?q?=F0=9F=9A=80=20=20(#440)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTORS.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.yml b/CONTRIBUTORS.yml index ab333d0b..81bc6671 100644 --- a/CONTRIBUTORS.yml +++ b/CONTRIBUTORS.yml @@ -38,3 +38,4 @@ - sbohrt - Capn05 - Hamza-Mos +- EmmanuelKey From db4ccf4c79424a9b83a8ff05b30e51071a30ff84 Mon Sep 17 00:00:00 2001 From: Gabrielle Polite <137876268+gpolite0@users.noreply.github.com> Date: Thu, 15 Aug 2024 17:34:49 -0400 Subject: [PATCH 09/18] =?UTF-8?q?chore:=20my=20first=20contribution=20?= =?UTF-8?q?=F0=9F=9A=80=20(#442)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTORS.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.yml b/CONTRIBUTORS.yml index 81bc6671..b7f4038f 100644 --- a/CONTRIBUTORS.yml +++ b/CONTRIBUTORS.yml @@ -39,3 +39,4 @@ - Capn05 - Hamza-Mos - EmmanuelKey +- gpolite0 From 07ccad85f7f26947540d85af58d53a06fb8f42a3 Mon Sep 17 00:00:00 2001 From: Habeebah Muse <63429228+Habeebah157@users.noreply.github.com> Date: Thu, 15 Aug 2024 17:39:03 -0400 Subject: [PATCH 10/18] =?UTF-8?q?chore:=20my=20first=20contribution=20?= =?UTF-8?q?=F0=9F=9A=80=20(#443)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTORS.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.yml b/CONTRIBUTORS.yml index b7f4038f..c3971ec3 100644 --- a/CONTRIBUTORS.yml +++ b/CONTRIBUTORS.yml @@ -40,3 +40,4 @@ - Hamza-Mos - EmmanuelKey - gpolite0 +- Habeebah157 From 2278190d542cb9735b8e4ec997d0dc1371ce57c4 Mon Sep 17 00:00:00 2001 From: Bryan Ansong <50881304+bryanansong@users.noreply.github.com> Date: Thu, 15 Aug 2024 17:46:10 -0400 Subject: [PATCH 11/18] =?UTF-8?q?chore:=20my=20first=20contribution=20?= =?UTF-8?q?=F0=9F=9A=80=20(#444)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTORS.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.yml b/CONTRIBUTORS.yml index c3971ec3..d2e82f2d 100644 --- a/CONTRIBUTORS.yml +++ b/CONTRIBUTORS.yml @@ -41,3 +41,4 @@ - EmmanuelKey - gpolite0 - Habeebah157 +- bryanansong From 707aef096761fbaca73f58797b81166afc0b3a01 Mon Sep 17 00:00:00 2001 From: nate <70911747+nathanallen242@users.noreply.github.com> Date: Thu, 15 Aug 2024 17:50:23 -0400 Subject: [PATCH 12/18] =?UTF-8?q?chore:=20my=20first=20contribution=20?= =?UTF-8?q?=F0=9F=9A=80=20(#450)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTORS.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.yml b/CONTRIBUTORS.yml index d2e82f2d..a259a177 100644 --- a/CONTRIBUTORS.yml +++ b/CONTRIBUTORS.yml @@ -42,3 +42,4 @@ - gpolite0 - Habeebah157 - bryanansong +- nathanallen242 From 55606673dde03f5777ba4d770f94315f337b7a61 Mon Sep 17 00:00:00 2001 From: Rami Abdou <38056800+ramiAbdou@users.noreply.github.com> Date: Thu, 15 Aug 2024 15:04:59 -0700 Subject: [PATCH 13/18] =?UTF-8?q?fix:=20parse=20slack=20message=20in=20act?= =?UTF-8?q?ivity=20history=20=F0=9F=91=96=20(#453)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/routes/_profile.points.tsx | 13 ++++---- .../_profile.recap.$date.announcements.tsx | 4 +-- .../app/shared/components/slack-message.tsx | 32 +++++++++++++++---- 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/apps/member-profile/app/routes/_profile.points.tsx b/apps/member-profile/app/routes/_profile.points.tsx index 3896ee06..81662858 100644 --- a/apps/member-profile/app/routes/_profile.points.tsx +++ b/apps/member-profile/app/routes/_profile.points.tsx @@ -41,6 +41,7 @@ import { EmptyStateContainer, } from '@/shared/components/empty-state'; import { Leaderboard } from '@/shared/components/leaderboard'; +import { SlackMessage } from '@/shared/components/slack-message'; import { Route } from '@/shared/constants'; import { getTimezone } from '@/shared/cookies.server'; import { ensureUserAuthenticated, user } from '@/shared/session.server'; @@ -595,12 +596,12 @@ function ActivityHistoryItemDescription({
    - {activity.messageReactedToText} - +
    ); @@ -624,12 +625,12 @@ function ActivityHistoryItemDescription({
    - {activity.threadRepliedToText} - +
    ); diff --git a/apps/member-profile/app/routes/_profile.recap.$date.announcements.tsx b/apps/member-profile/app/routes/_profile.recap.$date.announcements.tsx index 1e041446..d29a2bec 100644 --- a/apps/member-profile/app/routes/_profile.recap.$date.announcements.tsx +++ b/apps/member-profile/app/routes/_profile.recap.$date.announcements.tsx @@ -6,7 +6,7 @@ import { emojify } from 'node-emoji'; import { listSlackMessages } from '@oyster/core/slack.server'; import { getDateRange, Recap } from '@/routes/_profile.recap.$date'; -import { SlackMessage } from '@/shared/components/slack-message'; +import { SlackMessageCard } from '@/shared/components/slack-message'; import { ENV } from '@/shared/constants.server'; import { ensureUserAuthenticated } from '@/shared/session.server'; @@ -54,7 +54,7 @@ export default function RecapAnnouncements() {
      {announcements.map((announcement) => { return ( -
      @@ -64,13 +70,25 @@ export function SlackMessage({ )}
      - - {<>{toHTML(parseSlackMessage(text))}} - + {text} ); } +type SlackMessageProps = Pick; + +export function SlackMessage({ + children, + className, + ...rest +}: SlackMessageProps) { + return ( + + {<>{toHTML(parseSlackMessage(children as string))}} + + ); +} + function toHTML(node: Node) { const result = match(node) .with( From 9518f47f5266224ee26b5b89832748f7b274aab1 Mon Sep 17 00:00:00 2001 From: Rami Abdou <38056800+ramiAbdou@users.noreply.github.com> Date: Thu, 15 Aug 2024 15:24:16 -0700 Subject: [PATCH 14/18] =?UTF-8?q?docs:=20update=20docker=20desktop=20instr?= =?UTF-8?q?uctions=20=F0=9F=8E=AC=20(#454)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CONTRIBUTING.md | 3 ++- .../app/routes/_profile.points.tsx | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c588d5a3..659faa50 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,8 @@ members. ❤️ Follow these steps in order to get started with contributing to Oyster! -1. Install [Docker Desktop](https://docs.docker.com/engine/install). +1. Install [Docker Desktop](https://docs.docker.com/engine/install). After it's + installed, start the application! 2. Install [Node.js](https://nodejs.org/en/download/package-manager) (v20.x). diff --git a/apps/member-profile/app/routes/_profile.points.tsx b/apps/member-profile/app/routes/_profile.points.tsx index 81662858..3a18af10 100644 --- a/apps/member-profile/app/routes/_profile.points.tsx +++ b/apps/member-profile/app/routes/_profile.points.tsx @@ -12,6 +12,7 @@ import { useSubmit, } from '@remix-run/react'; import dayjs from 'dayjs'; +import { emojify } from 'node-emoji'; import { Award, Plus } from 'react-feather'; import { match } from 'ts-pattern'; import { z } from 'zod'; @@ -80,7 +81,7 @@ export async function loader({ request }: LoaderFunctionArgs) { const id = user(session); const [ - { completedActivities, totalActivitiesCompleted }, + { completedActivities: _completedActivities, totalActivitiesCompleted }, points, pointsLeaderboard, activities, @@ -110,6 +111,18 @@ export async function loader({ request }: LoaderFunctionArgs) { user: id, }); + const completedActivities = _completedActivities.map((activity) => { + if (activity.messageReactedToText) { + activity.messageReactedToText = emojify(activity.messageReactedToText); + } + + if (activity.threadRepliedToText) { + activity.threadRepliedToText = emojify(activity.threadRepliedToText); + } + + return activity; + }); + return json({ activities, completedActivities, @@ -597,7 +610,7 @@ function ActivityHistoryItemDescription({
      {activity.messageReactedToText} @@ -626,7 +639,7 @@ function ActivityHistoryItemDescription({
      {activity.threadRepliedToText} From 6ea61c93d18ee41cd3902fd682b4ffdd5c4e020b Mon Sep 17 00:00:00 2001 From: Hamza Mostafa Date: Fri, 16 Aug 2024 01:35:03 -0400 Subject: [PATCH 15/18] =?UTF-8?q?feat:=20send=20daily=20"feed"=20notificat?= =?UTF-8?q?ion=20w/=20new=20resources=20posted=20=F0=9F=93=A3=20(#435)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/.env.example | 1 + apps/api/src/main.ts | 2 + apps/api/src/shared/env.ts | 2 + .../app/routes/_profile.resources.tsx | 46 ++++++--- packages/core/src/api.ts | 1 + .../src/infrastructure/bull/bull.types.ts | 9 ++ .../src/infrastructure/bull/use-cases/job.ts | 1 + packages/core/src/modules/feed/feed.core.ts | 97 +++++++++++++++++++ 8 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/modules/feed/feed.core.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index ece10388..df113b84 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -36,6 +36,7 @@ STUDENT_PROFILE_URL=http://localhost:3000 # SLACK_BOT_TOKEN= # SLACK_CLIENT_ID= # SLACK_CLIENT_SECRET= +# SLACK_FEED_CHANNEL_ID= # SLACK_INTRODUCTIONS_CHANNEL_ID= # SLACK_SIGNING_SECRET= # SMTP_HOST= diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0882ef77..fcc84ce1 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -10,6 +10,7 @@ import { educationWorker, emailMarketingWorker, eventWorker, + feedWorker, gamificationWorker, memberEmailWorker, memberWorker, @@ -85,6 +86,7 @@ function initializeBullWorkers() { educationWorker.run(); emailMarketingWorker.run(); eventWorker.run(); + feedWorker.run(); gamificationWorker.run(); memberWorker.run(); memberEmailWorker.run(); diff --git a/apps/api/src/shared/env.ts b/apps/api/src/shared/env.ts index 90b3d09d..6f81b823 100644 --- a/apps/api/src/shared/env.ts +++ b/apps/api/src/shared/env.ts @@ -45,6 +45,7 @@ const BaseEnvironmentConfig = z.object({ SLACK_BOT_TOKEN: EnvironmentVariable, SLACK_CLIENT_ID: EnvironmentVariable, SLACK_CLIENT_SECRET: EnvironmentVariable, + SLACK_FEED_CHANNEL_ID: EnvironmentVariable, SLACK_INTRODUCTIONS_CHANNEL_ID: EnvironmentVariable, SLACK_SIGNING_SECRET: EnvironmentVariable, STUDENT_PROFILE_URL: EnvironmentVariable, @@ -80,6 +81,7 @@ const EnvironmentConfig = z.discriminatedUnion('ENVIRONMENT', [ SLACK_BOT_TOKEN: true, SLACK_CLIENT_ID: true, SLACK_CLIENT_SECRET: true, + SLACK_FEED_CHANNEL_ID: true, SLACK_INTRODUCTIONS_CHANNEL_ID: true, SLACK_SIGNING_SECRET: true, SWAG_UP_CLIENT_ID: true, diff --git a/apps/member-profile/app/routes/_profile.resources.tsx b/apps/member-profile/app/routes/_profile.resources.tsx index 9dd4495d..5fef9e4b 100644 --- a/apps/member-profile/app/routes/_profile.resources.tsx +++ b/apps/member-profile/app/routes/_profile.resources.tsx @@ -19,6 +19,7 @@ import { type ResourceType, } from '@oyster/core/resources'; import { listResources, listTags } from '@oyster/core/resources.server'; +import { ISO8601Date } from '@oyster/types'; import { Dashboard, ExistingSearchParams, @@ -28,7 +29,7 @@ import { Select, Text, } from '@oyster/ui'; -import { iife } from '@oyster/utils'; +import { run } from '@oyster/utils'; import { ListSearchParams } from '@/member-profile.ui'; import { Resource } from '@/shared/components/resource'; @@ -42,10 +43,11 @@ const ResourcesSearchParams = ListSearchParams.pick({ limit: true, page: true, }).extend({ - [whereKeys.id]: ListResourcesWhere.shape.id.catch(undefined), - [whereKeys.search]: ListResourcesWhere.shape.search, - [whereKeys.tags]: ListResourcesWhere.shape.tags.catch([]), + date: ISO8601Date.optional().catch(undefined), + id: ListResourcesWhere.shape.id.catch(undefined), orderBy: ListResourcesOrderBy, + search: ListResourcesWhere.shape.search, + tags: ListResourcesWhere.shape.tags.catch([]), }); export async function loader({ request }: LoaderFunctionArgs) { @@ -83,9 +85,21 @@ export async function loader({ request }: LoaderFunctionArgs) { id: searchParams.id, search: searchParams.search, tags: searchParams.tags, + + ...(searchParams.date && + run(() => { + const date = dayjs(searchParams.date).tz('America/Los_Angeles', true); + + return { + postedAfter: date.startOf('day').toDate(), + postedBefore: date.endOf('day').toDate(), + }; + })), }, }); + const tz = getTimezone(request); + const resources = await Promise.all( records.map( async ({ @@ -123,7 +137,7 @@ export async function loader({ request }: LoaderFunctionArgs) { postedAt: dayjs().to(postedAt), postedAtExpanded: dayjs(postedAt) - .tz(getTimezone(request)) + .tz(tz) .format('MMM DD, YYYY • h:mm A'), // This is the URL that can be shared with others to view the @@ -140,16 +154,25 @@ export async function loader({ request }: LoaderFunctionArgs) { ) ); - const tags = await iife(async () => { - const result: { id: string; name: string; param: string }[] = []; + const tags = await run(async () => { + const result: { name: string; param: string; value?: string }[] = []; + + if (searchParams.date) { + const date = dayjs(searchParams.date).tz(tz, true).format('M/D/YY'); + + result.push({ + name: `Date: ${date}`, + param: 'date', + }); + } // If there is an ID in the search params, we want to show a tag for it // to make it clear that the search is for a specific resource. if (searchParams.id && resources[0]) { result.push({ - id: searchParams.id, name: resources[0].title as string, param: whereKeys.id, + value: searchParams.id, }); } @@ -163,8 +186,9 @@ export async function loader({ request }: LoaderFunctionArgs) { tags.forEach((tag) => { result.push({ - ...tag, + name: tag.name, param: whereKeys.tags, + value: tag.id, }); }); } @@ -272,10 +296,10 @@ function CurrentTagsList() { // Since there could be multiple tags, we need to specify the value // along with the key. - searchParams.delete(tag.param, tag.id); + searchParams.delete(tag.param, tag.value); return ( -
    • +
    • = { 'event.register': 'event', 'event.registered': 'event', 'event.sync': 'event', + 'feed.slack.recurring': 'feed', 'gamification.activity.completed': 'gamification', 'gamification.activity.completed.undo': 'gamification', 'member_email.added': 'member_email', diff --git a/packages/core/src/modules/feed/feed.core.ts b/packages/core/src/modules/feed/feed.core.ts new file mode 100644 index 00000000..d7cdc277 --- /dev/null +++ b/packages/core/src/modules/feed/feed.core.ts @@ -0,0 +1,97 @@ +import dayjs from 'dayjs'; +import dayOfYear from 'dayjs/plugin/dayOfYear'; +import dedent from 'dedent'; +import { match } from 'ts-pattern'; + +import { db } from '@oyster/db'; +import { run } from '@oyster/utils'; + +import { + FeedBullJob, + type GetBullJobData, +} from '@/infrastructure/bull/bull.types'; +import { job } from '@/infrastructure/bull/use-cases/job'; +import { registerWorker } from '@/infrastructure/bull/use-cases/register-worker'; +import { ENV } from '@/shared/env'; + +// Environment Variables + +const SLACK_FEED_CHANNEL_ID = process.env.SLACK_FEED_CHANNEL_ID || ''; + +// Worker + +export const feedWorker = registerWorker('feed', FeedBullJob, async (job) => { + return match(job) + .with({ name: 'feed.slack.recurring' }, ({ data }) => { + return sendFeedSlackNotification(data); + }) + .exhaustive(); +}); + +dayjs.extend(dayOfYear); + +/** + * Sends a Slack notification to our "feed" channel on a daily basis. It serves + * as a "digest" of things that happened the day before, particularly in the + * Member Profile. + * + * For now, we're only including resources that were posted in the Resource + * Database. In the future, we'll expand this to include other things like + * company reviews, new members in the directory, etc. + */ +async function sendFeedSlackNotification( + _: GetBullJobData<'feed.slack.recurring'> +) { + // We're filtering for things that happened yesterday -- we'll use the PT + // timezone so that everyone is on the same page. + const yesterday = dayjs().tz('America/Los_Angeles').subtract(1, 'day'); + + const startOfYesterday = yesterday.startOf('day').toDate(); + const endOfYesterday = yesterday.endOf('day').toDate(); + + const resources = await db + .selectFrom('resources') + .leftJoin('students', 'students.id', 'resources.postedBy') + .select(['resources.title', 'students.slackId as posterSlackId']) + .where('resources.postedAt', '>=', startOfYesterday) + .where('resources.postedAt', '<=', endOfYesterday) + .execute(); + + if (!resources.length) { + return; + } + + const message = run(() => { + const resourceItems = resources + .map((resource) => { + return `• ${resource.title} by <@${resource.posterSlackId}>`; + }) + .join('\n'); + + const url = new URL('/resources', ENV.STUDENT_PROFILE_URL); + + // Example: https://app.colorstack.io/resources?date=2024-08-15 + url.searchParams.set('date', yesterday.format('YYYY-MM-DD')); + + const dayOfTheWeek = dayjs().format('dddd'); + const dayOfTheYear = dayjs().dayOfYear(); + + return dedent` + Morning y'all, happy ${dayOfTheWeek}! ☀️ + + The following <${url}|_resources_> were posted yesterday: + + ${resourceItems} + + Show some love for their contributions! ❤️ + + #TheFeed #Day${dayOfTheYear} + `; + }); + + job('notification.slack.send', { + channel: SLACK_FEED_CHANNEL_ID, + message, + workspace: 'regular', + }); +} From 2c164a53e7e4a7bab660e2eb9817ccfdc4fe434e Mon Sep 17 00:00:00 2001 From: Rami Abdou <38056800+ramiAbdou@users.noreply.github.com> Date: Thu, 15 Aug 2024 22:57:54 -0700 Subject: [PATCH 16/18] =?UTF-8?q?feat:=20update=20formatting=20of=20daily?= =?UTF-8?q?=20feed=20notification=20=F0=9F=94=97=20(#455)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/core/src/modules/feed/feed.core.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/core/src/modules/feed/feed.core.ts b/packages/core/src/modules/feed/feed.core.ts index d7cdc277..c8efc7d6 100644 --- a/packages/core/src/modules/feed/feed.core.ts +++ b/packages/core/src/modules/feed/feed.core.ts @@ -52,7 +52,11 @@ async function sendFeedSlackNotification( const resources = await db .selectFrom('resources') .leftJoin('students', 'students.id', 'resources.postedBy') - .select(['resources.title', 'students.slackId as posterSlackId']) + .select([ + 'resources.id', + 'resources.title', + 'students.slackId as posterSlackId', + ]) .where('resources.postedAt', '>=', startOfYesterday) .where('resources.postedAt', '<=', endOfYesterday) .execute(); @@ -64,7 +68,12 @@ async function sendFeedSlackNotification( const message = run(() => { const resourceItems = resources .map((resource) => { - return `• ${resource.title} by <@${resource.posterSlackId}>`; + const url = new URL('/resources', ENV.STUDENT_PROFILE_URL); + + // Example: https://app.colorstack.io/resources?id=123 + url.searchParams.set('id', resource.id); + + return `• <${url}|*${resource.title}*> by <@${resource.posterSlackId}>`; }) .join('\n'); @@ -79,7 +88,7 @@ async function sendFeedSlackNotification( return dedent` Morning y'all, happy ${dayOfTheWeek}! ☀️ - The following <${url}|_resources_> were posted yesterday: + The following <${url}|resources> were posted yesterday: ${resourceItems} From a9fad6ec237156412deffe9d3348c202e3c1e414 Mon Sep 17 00:00:00 2001 From: Rami Abdou <38056800+ramiAbdou@users.noreply.github.com> Date: Fri, 16 Aug 2024 18:15:05 -0700 Subject: [PATCH 17/18] =?UTF-8?q?fix:=20improve=20parsing=20to=20avoid=20h?= =?UTF-8?q?allucinations=20in=20resume=20review=20=E2=9D=97=EF=B8=8F=20(#4?= =?UTF-8?q?56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/routes/_profile.resume.review.tsx | 33 +++++++++-- .../core/src/modules/resume/resume.core.ts | 19 +++---- packages/core/src/shared/utils/file.utils.ts | 56 +++++++++++++++++++ packages/core/src/shared/utils/zod.utils.ts | 2 +- 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/apps/member-profile/app/routes/_profile.resume.review.tsx b/apps/member-profile/app/routes/_profile.resume.review.tsx index 9654ada2..2223cd94 100644 --- a/apps/member-profile/app/routes/_profile.resume.review.tsx +++ b/apps/member-profile/app/routes/_profile.resume.review.tsx @@ -24,7 +24,16 @@ import { type ResumeFeedback, reviewResume, } from '@oyster/core/resumes'; -import { Button, cx, FileUploader, Form, MB_IN_BYTES, Text } from '@oyster/ui'; +import { ReviewResumeInput } from '@oyster/core/resumes.types'; +import { + Button, + cx, + FileUploader, + Form, + MB_IN_BYTES, + Text, + validateForm, +} from '@oyster/ui'; import { Progress, useProgress } from '@oyster/ui/progress'; import { @@ -64,10 +73,24 @@ export async function action({ request }: ActionFunctionArgs) { const form = await parseMultipartFormData(request, uploadHandler); - const result = await reviewResume({ - memberId: user(session), - resume: form.get('resume') as File, - }); + form.set('memberId', user(session)); + + const { data, errors, ok } = await validateForm( + Object.fromEntries(form), + ReviewResumeInput + ); + + if (!ok) { + return json( + { + error: errors.resume, + ok: false, + } as const, + { status: 400 } + ); + } + + const result = await reviewResume(data); if (!result.ok) { return json(result, { status: result.code }); diff --git a/packages/core/src/modules/resume/resume.core.ts b/packages/core/src/modules/resume/resume.core.ts index 32a5c1fa..45fc125d 100644 --- a/packages/core/src/modules/resume/resume.core.ts +++ b/packages/core/src/modules/resume/resume.core.ts @@ -35,7 +35,7 @@ import { } from '@/modules/resume/resume.types'; import { ColorStackError } from '@/shared/errors'; import { fail, type Result, success } from '@/shared/utils/core.utils'; -import { convertPdfToImage } from '@/shared/utils/file.utils'; +import { getTextFromPDF } from '@/shared/utils/file.utils'; // Environment Variables @@ -516,8 +516,10 @@ export async function reviewResume({ `; const userPrompt = dedent` - Please review this resume. Only return JSON that respects the following Zod - schema: + The following is a resume that has been parsed to text from a PDF. Please + review this resume. + + IMPORTANT: Only return JSON that respects the following Zod schema: const ResumeBullet = z.object({ content: z.string(), @@ -527,6 +529,7 @@ export async function reviewResume({ }); z.object({ + // This should also include leadership experiences. experiences: z .object({ bullets: ResumeBullet.array(), @@ -544,7 +547,7 @@ export async function reviewResume({ }); `; - const imageBase64 = await convertPdfToImage(resume); + const resumeText = await getTextFromPDF(resume); const completionResult = await getChatCompletion({ maxTokens: 8192, @@ -557,12 +560,8 @@ export async function reviewResume({ text: userPrompt, }, { - type: 'image', - source: { - data: imageBase64, - media_type: 'image/png', - type: 'base64', - }, + type: 'text', + text: resumeText, }, ], }, diff --git a/packages/core/src/shared/utils/file.utils.ts b/packages/core/src/shared/utils/file.utils.ts index 4c2fe15e..f12c1587 100644 --- a/packages/core/src/shared/utils/file.utils.ts +++ b/packages/core/src/shared/utils/file.utils.ts @@ -36,3 +36,59 @@ export async function convertPdfToImage(file: File): Promise { return base64; } + +/** + * Extracts the text content from a PDF file. + * + * @param file - The PDF file to extract text from. + * @returns The text content of the PDF file. + */ +export async function getTextFromPDF(file: File) { + const arrayBuffer = await file.arrayBuffer(); + const data = new Uint8Array(arrayBuffer); + + const document = await getDocument({ data }).promise; + + let result = ''; + + // Tracks the y-coordinate of the last text item, which will help us + // determine whether or not to render a newline character (multiple items + // could be on the same line) + let lastY = 0; + + for (let i = 1; i <= document.numPages; i++) { + const page = await document.getPage(i); + const content = await page.getTextContent(); + + content.items.filter((item) => { + // We're only interested in text items, not marked content or images. + if (!('str' in item)) { + return; + } + + // The transform matrix is an array of 6 numbers that represent the + // transformation matrix required to position the text on the page. The + // 5th and 6th numbers are the x and y coordinates of the text. + const y = item.transform[5]; + + // If the y coordinate has changed, we're on a new line, so add a newline + // character. Note that it doesn't have to be exact... + if (Math.round(y) !== Math.round(lastY)) { + result += '\n'; + } + + // The actual content of the text item gets added. + result += item.str; + + // If the text item has an EOL (end of line) property, add a newline + // character. + if (item.hasEOL) { + result += '\n'; + } + + lastY = y; + }); + } + + return result; +} diff --git a/packages/core/src/shared/utils/zod.utils.ts b/packages/core/src/shared/utils/zod.utils.ts index 1d80c950..39bee3de 100644 --- a/packages/core/src/shared/utils/zod.utils.ts +++ b/packages/core/src/shared/utils/zod.utils.ts @@ -10,7 +10,7 @@ export const FileLike = z.custom((value) => { 'text' in value && 'type' in value ); -}); +}, 'This is not a valid file object.'); /** * Returns the error message that lives within the `error`. Note that even if From 39af6108dea289b49e70a1d36c217c8eec65ebca Mon Sep 17 00:00:00 2001 From: Rami Abdou <38056800+ramiAbdou@users.noreply.github.com> Date: Sat, 17 Aug 2024 04:21:00 -0700 Subject: [PATCH 18/18] =?UTF-8?q?fix:=20prompt=20for=20resume=20review=20t?= =?UTF-8?q?o=20return=20json=20=E2=9D=97=EF=B8=8F=20(#457)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/src/modules/resume/resume.core.ts | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/core/src/modules/resume/resume.core.ts b/packages/core/src/modules/resume/resume.core.ts index 45fc125d..215639c6 100644 --- a/packages/core/src/modules/resume/resume.core.ts +++ b/packages/core/src/modules/resume/resume.core.ts @@ -519,7 +519,8 @@ export async function reviewResume({ The following is a resume that has been parsed to text from a PDF. Please review this resume. - IMPORTANT: Only return JSON that respects the following Zod schema: + IMPORTANT: Do not return ANYTHING except for JSON that respects the + following Zod schema: const ResumeBullet = z.object({ content: z.string(), @@ -580,25 +581,30 @@ export async function reviewResume({ user: memberId, }); - const object = JSON.parse(completionResult.data); - const result = ResumeFeedback.safeParse(object); + try { + const object = JSON.parse(completionResult.data); + const feedback = ResumeFeedback.parse(object); + + // We'll cache the feedback for a week so that the user can view the + // feedback without having to constantly re-run the review. + await cache.set( + RESUME_FEEDBACK_REDIS_PREFIX + memberId, + feedback, + ONE_WEEK_IN_SECONDS + ); + + return success(feedback); + } catch (e) { + const error = new ColorStackError() + .withMessage('Failed to parse the AI response.') + .withContext({ data: completionResult.data, error: e }) + .report(); - if (!result.success) { return fail({ code: 500, - error: 'Failed to parse the AI response.', + error: error.message, }); } - - // We'll cache the feedback for a week so that the user can view the - // feedback without having to constantly re-run the review. - await cache.set( - RESUME_FEEDBACK_REDIS_PREFIX + memberId, - result.data, - ONE_WEEK_IN_SECONDS - ); - - return success(result.data); } /**