From c57612b4e28f6d46a5f9c7c65a897cc809aa4417 Mon Sep 17 00:00:00 2001 From: nyranmoodie Date: Tue, 11 Aug 2026 14:29:10 -0500 Subject: [PATCH 1/4] feat: add company management and resume book sponsor editing Co-authored-by: Cursor --- apps/admin-dashboard/.env.example | 1 + .../_dashboard.companies.$id.delete.tsx | 85 +++++ .../routes/_dashboard.companies.$id.edit.tsx | 163 +++++++++ .../routes/_dashboard.companies.create.tsx | 140 ++++++++ .../app/routes/_dashboard.companies.tsx | 234 +++++++++++++ .../_dashboard.resume-books.$id.delete.tsx | 84 +++++ .../_dashboard.resume-books.$id.edit.tsx | 46 ++- .../routes/_dashboard.resume-books.create.tsx | 77 +---- .../app/routes/_dashboard.resume-books.tsx | 14 +- .../admin-dashboard/app/routes/_dashboard.tsx | 6 + .../app/routes/api.companies.search.tsx | 2 +- apps/admin-dashboard/app/shared/constants.ts | 5 + bun.lock | 1 + packages/core/package.json | 2 + packages/core/src/modules/apify.ts | 12 + .../modules/employment/employment.types.ts | 76 +++++ .../src/modules/employment/index.server.ts | 4 + .../modules/employment/ui/companies.ui.tsx | 186 ++++++++++ .../employment/use-cases/create-company.ts | 113 ++++++ .../employment/use-cases/delete-company.ts | 96 ++++++ .../use-cases/fetch-company-from-linkedin.ts | 66 ++++ .../use-cases/save-company-if-necessary.ts | 133 ++++---- .../employment/use-cases/update-company.ts | 125 +++++++ .../src/modules/resume-books/resume-books.ts | 321 +++++++++++++++++- .../resume-books/resume-books.types.ts | 17 + .../modules/resume-books/resume-books.ui.tsx | 89 ++++- 26 files changed, 1937 insertions(+), 161 deletions(-) create mode 100644 apps/admin-dashboard/app/routes/_dashboard.companies.$id.delete.tsx create mode 100644 apps/admin-dashboard/app/routes/_dashboard.companies.$id.edit.tsx create mode 100644 apps/admin-dashboard/app/routes/_dashboard.companies.create.tsx create mode 100644 apps/admin-dashboard/app/routes/_dashboard.companies.tsx create mode 100644 apps/admin-dashboard/app/routes/_dashboard.resume-books.$id.delete.tsx create mode 100644 packages/core/src/modules/employment/ui/companies.ui.tsx create mode 100644 packages/core/src/modules/employment/use-cases/create-company.ts create mode 100644 packages/core/src/modules/employment/use-cases/delete-company.ts create mode 100644 packages/core/src/modules/employment/use-cases/fetch-company-from-linkedin.ts create mode 100644 packages/core/src/modules/employment/use-cases/update-company.ts diff --git a/apps/admin-dashboard/.env.example b/apps/admin-dashboard/.env.example index c6a6d34e8..fbebf6bc6 100644 --- a/apps/admin-dashboard/.env.example +++ b/apps/admin-dashboard/.env.example @@ -24,3 +24,4 @@ STUDENT_PROFILE_URL=http://localhost:3000 # SMTP_HOST= # SMTP_PASSWORD= # SMTP_USERNAME= +# APIFY_API_TOKEN= diff --git a/apps/admin-dashboard/app/routes/_dashboard.companies.$id.delete.tsx b/apps/admin-dashboard/app/routes/_dashboard.companies.$id.delete.tsx new file mode 100644 index 000000000..8cf8bddc6 --- /dev/null +++ b/apps/admin-dashboard/app/routes/_dashboard.companies.$id.delete.tsx @@ -0,0 +1,85 @@ +import { + type ActionFunctionArgs, + data, + Form, + type LoaderFunctionArgs, + redirect, + useActionData, + useLoaderData, +} from 'react-router'; + +import { deleteCompany, getCompany } from '@oyster/core/employment/server'; +import { Button, ErrorMessage, getErrors, Modal } from '@oyster/ui'; + +import { Route } from '@/shared/constants'; +import { + commitSession, + ensureUserAuthenticated, + toast, +} from '@/shared/session.server'; + +export async function loader({ params, request }: LoaderFunctionArgs) { + await ensureUserAuthenticated(request); + + const company = await getCompany({ + select: ['id', 'name'], + where: { id: params.id as string }, + }); + + if (!company) { + throw new Response(null, { status: 404 }); + } + + return { + company, + }; +} + +export async function action({ params, request }: ActionFunctionArgs) { + const session = await ensureUserAuthenticated(request); + + const result = await deleteCompany(params.id as string); + + if (!result.ok) { + return data({ error: result.error }, { status: result.code }); + } + + toast(session, { + message: 'Deleted company.', + type: 'success', + }); + + return redirect(Route['/companies'], { + headers: { + 'Set-Cookie': await commitSession(session), + }, + }); +} + +export default function DeleteCompanyModal() { + const { company } = useLoaderData(); + const { error } = getErrors(useActionData()); + + return ( + + + Delete Company: {company.name} + + + + + Are you sure you want to delete this company? This will remove it from + all resume book sponsorships. Companies with work experiences, job + offers, or member sponsor selections cannot be deleted. + + +
+ {error} + + + Delete + +
+
+ ); +} diff --git a/apps/admin-dashboard/app/routes/_dashboard.companies.$id.edit.tsx b/apps/admin-dashboard/app/routes/_dashboard.companies.$id.edit.tsx new file mode 100644 index 000000000..3d54be8d7 --- /dev/null +++ b/apps/admin-dashboard/app/routes/_dashboard.companies.$id.edit.tsx @@ -0,0 +1,163 @@ +import { + type ActionFunctionArgs, + data, + Form, + type LoaderFunctionArgs, + redirect, + useActionData, + useLoaderData, +} from 'react-router'; + +import { getCompany, isApifyConfigured, updateCompany } from '@oyster/core/employment/server'; +import { AdminUpdateCompanyInput } from '@oyster/core/employment/types'; +import { + CompanyDomainField, + CompanyImageUrlField, + CompanyLinkedInSlugField, + CompanyLogoPreview, + CompanyNameField, + CompanyResumeBooksField, +} from '@oyster/core/employment/ui'; +import { + listResumeBooks, + updateCompanyResumeBookSponsorships, +} from '@oyster/core/resume-books'; +import { db } from '@oyster/db'; +import { Button, ErrorMessage, getErrors, Modal, Text, validateForm } from '@oyster/ui'; + +import { Route } from '@/shared/constants'; +import { + commitSession, + ensureUserAuthenticated, + toast, +} from '@/shared/session.server'; + +export async function loader({ params, request }: LoaderFunctionArgs) { + await ensureUserAuthenticated(request); + + const companyId = params.id as string; + + const [company, resumeBooks, sponsorships] = await Promise.all([ + getCompany({ + select: ['domain', 'id', 'imageUrl', 'linkedinSlug', 'name'], + where: { id: companyId }, + }), + + listResumeBooks({ + select: ['id', 'name'], + }), + + db + .selectFrom('resumeBookSponsors') + .select(['resumeBookId']) + .where('companyId', '=', companyId) + .execute(), + ]); + + if (!company) { + throw new Response(null, { status: 404 }); + } + + return { + apifyConfigured: isApifyConfigured(), + company, + resumeBookIds: sponsorships.map((sponsorship) => sponsorship.resumeBookId), + resumeBooks, + }; +} + +export async function action({ params, request }: ActionFunctionArgs) { + const session = await ensureUserAuthenticated(request); + const companyId = params.id as string; + + const result = await validateForm(request, AdminUpdateCompanyInput); + + if (!result.ok) { + return data(result, { status: 400 }); + } + + const { resumeBookIds, ...companyInput } = result.data; + + const updateResult = await updateCompany({ + ...companyInput, + id: companyId, + }); + + if (!updateResult.ok) { + return data({ error: updateResult.error }, { status: updateResult.code }); + } + + const sponsorshipResult = await updateCompanyResumeBookSponsorships({ + companyId, + resumeBookIds, + }); + + if (!sponsorshipResult.ok) { + return data( + { error: sponsorshipResult.error }, + { status: sponsorshipResult.code } + ); + } + + toast(session, { + message: 'Updated company.', + type: 'success', + }); + + const url = new URL(request.url); + url.pathname = Route['/companies']; + + return redirect(url.toString(), { + headers: { + 'Set-Cookie': await commitSession(session), + }, + }); +} + +export default function EditCompanyPage() { + const { apifyConfigured, company, resumeBookIds, resumeBooks } = + useLoaderData(); + const { error, errors } = getErrors(useActionData()); + + return ( + + + Edit Company: {company.name} + + + +
+ + + + + + {!apifyConfigured && ( + + Apify is not configured. Use Logo URL to set the logo manually. + + )} + + + {error} + + + Save Changes + + +
+ ); +} diff --git a/apps/admin-dashboard/app/routes/_dashboard.companies.create.tsx b/apps/admin-dashboard/app/routes/_dashboard.companies.create.tsx new file mode 100644 index 000000000..0ffc9917e --- /dev/null +++ b/apps/admin-dashboard/app/routes/_dashboard.companies.create.tsx @@ -0,0 +1,140 @@ +import { + type ActionFunctionArgs, + data, + Form, + type LoaderFunctionArgs, + redirect, + useActionData, + useLoaderData, +} from 'react-router'; + +import { createCompany, isApifyConfigured } from '@oyster/core/employment/server'; +import { AdminCreateCompanyInput } from '@oyster/core/employment/types'; +import { + CompanyDomainField, + CompanyImageUrlField, + CompanyLinkedInSlugField, + CompanyNameField, + CompanyResumeBooksField, +} from '@oyster/core/employment/ui'; +import { listResumeBooks, updateCompanyResumeBookSponsorships } from '@oyster/core/resume-books'; +import { Button, ErrorMessage, getErrors, Modal, Text, validateForm } from '@oyster/ui'; + +import { Route } from '@/shared/constants'; +import { + commitSession, + ensureUserAuthenticated, + toast, +} from '@/shared/session.server'; + +export async function loader({ request }: LoaderFunctionArgs) { + await ensureUserAuthenticated(request); + + const resumeBooks = await listResumeBooks({ + select: ['id', 'name'], + }); + + return { + apifyConfigured: isApifyConfigured(), + resumeBooks, + }; +} + +export async function action({ request }: ActionFunctionArgs) { + const session = await ensureUserAuthenticated(request); + + const result = await validateForm(request, AdminCreateCompanyInput); + + if (!result.ok) { + return data(result, { status: 400 }); + } + + const { resumeBookIds, ...companyInput } = result.data; + + if ( + !companyInput.linkedinSlug && + (!companyInput.name || !companyInput.domain) + ) { + return data( + { + errors: { + linkedinSlug: + 'Provide a LinkedIn company slug or enter a name and domain manually.', + }, + }, + { status: 400 } + ); + } + + const createResult = await createCompany(companyInput); + + if (!createResult.ok) { + return data({ error: createResult.error }, { status: createResult.code }); + } + + if (resumeBookIds.length) { + const sponsorshipResult = await updateCompanyResumeBookSponsorships({ + companyId: createResult.data.id, + resumeBookIds, + }); + + if (!sponsorshipResult.ok) { + return data( + { error: sponsorshipResult.error }, + { status: sponsorshipResult.code } + ); + } + } + + toast(session, { + message: 'Created company.', + type: 'success', + }); + + const url = new URL(request.url); + url.pathname = Route['/companies']; + + return redirect(url.toString(), { + headers: { + 'Set-Cookie': await commitSession(session), + }, + }); +} + +export default function CreateCompanyPage() { + const { apifyConfigured, resumeBooks } = useLoaderData(); + const { error, errors } = getErrors(useActionData()); + + return ( + + + Create Company + + + +
+ {!apifyConfigured && ( + + Apify is not configured, so LinkedIn sync is unavailable. Enter the + company details manually below. + + )} + + + + + + + + {error} + + + Create + + +
+ ); +} diff --git a/apps/admin-dashboard/app/routes/_dashboard.companies.tsx b/apps/admin-dashboard/app/routes/_dashboard.companies.tsx new file mode 100644 index 000000000..97621c81e --- /dev/null +++ b/apps/admin-dashboard/app/routes/_dashboard.companies.tsx @@ -0,0 +1,234 @@ +import { sql } from 'kysely'; +import { Edit, Menu, Plus, Trash } from 'react-feather'; +import { + useSearchParams as _useSearchParams, + generatePath, + Link, + type LoaderFunctionArgs, + Outlet, + useLoaderData, +} from 'react-router'; + +import { ListSearchParams } from '@oyster/core/admin-dashboard/ui'; +import { db } from '@oyster/db'; +import { + Dashboard, + Dropdown, + IconButton, + Pagination, + type SerializeFrom, + Table, + type TableColumnProps, + useSearchParams, +} from '@oyster/ui'; + +import { Route } from '@/shared/constants'; +import { ensureUserAuthenticated } from '@/shared/session.server'; + +export async function loader({ request }: LoaderFunctionArgs) { + await ensureUserAuthenticated(request); + + const url = new URL(request.url); + + const searchParams = ListSearchParams.parse( + Object.fromEntries(url.searchParams) + ); + + const { companies, totalCompanies } = await listCompanies(searchParams); + + return { + companies, + totalCompanies, + }; +} + +async function listCompanies({ limit, page, search }: ListSearchParams) { + const query = db.selectFrom('companies').$if(!!search, (qb) => { + return qb + .where(sql`similarity(name, ${search}) > 0.15`) + .where(sql`word_similarity(name, ${search}) > 0.15`); + }); + + const [rows, countResult] = await Promise.all([ + query + .select(['companies.domain', 'companies.id', 'companies.imageUrl', 'companies.name']) + .select((eb) => { + return eb + .selectFrom('resumeBookSponsors') + .select(eb.fn.countAll().as('count')) + .whereRef('resumeBookSponsors.companyId', '=', 'companies.id') + .as('sponsorships'); + }) + .$if(!search, (qb) => { + return qb.orderBy('companies.name', 'asc'); + }) + .$if(!!search, (qb) => { + return qb.orderBy(sql`similarity(name, ${search})`, 'desc'); + }) + .limit(limit) + .offset((page - 1) * limit) + .execute(), + + query + .select((eb) => eb.fn.countAll().as('count')) + .executeTakeFirstOrThrow(), + ]); + + return { + companies: rows, + totalCompanies: parseInt(countResult.count), + }; +} + +export default function CompaniesPage() { + return ( + <> + Companies + + + + + + + + + + + ); +} + +function CompaniesActionDropdown() { + return ( + + + } + shape="square" + /> + + + + + + + Create Company + + + + + + ); +} + +type CompanyInView = SerializeFrom['companies'][number]; + +function CompaniesTable() { + const { companies } = useLoaderData(); + + const columns: TableColumnProps[] = [ + { + displayName: 'Logo', + size: '80', + render: (company) => { + if (!company.imageUrl) { + return ( +
+ N/A +
+ ); + } + + return ( + {company.name} + ); + }, + }, + { + displayName: 'Name', + size: '320', + render: (company) => company.name, + }, + { + displayName: 'Domain', + size: '240', + render: (company) => company.domain, + }, + { + displayName: 'Resume Books', + size: '160', + render: (company) => Number(company.sponsorships), + }, + { + size: '48', + sticky: true, + render: (company) => , + }, + ]; + + return ( + + ); +} + +function CompaniesPagination() { + const [searchParams] = useSearchParams(ListSearchParams); + + const { companies, totalCompanies } = useLoaderData(); + + return ( + + ); +} + +function CompaniesTableDropdown({ id }: CompanyInView) { + const [searchParams] = _useSearchParams(); + + return ( + + + + + + Edit Company + + + + + + Delete Company + + + + + + + + ); +} diff --git a/apps/admin-dashboard/app/routes/_dashboard.resume-books.$id.delete.tsx b/apps/admin-dashboard/app/routes/_dashboard.resume-books.$id.delete.tsx new file mode 100644 index 000000000..4a398d15a --- /dev/null +++ b/apps/admin-dashboard/app/routes/_dashboard.resume-books.$id.delete.tsx @@ -0,0 +1,84 @@ +import { + type ActionFunctionArgs, + data, + Form, + type LoaderFunctionArgs, + redirect, + useActionData, + useLoaderData, +} from 'react-router'; + +import { deleteResumeBook, getResumeBook } from '@oyster/core/resume-books'; +import { Button, ErrorMessage, getErrors, Modal } from '@oyster/ui'; + +import { Route } from '@/shared/constants'; +import { + commitSession, + ensureUserAuthenticated, + toast, +} from '@/shared/session.server'; + +export async function loader({ params, request }: LoaderFunctionArgs) { + await ensureUserAuthenticated(request); + + const resumeBook = await getResumeBook({ + select: ['name'], + where: { id: params.id as string }, + }); + + if (!resumeBook) { + throw new Response(null, { status: 404 }); + } + + return { + resumeBook, + }; +} + +export async function action({ params, request }: ActionFunctionArgs) { + const session = await ensureUserAuthenticated(request); + + const result = await deleteResumeBook(params.id as string); + + if (!result.ok) { + return data({ error: result.error }, { status: result.code }); + } + + toast(session, { + message: 'Deleted resume book.', + type: 'success', + }); + + return redirect(Route['/resume-books'], { + headers: { + 'Set-Cookie': await commitSession(session), + }, + }); +} + +export default function DeleteResumeBookModal() { + const { resumeBook } = useLoaderData(); + const { error } = getErrors(useActionData()); + + return ( + + + Delete Resume Book: {resumeBook.name} + + + + + Are you sure you want to delete this resume book? This action cannot be + undone. Resume books with submissions cannot be deleted. + + +
+ {error} + + + Delete + + +
+ ); +} 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 97e3893ba..6de9fe200 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 @@ -9,7 +9,11 @@ import { useLoaderData, } from 'react-router'; -import { getResumeBook, updateResumeBook } from '@oyster/core/resume-books'; +import { + getResumeBook, + listResumeBookSponsors, + updateResumeBook, +} from '@oyster/core/resume-books'; import { RESUME_BOOK_TIMEZONE, UpdateResumeBookInput, @@ -18,6 +22,7 @@ import { ResumeBookEndDateField, ResumeBookHiddenField, ResumeBookNameField, + ResumeBookSponsorsField, ResumeBookStartDateField, } from '@oyster/core/resume-books/ui'; import { Button, getErrors, Modal, validateForm } from '@oyster/ui'; @@ -32,17 +37,23 @@ import { export async function loader({ params, request }: LoaderFunctionArgs) { await ensureUserAuthenticated(request); - const resumeBook = await getResumeBook({ - select: ['endDate', 'hidden', 'name', 'startDate'], - where: { id: params.id as string }, - }); + const resumeBookId = params.id as string; + + const [resumeBook, sponsors] = await Promise.all([ + getResumeBook({ + select: ['endDate', 'hidden', 'name', 'startDate'], + where: { id: resumeBookId }, + }), + + listResumeBookSponsors({ + where: { resumeBookId }, + }), + ]); if (!resumeBook) { throw new Response(null, { status: 404 }); } - // We need to format the dates so that they respect the - // format. const format = 'YYYY-MM-DD'; const tz = RESUME_BOOK_TIMEZONE; @@ -50,6 +61,12 @@ export async function loader({ params, request }: LoaderFunctionArgs) { endDate: dayjs(resumeBook.endDate).tz(tz).format(format), hidden: resumeBook.hidden, name: resumeBook.name, + sponsors: sponsors.map((sponsor) => { + return { + id: sponsor.id, + name: sponsor.name, + }; + }), startDate: dayjs(resumeBook.startDate).tz(tz).format(format), }; } @@ -66,11 +83,15 @@ export async function action({ params, request }: ActionFunctionArgs) { return data(result, { status: 400 }); } - await updateResumeBook({ + const updateResult = await updateResumeBook({ ...result.data, id: params.id as string, }); + if (!updateResult.ok) { + return data({ error: updateResult.error }, { status: updateResult.code }); + } + toast(session, { message: 'Updated resume book.', type: 'success', @@ -84,8 +105,9 @@ export async function action({ params, request }: ActionFunctionArgs) { } export default function EditResumeBookModal() { - const { endDate, hidden, name, startDate } = useLoaderData(); - const { errors } = getErrors(useActionData()); + const { endDate, hidden, name, sponsors, startDate } = + useLoaderData(); + const { error, errors } = getErrors(useActionData()); return ( @@ -101,6 +123,10 @@ export default function EditResumeBookModal() { error={errors.startDate} /> + Edit 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 fc3c2c9e1..e03a1b7b8 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.resume-books.create.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.resume-books.create.tsx @@ -1,4 +1,3 @@ -import { useEffect } from 'react'; import { type ActionFunctionArgs, data, @@ -6,7 +5,6 @@ import { type LoaderFunctionArgs, redirect, useActionData, - useFetcher, } from 'react-router'; import { createResumeBook } from '@oyster/core/resume-books'; @@ -15,23 +13,11 @@ import { ResumeBookEndDateField, ResumeBookHiddenField, ResumeBookNameField, + ResumeBookSponsorsField, ResumeBookStartDateField, } from '@oyster/core/resume-books/ui'; -import { - Button, - ComboboxPopover, - Field, - getErrors, - Modal, - MultiCombobox, - MultiComboboxDisplay, - MultiComboboxItem, - MultiComboboxSearch, - MultiComboboxValues, - validateForm, -} from '@oyster/ui'; +import { Button, getErrors, Modal, validateForm } from '@oyster/ui'; -import { type SearchCompaniesResult } from '@/routes/api.companies.search'; import { Route } from '@/shared/constants'; import { commitSession, @@ -75,8 +61,6 @@ export async function action({ request }: ActionFunctionArgs) { }); } -const keys = CreateResumeBookInput.keyof().enum; - export default function CreateResumeBookModal() { const { errors } = getErrors(useActionData()); @@ -91,7 +75,7 @@ export default function CreateResumeBookModal() { - + Create @@ -100,58 +84,3 @@ export default function CreateResumeBookModal() { ); } - -function SponsorsField() { - const fetcher = useFetcher(); - const { errors } = getErrors(useActionData()); - - useEffect(() => { - fetcher.load('/api/companies/search'); - }, []); - - const companies = fetcher.data?.companies || []; - - return ( - - - - - { - fetcher.submit( - { search: e.currentTarget.value }, - { - action: '/api/companies/search', - method: 'get', - } - ); - }} - /> - - - -
    - {companies.map((company) => { - return ( - - {company.name} - - ); - })} -
-
-
-
- ); -} diff --git a/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx b/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx index 5f642b576..c813cb9f6 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx @@ -1,5 +1,5 @@ import dayjs from 'dayjs'; -import { Clipboard, Edit, ExternalLink, Menu, Plus } from 'react-feather'; +import { Clipboard, Edit, ExternalLink, Menu, Plus, Trash } from 'react-feather'; import { generatePath, Link, @@ -175,6 +175,7 @@ function ResumeBookDropdown({ googleDriveUri, id, resumeBookUri, + submissions, }: ResumeBookInView) { const toast = useToast(); @@ -214,6 +215,17 @@ function ResumeBookDropdown({ Go to Google Drive + + {Number(submissions) === 0 && ( + + + Delete Resume Book + + + )} diff --git a/apps/admin-dashboard/app/routes/_dashboard.tsx b/apps/admin-dashboard/app/routes/_dashboard.tsx index 439e884f8..8fe3c7618 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.tsx @@ -1,5 +1,6 @@ import { BookOpen, + Briefcase, Calendar, Gift, Layers, @@ -85,6 +86,11 @@ export default function DashboardLayout() { label="Resume Books" pathname={Route['/resume-books']} /> + } + label="Companies" + pathname={Route['/companies']} + /> } label="Onboarding" diff --git a/apps/admin-dashboard/app/routes/api.companies.search.tsx b/apps/admin-dashboard/app/routes/api.companies.search.tsx index c6a208d98..b014c02fc 100644 --- a/apps/admin-dashboard/app/routes/api.companies.search.tsx +++ b/apps/admin-dashboard/app/routes/api.companies.search.tsx @@ -19,7 +19,7 @@ export async function loader({ request }: LoaderFunctionArgs) { limit: 50, page: 1, }, - select: ['companies.id', 'companies.name'], + select: ['companies.domain', 'companies.id', 'companies.imageUrl', 'companies.name'], where: { search }, }); diff --git a/apps/admin-dashboard/app/shared/constants.ts b/apps/admin-dashboard/app/shared/constants.ts index a1dd71ac4..05412d160 100644 --- a/apps/admin-dashboard/app/shared/constants.ts +++ b/apps/admin-dashboard/app/shared/constants.ts @@ -33,8 +33,13 @@ const ROUTES = [ '/onboarding-sessions', '/onboarding-sessions/upload', '/onboarding-sessions/:id/add-attendees', + '/companies', + '/companies/create', + '/companies/:id/delete', + '/companies/:id/edit', '/resume-books', '/resume-books/create', + '/resume-books/:id/delete', '/resume-books/:id/edit', '/schools', '/schools/create', diff --git a/bun.lock b/bun.lock index 2c32f9b4d..8b867586c 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "@oyster/root", diff --git a/packages/core/package.json b/packages/core/package.json index acd8ff5fc..ca0d3f309 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -22,6 +22,8 @@ "./education/ui": "./src/modules/education/education.ui.tsx", "./employment": "./src/modules/employment/index.ts", "./employment/server": "./src/modules/employment/index.server.ts", + "./employment/types": "./src/modules/employment/employment.types.ts", + "./employment/ui": "./src/modules/employment/ui/companies.ui.tsx", "./events": "./src/modules/events/events.ts", "./events/attendees": "./src/modules/events/event-attendees.ts", "./events/registrations": "./src/modules/events/event-registrations.ts", diff --git a/packages/core/src/modules/apify.ts b/packages/core/src/modules/apify.ts index 1688d5f35..541e1d36e 100644 --- a/packages/core/src/modules/apify.ts +++ b/packages/core/src/modules/apify.ts @@ -8,6 +8,10 @@ import { extractZodErrorMessage } from '@/shared/utils/zod'; const APIFY_API_TOKEN = process.env.APIFY_API_TOKEN as string; +export function isApifyConfigured() { + return !!APIFY_API_TOKEN; +} + // Rate Limiter /** @@ -69,6 +73,14 @@ type StartRunInput = { * @returns Promise resolving to the dataset ID. */ async function startRun({ actorId, body }: StartRunInput): Promise { + if (!APIFY_API_TOKEN) { + throw new ColorStackError() + .withMessage( + 'Apify is not configured. Set APIFY_API_TOKEN in your environment.' + ) + .report(); + } + const url = new URL(`https://api.apify.com/v2/acts/${actorId}/runs`); url.searchParams.set('token', APIFY_API_TOKEN); diff --git a/packages/core/src/modules/employment/employment.types.ts b/packages/core/src/modules/employment/employment.types.ts index 442cb80a7..49c80f5b8 100644 --- a/packages/core/src/modules/employment/employment.types.ts +++ b/packages/core/src/modules/employment/employment.types.ts @@ -11,6 +11,7 @@ import { } from '@oyster/types'; import { ListSearchParams } from '@/shared/types'; +import { parseLinkedInCompanySlug } from '@/modules/employment/use-cases/fetch-company-from-linkedin'; // Enums @@ -143,3 +144,78 @@ export type DeleteWorkExperienceInput = z.infer< >; export type EditCompanyReviewInput = z.infer; export type UpvoteCompanyReviewInput = z.infer; + +export const CreateCompanyInput = z.object({ + linkedinSlug: z + .string() + .trim() + .min(1) + .transform(parseLinkedInCompanySlug), +}); + +export const UpdateCompanyInput = z.object({ + domain: z.string().trim().min(1), + id: Company.shape.id, + linkedinSlug: z + .string() + .trim() + .optional() + .transform((value) => { + return value ? parseLinkedInCompanySlug(value) : undefined; + }), + name: z.string().trim().min(1), +}); + +export const AdminCreateCompanyInput = z.object({ + domain: z.string().trim().optional(), + imageUrl: z + .string() + .trim() + .optional() + .transform((value) => value || undefined), + linkedinSlug: z + .string() + .trim() + .optional() + .transform((value) => { + return value ? parseLinkedInCompanySlug(value) : undefined; + }), + name: z.string().trim().optional(), + resumeBookIds: z + .string() + .trim() + .optional() + .transform((value) => { + return value ? value.split(',').filter(Boolean) : []; + }), +}); + +export const AdminUpdateCompanyInput = UpdateCompanyInput.omit({ + id: true, +}).extend({ + imageUrl: z + .string() + .trim() + .optional() + .transform((value) => { + if (!value) { + return undefined; + } + + const result = z.string().url().safeParse(value); + + return result.success ? result.data : value; + }), + resumeBookIds: z + .string() + .trim() + .optional() + .transform((value) => { + return value ? value.split(',').filter(Boolean) : []; + }), +}); + +export type CreateCompanyInput = z.infer; +export type AdminCreateCompanyInput = z.infer; +export type AdminUpdateCompanyInput = z.infer; +export type UpdateCompanyInput = z.infer; diff --git a/packages/core/src/modules/employment/index.server.ts b/packages/core/src/modules/employment/index.server.ts index eea846463..83966c245 100644 --- a/packages/core/src/modules/employment/index.server.ts +++ b/packages/core/src/modules/employment/index.server.ts @@ -4,7 +4,11 @@ export { hasReviewAccess } from './queries/has-review-access'; export { listCompanies } from './queries/list-companies'; export { listCompanyEmployees } from './queries/list-company-employees'; export { listCompanyReviews } from './queries/list-company-reviews'; +export { isApifyConfigured } from '@/modules/apify'; export { addCompanyReview } from './use-cases/add-company-review'; +export { createCompany } from './use-cases/create-company'; +export { deleteCompany } from './use-cases/delete-company'; export { editCompanyReview } from './use-cases/edit-company-review'; +export { updateCompany, syncCompanyFromLinkedIn } from './use-cases/update-company'; export { undoUpvoteCompanyReview } from './use-cases/undo-upvote-company-review'; export { upvoteCompanyReview } from './use-cases/upvote-company-review'; diff --git a/packages/core/src/modules/employment/ui/companies.ui.tsx b/packages/core/src/modules/employment/ui/companies.ui.tsx new file mode 100644 index 000000000..484e5aaaa --- /dev/null +++ b/packages/core/src/modules/employment/ui/companies.ui.tsx @@ -0,0 +1,186 @@ +import { + ComboboxPopover, + Field, + type FieldProps, + Input, + MultiCombobox, + MultiComboboxDisplay, + MultiComboboxItem, + MultiComboboxSearch, + MultiComboboxValues, +} from '@oyster/ui'; + +const keys = { + domain: 'domain', + imageUrl: 'imageUrl', + linkedinSlug: 'linkedinSlug', + name: 'name', +} as const; + +export function CompanyLinkedInSlugField({ + defaultValue, + error, + required = false, +}: Omit, 'name'> & { required?: boolean }) { + return ( + + + + ); +} + +export function CompanyNameField({ + defaultValue, + error, +}: Omit, 'name'>) { + return ( + + + + ); +} + +export function CompanyDomainField({ + defaultValue, + error, +}: Omit, 'name'>) { + return ( + + + + ); +} + +type CompanyLogoPreviewProps = { + imageUrl?: string | null; +}; + +export function CompanyLogoPreview({ imageUrl }: CompanyLogoPreviewProps) { + if (!imageUrl) { + return null; + } + + return ( + +
+ Current company logo +
+
+ ); +} + +export function CompanyImageUrlField({ + defaultValue, + error, +}: Omit, 'name'>) { + return ( + + + + ); +} + +type CompanyResumeBooksFieldProps = { + defaultValue?: string[]; + error?: FieldProps['error']; + resumeBooks: { id: string; name: string }[]; +}; + +export function CompanyResumeBooksField({ + defaultValue = [], + error, + resumeBooks, +}: CompanyResumeBooksFieldProps) { + const defaultValues = defaultValue + .map((resumeBookId) => { + const resumeBook = resumeBooks.find((book) => book.id === resumeBookId); + + if (!resumeBook) { + return null; + } + + return { + label: resumeBook.name, + value: resumeBook.id, + }; + }) + .filter(Boolean) as { label: string; value: string }[]; + + return ( + + + + + + + + +
    + {resumeBooks.map((resumeBook) => { + return ( + + {resumeBook.name} + + ); + })} +
+
+
+
+ ); +} diff --git a/packages/core/src/modules/employment/use-cases/create-company.ts b/packages/core/src/modules/employment/use-cases/create-company.ts new file mode 100644 index 000000000..3e59907fc --- /dev/null +++ b/packages/core/src/modules/employment/use-cases/create-company.ts @@ -0,0 +1,113 @@ +import { db } from '@oyster/db'; +import { id } from '@oyster/utils'; + +import { type AdminCreateCompanyInput } from '@/modules/employment/employment.types'; +import { + fetchCompanyFromLinkedIn, + getDomainFromCompanyWebsite, +} from '@/modules/employment/use-cases/fetch-company-from-linkedin'; +import { isApifyConfigured } from '@/modules/apify'; +import { fail, success, type Result } from '@/shared/utils/core'; + +export async function createCompany({ + domain, + imageUrl, + linkedinSlug, + name, +}: Omit): Promise< + Result<{ id: string }, object> +> { + if (linkedinSlug && isApifyConfigured()) { + try { + return await createCompanyFromLinkedIn(linkedinSlug); + } catch { + if (!name || !domain) { + return fail<{ id: string }, object>({ + code: 502, + error: + 'Failed to fetch company from LinkedIn. Enter a name and domain manually, or try again later.', + }); + } + } + } + + if (linkedinSlug && !isApifyConfigured() && (!name || !domain)) { + return fail<{ id: string }, object>({ + code: 400, + error: + 'Apify is not configured. Enter a company name and domain manually, or set APIFY_API_TOKEN.', + }); + } + + if (!name || !domain) { + return fail<{ id: string }, object>({ + code: 400, + error: 'Company name and domain are required.', + }); + } + + const companyId = id(); + + await db + .insertInto('companies') + .values({ + domain, + id: companyId, + ...(imageUrl && { imageUrl }), + ...(linkedinSlug && { linkedinSlug }), + name, + }) + .execute(); + + return success<{ id: string }, object>({ id: companyId }); +} + +async function createCompanyFromLinkedIn( + linkedinSlug: string +): Promise> { + const existingCompany = await db + .selectFrom('companies') + .select(['id', 'name']) + .where((eb) => { + return eb.or([ + eb('linkedinSlug', '=', linkedinSlug), + eb('linkedinId', '=', linkedinSlug), + ]); + }) + .executeTakeFirst(); + + if (existingCompany) { + return fail<{ id: string }, object>({ + code: 409, + error: `${existingCompany.name} already exists in the database.`, + }); + } + + const companyFromLinkedIn = await fetchCompanyFromLinkedIn(linkedinSlug); + + if (!companyFromLinkedIn) { + return fail<{ id: string }, object>({ + code: 404, + error: 'Company not found on LinkedIn.', + }); + } + + const companyId = id(); + + await db + .insertInto('companies') + .values({ + description: companyFromLinkedIn.description, + domain: companyFromLinkedIn.website + ? getDomainFromCompanyWebsite(companyFromLinkedIn.website) + : undefined, + id: companyId, + imageUrl: companyFromLinkedIn.logo, + linkedinId: companyFromLinkedIn.id, + linkedinSlug: companyFromLinkedIn.universalName, + name: companyFromLinkedIn.name, + }) + .execute(); + + return success<{ id: string }, object>({ id: companyId }); +} diff --git a/packages/core/src/modules/employment/use-cases/delete-company.ts b/packages/core/src/modules/employment/use-cases/delete-company.ts new file mode 100644 index 000000000..b8c45a930 --- /dev/null +++ b/packages/core/src/modules/employment/use-cases/delete-company.ts @@ -0,0 +1,96 @@ +import { db } from '@oyster/db'; + +import { fail, success } from '@/shared/utils/core'; + +export async function deleteCompany(id: string) { + const company = await db + .selectFrom('companies') + .select(['id', 'name']) + .where('id', '=', id) + .executeTakeFirst(); + + if (!company) { + return fail({ + code: 404, + error: 'Company not found.', + }); + } + + const [ + workExperiences, + fullTimeOffers, + internshipOffers, + preferredSponsorSubmissions, + ] = await Promise.all([ + db + .selectFrom('workExperiences') + .select((eb) => eb.fn.countAll().as('count')) + .where('companyId', '=', id) + .where('deletedAt', 'is', null) + .executeTakeFirst(), + + db + .selectFrom('fullTimeOffers') + .select((eb) => eb.fn.countAll().as('count')) + .where('companyId', '=', id) + .executeTakeFirst(), + + db + .selectFrom('internshipOffers') + .select((eb) => eb.fn.countAll().as('count')) + .where('companyId', '=', id) + .executeTakeFirst(), + + db + .selectFrom('resumeBookSubmissions') + .select((eb) => eb.fn.countAll().as('count')) + .where((eb) => { + return eb.or([ + eb('preferredCompany1', '=', id), + eb('preferredCompany2', '=', id), + eb('preferredCompany3', '=', id), + ]); + }) + .executeTakeFirst(), + ]); + + if (Number(workExperiences?.count) > 0) { + return fail({ + code: 409, + error: 'Cannot delete a company that has member work experiences.', + }); + } + + if (Number(fullTimeOffers?.count) > 0) { + return fail({ + code: 409, + error: 'Cannot delete a company that has full-time job offers.', + }); + } + + if (Number(internshipOffers?.count) > 0) { + return fail({ + code: 409, + error: 'Cannot delete a company that has internship offers.', + }); + } + + if (Number(preferredSponsorSubmissions?.count) > 0) { + return fail({ + code: 409, + error: + 'Cannot delete a company that members have selected as a preferred resume book sponsor.', + }); + } + + await db.transaction().execute(async (trx) => { + await trx + .deleteFrom('resumeBookSponsors') + .where('companyId', '=', id) + .execute(); + + await trx.deleteFrom('companies').where('id', '=', id).execute(); + }); + + return success({}); +} diff --git a/packages/core/src/modules/employment/use-cases/fetch-company-from-linkedin.ts b/packages/core/src/modules/employment/use-cases/fetch-company-from-linkedin.ts new file mode 100644 index 000000000..ac476a28d --- /dev/null +++ b/packages/core/src/modules/employment/use-cases/fetch-company-from-linkedin.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; + +import { withCache } from '@/infrastructure/redis'; +import { runActor } from '@/modules/apify'; +import { ColorStackError } from '@/shared/errors'; + +const LinkedInCompany = z.object({ + description: z.string().nullish(), + id: z.string(), + logo: z.string().url().optional(), + name: z.string(), + universalName: z.string(), + website: z.string().url().nullish(), +}); + +export type LinkedInCompany = z.infer; + +export function parseLinkedInCompanySlug(input: string) { + const trimmed = input.trim(); + + const match = trimmed.match(/linkedin\.com\/company\/([^/?#]+)/i); + + if (match?.[1]) { + return match[1]; + } + + return trimmed.replace(/^@/, ''); +} + +export async function fetchCompanyFromLinkedIn( + companyNameOrLinkedInSlug: string +): Promise { + const linkedInSlug = parseLinkedInCompanySlug(companyNameOrLinkedInSlug); + + const apifyResult = await withCache( + `harvestapi~linkedin-company:v2:${linkedInSlug}`, + 60 * 60 * 24 * 30, + async () => { + return runActor({ + actorId: 'harvestapi~linkedin-company', + body: { + companies: [`https://www.linkedin.com/company/${linkedInSlug}`], + }, + }); + } + ); + + const parseResult = z.array(LinkedInCompany).safeParse(apifyResult); + + if (!parseResult.success) { + throw new ColorStackError() + .withMessage('Failed to parse company from LinkedIn.') + .withContext({ error: JSON.stringify(parseResult.error, null, 2) }) + .report(); + } + + const [companyFromLinkedIn] = parseResult.data; + + return companyFromLinkedIn || null; +} + +export function getDomainFromCompanyWebsite(website: string) { + const hostname = new URL(website).hostname; + + return hostname.split('.').slice(-2).join('.'); +} diff --git a/packages/core/src/modules/employment/use-cases/save-company-if-necessary.ts b/packages/core/src/modules/employment/use-cases/save-company-if-necessary.ts index 142f78825..73f1053ab 100644 --- a/packages/core/src/modules/employment/use-cases/save-company-if-necessary.ts +++ b/packages/core/src/modules/employment/use-cases/save-company-if-necessary.ts @@ -1,27 +1,20 @@ import { type Transaction } from 'kysely'; -import { z } from 'zod'; import { type DB } from '@oyster/db'; import { id } from '@oyster/utils'; -import { withCache } from '@/infrastructure/redis'; import { deleteObject, putObject, R2_PUBLIC_BUCKET_NAME, R2_PUBLIC_BUCKET_URL, } from '@/infrastructure/s3'; -import { runActor } from '@/modules/apify'; -import { ColorStackError } from '@/shared/errors'; - -const Company = z.object({ - description: z.string().nullish(), - id: z.string(), - logo: z.string().url().optional(), - name: z.string(), - universalName: z.string(), - website: z.string().url().nullish(), -}); +import { + fetchCompanyFromLinkedIn, + getDomainFromCompanyWebsite, +} from '@/modules/employment/use-cases/fetch-company-from-linkedin'; + +const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID || ''; /** * Saves a company in the database, if it does not already exist. @@ -59,49 +52,21 @@ export async function saveCompanyIfNecessary( return existingCompany.id; } - const apifyResult = await withCache( - `harvestapi~linkedin-company:v2:${companyNameOrLinkedInId}`, - 60 * 60 * 24 * 30, - async () => { - return runActor({ - actorId: 'harvestapi~linkedin-company', - body: { - companies: [ - `https://www.linkedin.com/company/${companyNameOrLinkedInId}`, - ], - }, - }); - } + const companyFromLinkedIn = await fetchCompanyFromLinkedIn( + companyNameOrLinkedInId ); - const parseResult = z.array(Company).safeParse(apifyResult); - - if (!parseResult.success) { - throw new ColorStackError() - .withMessage('Failed to parse company from Apify.') - .withContext({ error: JSON.stringify(parseResult.error, null, 2) }) - .report(); - } - - const [companyFromLinkedIn] = parseResult.data; - if (!companyFromLinkedIn) { return null; } - let domain = undefined; - - if (companyFromLinkedIn.website) { - const hostname = new URL(companyFromLinkedIn.website).hostname; - - domain = getDomainFromHostname(hostname); - } - const { id: companyId, logoKey: existingLogoKey } = await trx .insertInto('companies') .values({ description: companyFromLinkedIn.description, - domain, + domain: companyFromLinkedIn.website + ? getDomainFromCompanyWebsite(companyFromLinkedIn.website) + : undefined, id: id(), linkedinId: companyFromLinkedIn.id, linkedinSlug: companyFromLinkedIn.universalName, @@ -120,32 +85,66 @@ export async function saveCompanyIfNecessary( .executeTakeFirstOrThrow(); if (companyFromLinkedIn.logo) { - const newLogoKey = await uploadLogo(companyFromLinkedIn.logo); - - if (newLogoKey) { - await trx - .updateTable('companies') - .set({ - imageUrl: `${R2_PUBLIC_BUCKET_URL}/${newLogoKey}`, - logoKey: newLogoKey, - }) - .where('id', '=', companyId) - .execute(); - - if (existingLogoKey) { - await deleteObject({ - bucket: R2_PUBLIC_BUCKET_NAME, - key: existingLogoKey, - }); - } - } + await setCompanyLogo({ + companyId, + existingLogoKey, + logoUrl: companyFromLinkedIn.logo, + trx, + }); } return companyId; } -function getDomainFromHostname(hostname: string) { - return hostname.split('.').slice(-2).join('.'); +type SetCompanyLogoInput = { + companyId: string; + existingLogoKey?: string | null; + logoUrl: string; + trx: Transaction; +}; + +async function setCompanyLogo({ + companyId, + existingLogoKey, + logoUrl, + trx, +}: SetCompanyLogoInput) { + const hasR2Credentials = + !!R2_ACCESS_KEY_ID && !!R2_PUBLIC_BUCKET_NAME && !!R2_PUBLIC_BUCKET_URL; + + if (hasR2Credentials) { + const newLogoKey = await uploadLogo(logoUrl); + + if (!newLogoKey) { + return; + } + + await trx + .updateTable('companies') + .set({ + imageUrl: `${R2_PUBLIC_BUCKET_URL}/${newLogoKey}`, + logoKey: newLogoKey, + }) + .where('id', '=', companyId) + .execute(); + + if (existingLogoKey) { + await deleteObject({ + bucket: R2_PUBLIC_BUCKET_NAME, + key: existingLogoKey, + }); + } + + return; + } + + await trx + .updateTable('companies') + .set({ + imageUrl: logoUrl, + }) + .where('id', '=', companyId) + .execute(); } /** diff --git a/packages/core/src/modules/employment/use-cases/update-company.ts b/packages/core/src/modules/employment/use-cases/update-company.ts new file mode 100644 index 000000000..d95886418 --- /dev/null +++ b/packages/core/src/modules/employment/use-cases/update-company.ts @@ -0,0 +1,125 @@ +import { db } from '@oyster/db'; + +import { type UpdateCompanyInput } from '@/modules/employment/employment.types'; +import { + fetchCompanyFromLinkedIn, + getDomainFromCompanyWebsite, +} from '@/modules/employment/use-cases/fetch-company-from-linkedin'; +import { isApifyConfigured } from '@/modules/apify'; +import { fail, success } from '@/shared/utils/core'; + +export async function updateCompany({ + domain, + id, + imageUrl, + linkedinSlug, + name, +}: UpdateCompanyInput & { imageUrl?: string }) { + const company = await db + .selectFrom('companies') + .select(['id']) + .where('id', '=', id) + .executeTakeFirst(); + + if (!company) { + return fail({ + code: 404, + error: 'Company not found.', + }); + } + + let syncedImageUrl: string | undefined; + let description: string | null | undefined; + let linkedinId: string | undefined; + let resolvedLinkedInSlug: string | undefined; + + if (linkedinSlug && isApifyConfigured()) { + try { + const companyFromLinkedIn = await fetchCompanyFromLinkedIn(linkedinSlug); + + if (!companyFromLinkedIn) { + return fail({ + code: 404, + error: 'Company not found on LinkedIn.', + }); + } + + syncedImageUrl = companyFromLinkedIn.logo; + description = companyFromLinkedIn.description; + linkedinId = companyFromLinkedIn.id; + resolvedLinkedInSlug = companyFromLinkedIn.universalName; + } catch { + return fail({ + code: 502, + error: + 'Failed to sync from LinkedIn. Save your changes without updating the LinkedIn slug, or set APIFY_API_TOKEN.', + }); + } + } + + await db + .updateTable('companies') + .set({ + description, + domain, + linkedinId, + linkedinSlug: resolvedLinkedInSlug ?? linkedinSlug, + name, + ...(syncedImageUrl && { imageUrl: syncedImageUrl }), + ...(imageUrl && { imageUrl }), + }) + .where('id', '=', id) + .execute(); + + return success({}); +} + +export async function syncCompanyFromLinkedIn(id: string) { + const company = await db + .selectFrom('companies') + .select(['id', 'linkedinSlug']) + .where('id', '=', id) + .executeTakeFirst(); + + if (!company) { + return fail({ + code: 404, + error: 'Company not found.', + }); + } + + if (!company.linkedinSlug) { + return fail({ + code: 400, + error: 'This company does not have a LinkedIn slug to sync from.', + }); + } + + const companyFromLinkedIn = await fetchCompanyFromLinkedIn( + company.linkedinSlug + ); + + if (!companyFromLinkedIn) { + return fail({ + code: 404, + error: 'Company not found on LinkedIn.', + }); + } + + await db + .updateTable('companies') + .set({ + description: companyFromLinkedIn.description, + domain: companyFromLinkedIn.website + ? getDomainFromCompanyWebsite(companyFromLinkedIn.website) + : undefined, + imageUrl: companyFromLinkedIn.logo, + linkedinId: companyFromLinkedIn.id, + linkedinSlug: companyFromLinkedIn.universalName, + name: companyFromLinkedIn.name, + }) + .where('id', '=', id) + .execute(); + + return success({}); +} diff --git a/packages/core/src/modules/resume-books/resume-books.ts b/packages/core/src/modules/resume-books/resume-books.ts index 422172c54..917218805 100644 --- a/packages/core/src/modules/resume-books/resume-books.ts +++ b/packages/core/src/modules/resume-books/resume-books.ts @@ -24,6 +24,8 @@ import { RESUME_BOOK_CODING_LANGUAGES, RESUME_BOOK_JOB_SEARCH_STATUSES, RESUME_BOOK_ROLES, + type AddResumeBookSponsorInput, + type RemoveResumeBookSponsorInput, type SubmitResumeInput, type UpdateResumeBookInput, } from '@/modules/resume-books/resume-books.types'; @@ -137,6 +139,239 @@ export async function listResumeBookSponsors({ return sponsors; } +/** + * Adds a company as a sponsor of an existing resume book. + */ +export async function addResumeBookSponsor({ + companyId, + resumeBookId, +}: AddResumeBookSponsorInput) { + const [resumeBook, company, existingSponsor] = await Promise.all([ + getResumeBook({ + select: ['id'], + where: { id: resumeBookId }, + }), + db + .selectFrom('companies') + .select(['id']) + .where('id', '=', companyId) + .executeTakeFirst(), + db + .selectFrom('resumeBookSponsors') + .select(['companyId']) + .where('companyId', '=', companyId) + .where('resumeBookId', '=', resumeBookId) + .executeTakeFirst(), + ]); + + if (!resumeBook) { + return fail({ + code: 404, + error: 'Resume book not found.', + }); + } + + if (!company) { + return fail({ + code: 404, + error: 'Company not found.', + }); + } + + if (existingSponsor) { + return fail({ + code: 409, + error: 'This company is already a sponsor of this resume book.', + }); + } + + await db + .insertInto('resumeBookSponsors') + .values({ + companyId, + resumeBookId, + }) + .execute(); + + return success({}); +} + +/** + * Removes a company as a sponsor from a resume book. This will fail if any + * members have already selected the company as a preferred sponsor. + */ +export async function removeResumeBookSponsor({ + companyId, + resumeBookId, +}: RemoveResumeBookSponsorInput) { + const [resumeBook, sponsor, submissions] = await Promise.all([ + getResumeBook({ + select: ['id'], + where: { id: resumeBookId }, + }), + db + .selectFrom('resumeBookSponsors') + .select(['companyId']) + .where('companyId', '=', companyId) + .where('resumeBookId', '=', resumeBookId) + .executeTakeFirst(), + db + .selectFrom('resumeBookSubmissions') + .select((eb) => { + return eb.fn.countAll().as('count'); + }) + .where('resumeBookId', '=', resumeBookId) + .where((eb) => { + return eb.or([ + eb('preferredCompany1', '=', companyId), + eb('preferredCompany2', '=', companyId), + eb('preferredCompany3', '=', companyId), + ]); + }) + .executeTakeFirst(), + ]); + + if (!resumeBook) { + return fail({ + code: 404, + error: 'Resume book not found.', + }); + } + + if (!sponsor) { + return fail({ + code: 404, + error: 'This company is not a sponsor of this resume book.', + }); + } + + if (Number(submissions?.count) > 0) { + return fail({ + code: 409, + error: + 'Cannot remove a sponsor that members have selected as a preferred company.', + }); + } + + await db + .deleteFrom('resumeBookSponsors') + .where('companyId', '=', companyId) + .where('resumeBookId', '=', resumeBookId) + .execute(); + + return success({}); +} + +/** + * Syncs which resume books a company sponsors by diffing the desired list + * against the current list. + */ +export async function updateCompanyResumeBookSponsorships({ + companyId, + resumeBookIds, +}: { + companyId: string; + resumeBookIds: string[]; +}) { + const currentSponsorships = await db + .selectFrom('resumeBookSponsors') + .select(['resumeBookId']) + .where('companyId', '=', companyId) + .execute(); + + const currentIds = new Set( + currentSponsorships.map((sponsorship) => sponsorship.resumeBookId) + ); + const desiredIds = new Set(resumeBookIds); + + const toAdd = resumeBookIds.filter((resumeBookId) => { + return !currentIds.has(resumeBookId); + }); + + const toRemove = [...currentIds].filter((resumeBookId) => { + return !desiredIds.has(resumeBookId); + }); + + for (const resumeBookId of toRemove) { + const result = await removeResumeBookSponsor({ + companyId, + resumeBookId, + }); + + if (!result.ok) { + return result; + } + } + + for (const resumeBookId of toAdd) { + const result = await addResumeBookSponsor({ + companyId, + resumeBookId, + }); + + if (!result.ok) { + return result; + } + } + + return success({}); +} + +/** + * Syncs which companies sponsor a resume book by diffing the desired list + * against the current list. + */ +export async function updateResumeBookSponsors({ + resumeBookId, + sponsors, +}: { + resumeBookId: string; + sponsors: string[]; +}) { + const currentSponsorships = await db + .selectFrom('resumeBookSponsors') + .select(['companyId']) + .where('resumeBookId', '=', resumeBookId) + .execute(); + + const currentIds = new Set( + currentSponsorships.map((sponsorship) => sponsorship.companyId) + ); + const desiredIds = new Set(sponsors); + + const toAdd = sponsors.filter((companyId) => { + return !currentIds.has(companyId); + }); + + const toRemove = [...currentIds].filter((companyId) => { + return !desiredIds.has(companyId); + }); + + for (const companyId of toRemove) { + const result = await removeResumeBookSponsor({ + companyId, + resumeBookId, + }); + + if (!result.ok) { + return result; + } + } + + for (const companyId of toAdd) { + const result = await addResumeBookSponsor({ + companyId, + resumeBookId, + }); + + if (!result.ok) { + return result; + } + } + + return success({}); +} + // Use Cases /** @@ -435,21 +670,84 @@ async function getResumeBookAirtableFields({ } /** - * 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. + * Deletes a resume book and its sponsors. Only allowed when the resume book + * has zero submissions. + */ +export async function deleteResumeBook(id: string) { + const resumeBook = await db + .selectFrom('resumeBooks') + .select([ + 'id', + (eb) => { + return eb + .selectFrom('resumeBookSubmissions') + .select((eb) => eb.fn.countAll().as('count')) + .whereRef( + 'resumeBookSubmissions.resumeBookId', + '=', + 'resumeBooks.id' + ) + .as('submissions'); + }, + ]) + .where('id', '=', id) + .executeTakeFirst(); + + if (!resumeBook) { + return fail({ + code: 404, + error: 'Resume book not found.', + }); + } + + if (Number(resumeBook.submissions) > 0) { + return fail({ + code: 409, + context: { id, submissions: resumeBook.submissions }, + error: 'Cannot delete a resume book that has submissions.', + }); + } + + await db.transaction().execute(async (trx) => { + await trx + .deleteFrom('completedActivities') + .where('resumeBookId', '=', id) + .execute(); + + await trx + .deleteFrom('resumeBookSponsors') + .where('resumeBookId', '=', id) + .execute(); + + await trx.deleteFrom('resumeBooks').where('id', '=', id).execute(); + }); + + return success({}); +} + +/** + * Updates the resume book information, including its sponsors. */ export async function updateResumeBook({ endDate, hidden, id, name, + sponsors, startDate, }: UpdateResumeBookInput) { + const resumeBook = await getResumeBook({ + select: ['id'], + where: { id }, + }); + + if (!resumeBook) { + return fail({ + code: 404, + error: 'Resume book not found.', + }); + } + await db.transaction().execute(async (trx) => { await trx .updateTable('resumeBooks') @@ -463,6 +761,15 @@ export async function updateResumeBook({ .execute(); }); + const sponsorshipResult = await updateResumeBookSponsors({ + resumeBookId: id, + sponsors, + }); + + if (!sponsorshipResult.ok) { + return sponsorshipResult; + } + return success({}); } diff --git a/packages/core/src/modules/resume-books/resume-books.types.ts b/packages/core/src/modules/resume-books/resume-books.types.ts index e46766c20..1658b060d 100644 --- a/packages/core/src/modules/resume-books/resume-books.types.ts +++ b/packages/core/src/modules/resume-books/resume-books.types.ts @@ -130,8 +130,25 @@ export const UpdateResumeBookInput = ResumeBook.pick({ id: true, name: true, startDate: true, +}).extend({ + sponsors: z + .string() + .trim() + .min(1) + .transform((value) => value.split(',').filter(Boolean)), +}); + +export const AddResumeBookSponsorInput = z.object({ + companyId: z.string().trim().min(1), + resumeBookId: z.string().trim().min(1), }); +export const RemoveResumeBookSponsorInput = AddResumeBookSponsorInput; + export type CreateResumeBookInput = z.infer; export type SubmitResumeInput = z.infer; export type UpdateResumeBookInput = z.infer; +export type AddResumeBookSponsorInput = z.infer; +export type RemoveResumeBookSponsorInput = z.infer< + typeof RemoveResumeBookSponsorInput +>; diff --git a/packages/core/src/modules/resume-books/resume-books.ui.tsx b/packages/core/src/modules/resume-books/resume-books.ui.tsx index 95ef8c5bb..54b76cdd1 100644 --- a/packages/core/src/modules/resume-books/resume-books.ui.tsx +++ b/packages/core/src/modules/resume-books/resume-books.ui.tsx @@ -1,4 +1,19 @@ -import { DatePicker, Field, type FieldProps, Input, Radio } from '@oyster/ui'; +import { useEffect } from 'react'; +import { useFetcher } from 'react-router'; + +import { + ComboboxPopover, + DatePicker, + Field, + type FieldProps, + Input, + MultiCombobox, + MultiComboboxDisplay, + MultiComboboxItem, + MultiComboboxSearch, + MultiComboboxValues, + Radio, +} from '@oyster/ui'; import { ResumeBook } from '@/modules/resume-books/resume-books.types'; @@ -107,3 +122,75 @@ export function ResumeBookStartDateField({ ); } + +type ResumeBookSponsor = { + id: string; + name: string; +}; + +type ResumeBookSponsorsFieldProps = Omit, 'name'> & { + defaultSponsors?: ResumeBookSponsor[]; +}; + +export function ResumeBookSponsorsField({ + defaultSponsors = [], + error, +}: ResumeBookSponsorsFieldProps) { + const fetcher = useFetcher<{ companies: ResumeBookSponsor[] }>(); + + useEffect(() => { + fetcher.load('/api/companies/search'); + }, []); + + const companies = fetcher.data?.companies || []; + const defaultValues = defaultSponsors.map((sponsor) => { + return { + label: sponsor.name, + value: sponsor.id, + }; + }); + + return ( + + + + + { + fetcher.submit( + { search: e.currentTarget.value }, + { + action: '/api/companies/search', + method: 'get', + } + ); + }} + /> + + + +
    + {companies.map((company) => { + return ( + + {company.name} + + ); + })} +
+
+
+
+ ); +} From f1b9e074560d451e2d35ad72e7d62878b192b956 Mon Sep 17 00:00:00 2001 From: nyranmoodie Date: Tue, 11 Aug 2026 14:36:53 -0500 Subject: [PATCH 2/4] chore: fix admin dashboard lint formatting Co-authored-by: Cursor --- .../routes/_dashboard.companies.$id.edit.tsx | 16 +++++++++++++-- .../routes/_dashboard.companies.create.tsx | 20 ++++++++++++++++--- .../app/routes/_dashboard.companies.tsx | 7 ++++++- .../app/routes/_dashboard.resume-books.tsx | 9 ++++++++- .../app/routes/api.companies.search.tsx | 7 ++++++- 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/apps/admin-dashboard/app/routes/_dashboard.companies.$id.edit.tsx b/apps/admin-dashboard/app/routes/_dashboard.companies.$id.edit.tsx index 3d54be8d7..e22c11822 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.companies.$id.edit.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.companies.$id.edit.tsx @@ -8,7 +8,11 @@ import { useLoaderData, } from 'react-router'; -import { getCompany, isApifyConfigured, updateCompany } from '@oyster/core/employment/server'; +import { + getCompany, + isApifyConfigured, + updateCompany, +} from '@oyster/core/employment/server'; import { AdminUpdateCompanyInput } from '@oyster/core/employment/types'; import { CompanyDomainField, @@ -23,7 +27,14 @@ import { updateCompanyResumeBookSponsorships, } from '@oyster/core/resume-books'; import { db } from '@oyster/db'; -import { Button, ErrorMessage, getErrors, Modal, Text, validateForm } from '@oyster/ui'; +import { + Button, + ErrorMessage, + getErrors, + Modal, + Text, + validateForm, +} from '@oyster/ui'; import { Route } from '@/shared/constants'; import { @@ -105,6 +116,7 @@ export async function action({ params, request }: ActionFunctionArgs) { }); const url = new URL(request.url); + url.pathname = Route['/companies']; return redirect(url.toString(), { diff --git a/apps/admin-dashboard/app/routes/_dashboard.companies.create.tsx b/apps/admin-dashboard/app/routes/_dashboard.companies.create.tsx index 0ffc9917e..bbe9b1380 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.companies.create.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.companies.create.tsx @@ -8,7 +8,10 @@ import { useLoaderData, } from 'react-router'; -import { createCompany, isApifyConfigured } from '@oyster/core/employment/server'; +import { + createCompany, + isApifyConfigured, +} from '@oyster/core/employment/server'; import { AdminCreateCompanyInput } from '@oyster/core/employment/types'; import { CompanyDomainField, @@ -17,8 +20,18 @@ import { CompanyNameField, CompanyResumeBooksField, } from '@oyster/core/employment/ui'; -import { listResumeBooks, updateCompanyResumeBookSponsorships } from '@oyster/core/resume-books'; -import { Button, ErrorMessage, getErrors, Modal, Text, validateForm } from '@oyster/ui'; +import { + listResumeBooks, + updateCompanyResumeBookSponsorships, +} from '@oyster/core/resume-books'; +import { + Button, + ErrorMessage, + getErrors, + Modal, + Text, + validateForm, +} from '@oyster/ui'; import { Route } from '@/shared/constants'; import { @@ -92,6 +105,7 @@ export async function action({ request }: ActionFunctionArgs) { }); const url = new URL(request.url); + url.pathname = Route['/companies']; return redirect(url.toString(), { diff --git a/apps/admin-dashboard/app/routes/_dashboard.companies.tsx b/apps/admin-dashboard/app/routes/_dashboard.companies.tsx index 97621c81e..166bf9928 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.companies.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.companies.tsx @@ -51,7 +51,12 @@ async function listCompanies({ limit, page, search }: ListSearchParams) { const [rows, countResult] = await Promise.all([ query - .select(['companies.domain', 'companies.id', 'companies.imageUrl', 'companies.name']) + .select([ + 'companies.domain', + 'companies.id', + 'companies.imageUrl', + 'companies.name', + ]) .select((eb) => { return eb .selectFrom('resumeBookSponsors') diff --git a/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx b/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx index c813cb9f6..c1c0d4cbe 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.resume-books.tsx @@ -1,5 +1,12 @@ import dayjs from 'dayjs'; -import { Clipboard, Edit, ExternalLink, Menu, Plus, Trash } from 'react-feather'; +import { + Clipboard, + Edit, + ExternalLink, + Menu, + Plus, + Trash, +} from 'react-feather'; import { generatePath, Link, diff --git a/apps/admin-dashboard/app/routes/api.companies.search.tsx b/apps/admin-dashboard/app/routes/api.companies.search.tsx index b014c02fc..9babd7304 100644 --- a/apps/admin-dashboard/app/routes/api.companies.search.tsx +++ b/apps/admin-dashboard/app/routes/api.companies.search.tsx @@ -19,7 +19,12 @@ export async function loader({ request }: LoaderFunctionArgs) { limit: 50, page: 1, }, - select: ['companies.domain', 'companies.id', 'companies.imageUrl', 'companies.name'], + select: [ + 'companies.domain', + 'companies.id', + 'companies.imageUrl', + 'companies.name', + ], where: { search }, }); From 03895fa67c8baa2974c691a80307b147102b5f3f Mon Sep 17 00:00:00 2001 From: nyranmoodie Date: Tue, 11 Aug 2026 14:39:48 -0500 Subject: [PATCH 3/4] chore: fix core package lint errors Co-authored-by: Cursor --- .../core/src/modules/employment/employment.types.ts | 8 ++------ packages/core/src/modules/employment/index.server.ts | 5 ++++- .../core/src/modules/employment/ui/companies.ui.tsx | 2 +- .../src/modules/employment/use-cases/create-company.ts | 4 ++-- .../src/modules/employment/use-cases/update-company.ts | 2 +- packages/core/src/modules/resume-books/resume-books.ts | 10 +++------- .../src/modules/resume-books/resume-books.types.ts | 4 +++- 7 files changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/core/src/modules/employment/employment.types.ts b/packages/core/src/modules/employment/employment.types.ts index 49c80f5b8..b89873293 100644 --- a/packages/core/src/modules/employment/employment.types.ts +++ b/packages/core/src/modules/employment/employment.types.ts @@ -10,8 +10,8 @@ import { Student, } from '@oyster/types'; -import { ListSearchParams } from '@/shared/types'; import { parseLinkedInCompanySlug } from '@/modules/employment/use-cases/fetch-company-from-linkedin'; +import { ListSearchParams } from '@/shared/types'; // Enums @@ -146,11 +146,7 @@ export type EditCompanyReviewInput = z.infer; export type UpvoteCompanyReviewInput = z.infer; export const CreateCompanyInput = z.object({ - linkedinSlug: z - .string() - .trim() - .min(1) - .transform(parseLinkedInCompanySlug), + linkedinSlug: z.string().trim().min(1).transform(parseLinkedInCompanySlug), }); export const UpdateCompanyInput = z.object({ diff --git a/packages/core/src/modules/employment/index.server.ts b/packages/core/src/modules/employment/index.server.ts index 83966c245..d218cbe24 100644 --- a/packages/core/src/modules/employment/index.server.ts +++ b/packages/core/src/modules/employment/index.server.ts @@ -9,6 +9,9 @@ export { addCompanyReview } from './use-cases/add-company-review'; export { createCompany } from './use-cases/create-company'; export { deleteCompany } from './use-cases/delete-company'; export { editCompanyReview } from './use-cases/edit-company-review'; -export { updateCompany, syncCompanyFromLinkedIn } from './use-cases/update-company'; +export { + updateCompany, + syncCompanyFromLinkedIn, +} from './use-cases/update-company'; export { undoUpvoteCompanyReview } from './use-cases/undo-upvote-company-review'; export { upvoteCompanyReview } from './use-cases/upvote-company-review'; diff --git a/packages/core/src/modules/employment/ui/companies.ui.tsx b/packages/core/src/modules/employment/ui/companies.ui.tsx index 484e5aaaa..bc2246fcc 100644 --- a/packages/core/src/modules/employment/ui/companies.ui.tsx +++ b/packages/core/src/modules/employment/ui/companies.ui.tsx @@ -111,7 +111,7 @@ export function CompanyImageUrlField({ }: Omit, 'name'>) { return ( eb.fn.countAll().as('count')) - .whereRef( - 'resumeBookSubmissions.resumeBookId', - '=', - 'resumeBooks.id' - ) + .whereRef('resumeBookSubmissions.resumeBookId', '=', 'resumeBooks.id') .as('submissions'); }, ]) diff --git a/packages/core/src/modules/resume-books/resume-books.types.ts b/packages/core/src/modules/resume-books/resume-books.types.ts index 1658b060d..c7c4afa67 100644 --- a/packages/core/src/modules/resume-books/resume-books.types.ts +++ b/packages/core/src/modules/resume-books/resume-books.types.ts @@ -148,7 +148,9 @@ export const RemoveResumeBookSponsorInput = AddResumeBookSponsorInput; export type CreateResumeBookInput = z.infer; export type SubmitResumeInput = z.infer; export type UpdateResumeBookInput = z.infer; -export type AddResumeBookSponsorInput = z.infer; +export type AddResumeBookSponsorInput = z.infer< + typeof AddResumeBookSponsorInput +>; export type RemoveResumeBookSponsorInput = z.infer< typeof RemoveResumeBookSponsorInput >; From d699c413ce559c9984d6ef139219ab0121ec8d54 Mon Sep 17 00:00:00 2001 From: nyranmoodie Date: Tue, 11 Aug 2026 14:43:29 -0500 Subject: [PATCH 4/4] fix: resolve resume book sponsor type errors Co-authored-by: Cursor --- packages/core/src/modules/resume-books/resume-books.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/modules/resume-books/resume-books.ts b/packages/core/src/modules/resume-books/resume-books.ts index fd6f22d30..1b0be8200 100644 --- a/packages/core/src/modules/resume-books/resume-books.ts +++ b/packages/core/src/modules/resume-books/resume-books.ts @@ -125,7 +125,7 @@ export async function listResumeBookSponsors({ }: ListResumeBookSponsorsOptions) { const sponsors = await db .selectFrom('resumeBookSponsors') - .leftJoin('companies', 'companies.id', 'resumeBookSponsors.companyId') + .innerJoin('companies', 'companies.id', 'resumeBookSponsors.companyId') .select([ 'companies.domain', 'companies.id', @@ -288,7 +288,7 @@ export async function updateCompanyResumeBookSponsorships({ return !currentIds.has(resumeBookId); }); - const toRemove = [...currentIds].filter((resumeBookId) => { + const toRemove = Array.from(currentIds).filter((resumeBookId) => { return !desiredIds.has(resumeBookId); }); @@ -343,7 +343,7 @@ export async function updateResumeBookSponsors({ return !currentIds.has(companyId); }); - const toRemove = [...currentIds].filter((companyId) => { + const toRemove = Array.from(currentIds).filter((companyId) => { return !desiredIds.has(companyId); });