diff --git a/apps/admin-dashboard/.env.example b/apps/admin-dashboard/.env.example index b7aa11f3..a7bd4e96 100644 --- a/apps/admin-dashboard/.env.example +++ b/apps/admin-dashboard/.env.example @@ -11,6 +11,7 @@ SESSION_SECRET=_ # Optional for development, but won't be able to run certain features... +# AIRTABLE_API_KEY= # AIRTABLE_FAMILY_BASE_ID= # AIRTABLE_MEMBERS_TABLE_ID= # AIRTABLE_RESUME_BOOKS_BASE_ID= 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 ae8feeda..bd98d51e 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.resume-books.create.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.resume-books.create.tsx @@ -12,7 +12,6 @@ import { Button, ComboboxPopover, DatePicker, - Divider, Form, getErrors, Input, @@ -53,7 +52,6 @@ export async function action({ request }: ActionFunctionArgs) { } await createResumeBook({ - airtableTableId: data.airtableTableId, endDate: data.endDate, name: data.name, sponsors: data.sponsors, @@ -127,22 +125,6 @@ export default function CreateResumeBookModal() { - - - - - - {error} diff --git a/apps/admin-dashboard/app/shared/constants.server.ts b/apps/admin-dashboard/app/shared/constants.server.ts index cd66f13d..0205d47a 100644 --- a/apps/admin-dashboard/app/shared/constants.server.ts +++ b/apps/admin-dashboard/app/shared/constants.server.ts @@ -6,6 +6,7 @@ const EnvironmentVariable = z.string().trim().min(1); const BaseEnvironmentConfig = z.object({ ADMIN_DASHBOARD_URL: EnvironmentVariable, + AIRTABLE_API_KEY: EnvironmentVariable, AIRTABLE_FAMILY_BASE_ID: EnvironmentVariable, AIRTABLE_MEMBERS_TABLE_ID: EnvironmentVariable, AIRTABLE_RESUME_BOOKS_BASE_ID: EnvironmentVariable, @@ -24,6 +25,7 @@ const BaseEnvironmentConfig = z.object({ const EnvironmentConfig = z.discriminatedUnion('ENVIRONMENT', [ BaseEnvironmentConfig.partial({ + AIRTABLE_API_KEY: true, AIRTABLE_FAMILY_BASE_ID: true, AIRTABLE_MEMBERS_TABLE_ID: true, AIRTABLE_RESUME_BOOKS_BASE_ID: true, 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 b8d379e5..113ff6d2 100644 --- a/apps/member-profile/app/routes/_profile.resume-books.$id.tsx +++ b/apps/member-profile/app/routes/_profile.resume-books.$id.tsx @@ -25,7 +25,12 @@ import { listResumeBookSponsors, submitResume, } from '@oyster/core/resume-books'; -import { SubmitResumeInput } from '@oyster/core/resume-books.types'; +import { + RESUME_BOOK_CODING_LANGUAGES, + RESUME_BOOK_JOB_SEARCH_STATUSES, + RESUME_BOOK_ROLES, + SubmitResumeInput, +} from '@oyster/core/resume-books.types'; import { db } from '@oyster/db'; import { FORMATTED_RACE, Race, WorkAuthorizationStatus } from '@oyster/types'; import { @@ -520,26 +525,7 @@ function ResumeBookForm() { required > - {[ - 'C', - 'C++', - 'C#', - 'Go', - 'Java', - 'JavaScript', - 'Kotlin', - 'Matlab', - 'Objective-C', - 'PHP', - 'Python', - 'Ruby', - 'Rust', - 'Scala', - 'Solidity', - 'SQL', - 'Swift', - 'TypeScript', - ].map((value) => { + {RESUME_BOOK_CODING_LANGUAGES.map((value) => { return ( - {[ - 'Software Engineering', - 'Data Science', - 'Web Development', - 'AI/Machine Learning', - 'iOS Developer', - 'Android Developer', - 'Product Management', - 'Product Design (UI/UX)', - 'Developer Advocacy', - 'Network Architecture', - 'Cybersecurity Engineer/Analyst', - ].map((value) => { + {RESUME_BOOK_ROLES.map((value) => { return ( - {[ - 'I am actively searching for a position.', - 'I have accepted an offer.', - 'I am between offers, but still searching.', - ].map((value) => { + {RESUME_BOOK_JOB_SEARCH_STATUSES.map((value) => { return ( { + return match(job) + .with({ name: 'airtable.record.create' }, ({ data }) => { + return createAirtableRecord(data); + }) + .with({ name: 'airtable.record.create.member' }, ({ data }) => { + return createAirtableMemberRecord(data); + }) + .with({ name: 'airtable.record.delete' }, ({ data }) => { + return deleteAirtableRecord(data); + }) + .with({ name: 'airtable.record.update' }, ({ data }) => { + return updateAirtableRecord(data); + }) + .exhaustive(); + } +); + +// Core + +const AirtableMemberRecord = Student.pick({ + email: true, + firstName: true, + gender: true, + id: true, + lastName: true, + linkedInUrl: true, + graduationYear: true, + otherDemographics: true, + race: true, +}).extend({ + school: z.string().optional(), +}); + +type AirtableMemberRecord = z.infer; + +async function createAirtableMemberRecord({ + studentId, +}: GetBullJobData<'airtable.record.create.member'>) { + if ( + !IS_PRODUCTION || + !AIRTABLE_FAMILY_BASE_ID || + !AIRTABLE_MEMBERS_TABLE_ID + ) { + return; + } + + const member = await db + .selectFrom('students') + .leftJoin('schools', 'schools.id', 'students.schoolId') + .select([ + 'email', + 'firstName', + 'gender', + 'lastName', + 'linkedInUrl', + 'graduationYear', + 'otherDemographics', + 'race', + 'students.id', + (eb) => { + return eb.fn + .coalesce('schools.name', 'students.otherSchool') + .as('school'); + }, + ]) + .where('students.id', '=', studentId) + .executeTakeFirstOrThrow(); + + const record = AirtableMemberRecord.parse(member); + + const id = await createAirtableRecord({ + airtableBaseId: AIRTABLE_FAMILY_BASE_ID, + airtableTableId: AIRTABLE_MEMBERS_TABLE_ID, + data: { + 'ColorStack ID': studentId, + Email: record.email, + 'Expected Graduation Year': record.graduationYear.toString(), + 'First Name': record.firstName, + 'Last Name': record.lastName, + 'LinkedIn Profile/URL': record.linkedInUrl, + 'Race & Ethnicity': record.race.map((race) => FORMATTED_RACE[race]), + Gender: FORMATTED_GENDER[record.gender], + 'Member Type': 'Full Member', + 'Quality of Life': record.otherDemographics.map((demographic) => { + return FORMATTED_DEMOGRAPHICS[demographic]; + }), + School: record.school, + }, + }); + + await db.transaction().execute(async (trx) => { + await trx + .updateTable('students') + .set({ airtableId: id }) + .where('id', '=', studentId) + .execute(); + }); +} + +/** + * @see https://airtable.com/developers/web/api/create-records + */ +export async function createAirtableRecord({ + airtableBaseId, + airtableTableId, + data, +}: GetBullJobData<'airtable.record.create'>) { + if (!IS_PRODUCTION) { + return; + } + + await airtableRateLimiter.process(); + + const response = await fetch( + `${AIRTABLE_API_URI}/${airtableBaseId}/${airtableTableId}`, + { + body: JSON.stringify({ + fields: data, + + // This means that if there is a select field (whether single or multi), + // if the value we send to Airtable is not already there, it should + // create that value instead of failing. + typecast: true, + }), + headers: getAirtableHeaders({ includeContentType: true }), + method: 'post', + } + ); + + if (!response.ok) { + throw new ErrorWithContext('Failed to create Airtable record.').withContext( + data + ); + } + + console.log({ + code: 'airtable_record_created', + message: 'Airtable record was created.', + }); + + const json = await response.json(); + + return json.id as string; +} + +type AirtableColor = + | 'blueBright' + | 'blueDark1' + | 'blueLight1' + | 'blueLight2' + | 'cyanBright' + | 'cyanDark1' + | 'cyanLight1' + | 'cyanLight2' + | 'grayBright' + | 'grayDark1' + | 'grayLight1' + | 'grayLight2' + | 'greenBright' + | 'greenDark1' + | 'greenLight1' + | 'greenLight2' + | 'orangeBright' + | 'orangeDark1' + | 'orangeLight1' + | 'orangeLight2' + | 'pinkBright' + | 'pinkDark1' + | 'pinkLight1' + | 'pinkLight2' + | 'purpleBright' + | 'purpleDark1' + | 'purpleLight1' + | 'purpleLight2' + | 'redBright' + | 'redDark1' + | 'redLight1' + | 'redLight2' + | 'tealBright' + | 'tealDark1' + | 'tealLight1' + | 'tealLight2' + | 'yellowBright' + | 'yellowDark1' + | 'yellowLight1' + | 'yellowLight2'; + +const SORTED_AIRTABLE_COLORS: AirtableColor[] = [ + 'blueLight2', + 'cyanLight2', + 'tealLight2', + 'greenLight2', + 'yellowLight2', + 'orangeLight2', + 'redLight2', + 'pinkLight2', + 'purpleLight2', + 'grayLight2', + 'blueLight1', + 'cyanLight1', + 'tealLight1', + 'greenLight1', + 'yellowLight1', + 'orangeLight1', + 'redLight1', + 'pinkLight1', + 'purpleLight1', + 'grayLight1', + 'blueBright', + 'cyanBright', + 'tealBright', + 'greenBright', + 'yellowBright', + 'orangeBright', + 'redBright', + 'pinkBright', + 'purpleBright', + 'grayBright', + 'blueDark1', + 'cyanDark1', + 'tealDark1', + 'greenDark1', + 'yellowDark1', + 'orangeDark1', + 'redDark1', + 'pinkDark1', + 'purpleDark1', + 'grayDark1', +]; + +type AirtableFieldOptions = { + choices: { + color?: AirtableColor; + name: string; + }[]; +}; + +type AirtableField = { name: string } & ( + | { type: 'email' } + | { type: 'multipleAttachments' } + | { type: 'multipleSelects'; options: AirtableFieldOptions } + | { type: 'singleLineText' } + | { type: 'singleSelect'; options: AirtableFieldOptions } + | { type: 'url' } +); + +type CreateAirtableTableInput = { + baseId: string; + fields: AirtableField[]; + name: string; +}; + +export async function createAirtableTable({ + baseId, + fields, + name, +}: CreateAirtableTableInput) { + await airtableRateLimiter.process(); + + // The Airtable API doesn't automatically assign colors to select fields, that + // only happens when you use the Airtable UI. So we need to manually assign + // colors if we don't specify them. + fields = fields.map((field) => { + if (field.type === 'singleSelect' || field.type === 'multipleSelects') { + return { + ...field, + options: { + choices: field.options.choices.map((choice, i) => { + return { + ...choice, + color: + choice.color || + SORTED_AIRTABLE_COLORS[i % SORTED_AIRTABLE_COLORS.length], + }; + }), + }, + }; + } + + return field; + }); + + const response = await fetch( + `${AIRTABLE_API_URI}/meta/bases/${baseId}/tables`, + { + body: JSON.stringify({ name, fields }), + method: 'post', + headers: getAirtableHeaders({ includeContentType: true }), + } + ); + + const json = await response.json(); + + if (!response.ok) { + throw new ColorStackError() + .withMessage('Failed to create Airtable table.') + .withContext({ + baseId, + fields, + name, + response: json, + }); + } + + return json.id as string; +} + +/** + * @see https://airtable.com/developers/web/api/delete-record + */ +async function deleteAirtableRecord({ + airtableBaseId, + airtableRecordId, + airtableTableId, +}: GetBullJobData<'airtable.record.delete'>) { + if (!IS_PRODUCTION) { + return; + } + + await airtableRateLimiter.process(); + + await fetch( + `${AIRTABLE_API_URI}/${airtableBaseId}/${airtableTableId}/${airtableRecordId}`, + { + headers: getAirtableHeaders(), + method: 'delete', + } + ); + + console.log({ + code: 'airtable_record_deleted', + message: 'Airtable record was deleted.', + data: { + airtableBaseId, + airtableRecordId, + airtableTableId, + }, + }); +} + +/** + * @see https://airtable.com/developers/web/api/update-record + */ +export async function updateAirtableRecord({ + airtableBaseId, + airtableRecordId, + airtableTableId, + data, +}: GetBullJobData<'airtable.record.update'>) { + if (!IS_PRODUCTION) { + return; + } + + await airtableRateLimiter.process(); + + const response = await fetch( + `${AIRTABLE_API_URI}/${airtableBaseId}/${airtableTableId}/${airtableRecordId}`, + { + body: JSON.stringify({ + fields: data, + typecast: true, + }), + headers: getAirtableHeaders({ includeContentType: true }), + method: 'PATCH', + } + ); + + console.log({ + code: 'airtable_record_updated', + message: 'Airtable record was updated.', + data: { + airtableBaseId, + airtableRecordId, + airtableTableId, + data, + }, + }); + + const json = await response.json(); + + return json.id as string; +} diff --git a/packages/core/src/modules/airtable/airtable.shared.ts b/packages/core/src/modules/airtable/airtable.shared.ts deleted file mode 100644 index 2f339fa2..00000000 --- a/packages/core/src/modules/airtable/airtable.shared.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { RateLimiter } from '@/shared/utils/rate-limiter'; - -// Environment Variables - -const AIRTABLE_API_KEY = process.env.AIRTABLE_API_KEY; - -export const AIRTABLE_FAMILY_BASE_ID = process.env.AIRTABLE_FAMILY_BASE_ID; -export const AIRTABLE_MEMBERS_TABLE_ID = process.env.AIRTABLE_MEMBERS_TABLE_ID; - -// Constants - -export const AIRTABLE_API_URI = 'https://api.airtable.com/v0'; - -// Rate Limiter - -/** - * @see https://airtable.com/developers/web/api/rate-limits - */ -export const airtableRateLimiter = new RateLimiter('airtable:connections', { - rateLimit: 5, - rateLimitWindow: 1, -}); - -// Functions - -export function getAirtableHeaders( - options: { includeContentType: boolean } = { includeContentType: false } -) { - return { - Authorization: `Bearer ${AIRTABLE_API_KEY}`, - ...(options.includeContentType && { - 'Content-Type': 'application/json', - }), - }; -} diff --git a/packages/core/src/modules/airtable/airtable.worker.ts b/packages/core/src/modules/airtable/airtable.worker.ts deleted file mode 100644 index c0f0a635..00000000 --- a/packages/core/src/modules/airtable/airtable.worker.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { match } from 'ts-pattern'; - -import { AirtableBullJob } from '@/infrastructure/bull/bull.types'; -import { registerWorker } from '@/infrastructure/bull/use-cases/register-worker'; -import { createAirtableMemberRecord } from './use-cases/create-airtable-member-record'; -import { createAirtableRecord } from './use-cases/create-airtable-record'; -import { deleteAirtableRecord } from './use-cases/delete-airtable-record'; -import { updateAirtableRecord } from './use-cases/update-airtable-record'; - -export const airtableWorker = registerWorker( - 'airtable', - AirtableBullJob, - async (job) => { - return match(job) - .with({ name: 'airtable.record.create' }, ({ data }) => { - return createAirtableRecord(data); - }) - .with({ name: 'airtable.record.create.member' }, ({ data }) => { - return createAirtableMemberRecord(data); - }) - .with({ name: 'airtable.record.delete' }, ({ data }) => { - return deleteAirtableRecord(data); - }) - .with({ name: 'airtable.record.update' }, ({ data }) => { - return updateAirtableRecord(data); - }) - .exhaustive(); - } -); diff --git a/packages/core/src/modules/airtable/use-cases/create-airtable-member-record.ts b/packages/core/src/modules/airtable/use-cases/create-airtable-member-record.ts deleted file mode 100644 index 36741750..00000000 --- a/packages/core/src/modules/airtable/use-cases/create-airtable-member-record.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { z } from 'zod'; - -import { - FORMATTED_DEMOGRAPHICS, - FORMATTED_GENDER, - FORMATTED_RACE, - Student, -} from '@oyster/types'; - -import { type GetBullJobData } from '@/infrastructure/bull/bull.types'; -import { db } from '@/infrastructure/database'; -import { - AIRTABLE_FAMILY_BASE_ID, - AIRTABLE_MEMBERS_TABLE_ID, -} from '@/modules/airtable/airtable.shared'; -import { createAirtableRecord } from '@/modules/airtable/use-cases/create-airtable-record'; -import { IS_PRODUCTION } from '@/shared/env'; - -const AirtableMemberRecord = Student.pick({ - email: true, - firstName: true, - gender: true, - id: true, - lastName: true, - linkedInUrl: true, - graduationYear: true, - otherDemographics: true, - race: true, -}).extend({ - school: z.string().optional(), -}); - -type AirtableMemberRecord = z.infer; - -export async function createAirtableMemberRecord({ - studentId, -}: GetBullJobData<'airtable.record.create.member'>) { - if ( - !IS_PRODUCTION || - !AIRTABLE_FAMILY_BASE_ID || - !AIRTABLE_MEMBERS_TABLE_ID - ) { - return; - } - - const member = await db - .selectFrom('students') - .leftJoin('schools', 'schools.id', 'students.schoolId') - .select([ - 'email', - 'firstName', - 'gender', - 'lastName', - 'linkedInUrl', - 'graduationYear', - 'otherDemographics', - 'race', - 'students.id', - (eb) => { - return eb.fn - .coalesce('schools.name', 'students.otherSchool') - .as('school'); - }, - ]) - .where('students.id', '=', studentId) - .executeTakeFirstOrThrow(); - - const record = AirtableMemberRecord.parse(member); - - const id = await createAirtableRecord({ - airtableBaseId: AIRTABLE_FAMILY_BASE_ID, - airtableTableId: AIRTABLE_MEMBERS_TABLE_ID, - data: { - 'ColorStack ID': studentId, - Email: record.email, - 'Expected Graduation Year': record.graduationYear.toString(), - 'First Name': record.firstName, - 'Last Name': record.lastName, - 'LinkedIn Profile/URL': record.linkedInUrl, - 'Race & Ethnicity': record.race.map((race) => FORMATTED_RACE[race]), - Gender: FORMATTED_GENDER[record.gender], - 'Member Type': 'Full Member', - 'Quality of Life': record.otherDemographics.map((demographic) => { - return FORMATTED_DEMOGRAPHICS[demographic]; - }), - School: record.school, - }, - }); - - await db.transaction().execute(async (trx) => { - await trx - .updateTable('students') - .set({ airtableId: id }) - .where('id', '=', studentId) - .execute(); - }); -} diff --git a/packages/core/src/modules/airtable/use-cases/create-airtable-record.ts b/packages/core/src/modules/airtable/use-cases/create-airtable-record.ts deleted file mode 100644 index dc1b916c..00000000 --- a/packages/core/src/modules/airtable/use-cases/create-airtable-record.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { type GetBullJobData } from '@/infrastructure/bull/bull.types'; -import { IS_PRODUCTION } from '@/shared/env'; -import { ErrorWithContext } from '@/shared/errors'; -import { - AIRTABLE_API_URI, - airtableRateLimiter, - getAirtableHeaders, -} from '../airtable.shared'; - -/** - * @see https://airtable.com/developers/web/api/create-records - */ -export async function createAirtableRecord({ - airtableBaseId, - airtableTableId, - data, -}: GetBullJobData<'airtable.record.create'>) { - if (!IS_PRODUCTION) { - return; - } - - await airtableRateLimiter.process(); - - const response = await fetch( - `${AIRTABLE_API_URI}/${airtableBaseId}/${airtableTableId}`, - { - body: JSON.stringify({ - fields: data, - - // This means that if there is a select field (whether single or multi), - // if the value we send to Airtable is not already there, it should - // create that value instead of failing. - typecast: true, - }), - headers: getAirtableHeaders({ includeContentType: true }), - method: 'post', - } - ); - - if (!response.ok) { - throw new ErrorWithContext('Failed to create Airtable record.').withContext( - data - ); - } - - console.log({ - code: 'airtable_record_created', - message: 'Airtable record was created.', - }); - - const json = await response.json(); - - return json.id as string; -} diff --git a/packages/core/src/modules/airtable/use-cases/delete-airtable-record.ts b/packages/core/src/modules/airtable/use-cases/delete-airtable-record.ts deleted file mode 100644 index 8090052f..00000000 --- a/packages/core/src/modules/airtable/use-cases/delete-airtable-record.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { type GetBullJobData } from '@/infrastructure/bull/bull.types'; -import { IS_PRODUCTION } from '@/shared/env'; -import { - AIRTABLE_API_URI, - airtableRateLimiter, - getAirtableHeaders, -} from '../airtable.shared'; - -/** - * @see https://airtable.com/developers/web/api/delete-record - */ -export async function deleteAirtableRecord({ - airtableBaseId, - airtableRecordId, - airtableTableId, -}: GetBullJobData<'airtable.record.delete'>) { - if (!IS_PRODUCTION) { - return; - } - - await airtableRateLimiter.process(); - - await fetch( - `${AIRTABLE_API_URI}/${airtableBaseId}/${airtableTableId}/${airtableRecordId}`, - { - headers: getAirtableHeaders(), - method: 'delete', - } - ); - - console.log({ - code: 'airtable_record_deleted', - message: 'Airtable record was deleted.', - data: { - airtableBaseId, - airtableRecordId, - airtableTableId, - }, - }); -} diff --git a/packages/core/src/modules/airtable/use-cases/update-airtable-record.ts b/packages/core/src/modules/airtable/use-cases/update-airtable-record.ts deleted file mode 100644 index 117b7a73..00000000 --- a/packages/core/src/modules/airtable/use-cases/update-airtable-record.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { type GetBullJobData } from '@/infrastructure/bull/bull.types'; -import { IS_PRODUCTION } from '@/shared/env'; -import { - AIRTABLE_API_URI, - airtableRateLimiter, - getAirtableHeaders, -} from '../airtable.shared'; - -/** - * @see https://airtable.com/developers/web/api/update-record - */ -export async function updateAirtableRecord({ - airtableBaseId, - airtableRecordId, - airtableTableId, - data, -}: GetBullJobData<'airtable.record.update'>) { - if (!IS_PRODUCTION) { - return; - } - - await airtableRateLimiter.process(); - - const response = await fetch( - `${AIRTABLE_API_URI}/${airtableBaseId}/${airtableTableId}/${airtableRecordId}`, - { - body: JSON.stringify({ - fields: data, - typecast: true, - }), - headers: getAirtableHeaders({ includeContentType: true }), - method: 'PATCH', - } - ); - - console.log({ - code: 'airtable_record_updated', - message: 'Airtable record was updated.', - data: { - airtableBaseId, - airtableRecordId, - airtableTableId, - data, - }, - }); - - const json = await response.json(); - - return json.id as string; -} diff --git a/packages/core/src/modules/education/use-cases/check-most-recent-education.ts b/packages/core/src/modules/education/use-cases/check-most-recent-education.ts index 24237e1d..debfc2da 100644 --- a/packages/core/src/modules/education/use-cases/check-most-recent-education.ts +++ b/packages/core/src/modules/education/use-cases/check-most-recent-education.ts @@ -3,7 +3,7 @@ import { db } from '@/infrastructure/database'; import { AIRTABLE_FAMILY_BASE_ID, AIRTABLE_MEMBERS_TABLE_ID, -} from '@/modules/airtable/airtable.shared'; +} from '@/modules/airtable/airtable.core'; import { DegreeType, type EducationLevel } from '../education.types'; const EducationLevelFromDegreeType: Record = { diff --git a/packages/core/src/modules/member/events/member-primary-email-changed.ts b/packages/core/src/modules/member/events/member-primary-email-changed.ts index 987f600e..88e1f7ed 100644 --- a/packages/core/src/modules/member/events/member-primary-email-changed.ts +++ b/packages/core/src/modules/member/events/member-primary-email-changed.ts @@ -4,7 +4,7 @@ import { db } from '@/infrastructure/database'; import { AIRTABLE_FAMILY_BASE_ID, AIRTABLE_MEMBERS_TABLE_ID, -} from '@/modules/airtable/airtable.shared'; +} from '@/modules/airtable/airtable.core'; import { updateMailchimpListMember } from '@/modules/mailchimp/use-cases/update-mailchimp-list-member'; import { reportException } from '@/modules/sentry/use-cases/report-exception'; import { diff --git a/packages/core/src/modules/member/events/member-removed.ts b/packages/core/src/modules/member/events/member-removed.ts index 1333c54b..eb3c327d 100644 --- a/packages/core/src/modules/member/events/member-removed.ts +++ b/packages/core/src/modules/member/events/member-removed.ts @@ -3,7 +3,7 @@ import { job } from '@/infrastructure/bull/use-cases/job'; import { AIRTABLE_FAMILY_BASE_ID, AIRTABLE_MEMBERS_TABLE_ID, -} from '@/modules/airtable/airtable.shared'; +} from '@/modules/airtable/airtable.core'; export async function onMemberRemoved({ airtableId, diff --git a/packages/core/src/modules/resume-book/resume-book.core.ts b/packages/core/src/modules/resume-book/resume-book.core.ts index 0ec9d361..fc0383aa 100644 --- a/packages/core/src/modules/resume-book/resume-book.core.ts +++ b/packages/core/src/modules/resume-book/resume-book.core.ts @@ -3,12 +3,15 @@ import { type SelectExpression } from 'kysely'; import { match } from 'ts-pattern'; import { type DB, db, point } from '@oyster/db'; -import { FORMATTED_RACE } from '@oyster/types'; +import { FORMATTED_RACE, Race } from '@oyster/types'; import { id, iife } from '@oyster/utils'; import { job } from '@/infrastructure/bull/use-cases/job'; -import { createAirtableRecord } from '@/modules/airtable/use-cases/create-airtable-record'; -import { updateAirtableRecord } from '@/modules/airtable/use-cases/update-airtable-record'; +import { + createAirtableRecord, + createAirtableTable, + updateAirtableRecord, +} from '@/modules/airtable/airtable.core'; import { type DegreeType } from '@/modules/education/education.types'; import { createGoogleDriveFolder, @@ -17,6 +20,9 @@ import { import { getPresignedURL, putObject } from '@/modules/object-storage'; import { type CreateResumeBookInput, + RESUME_BOOK_CODING_LANGUAGES, + RESUME_BOOK_JOB_SEARCH_STATUSES, + RESUME_BOOK_ROLES, type SubmitResumeInput, } from '@/modules/resume-book/resume-book.types'; import { ColorStackError } from '@/shared/errors'; @@ -120,11 +126,235 @@ export async function listResumeBookSponsors({ * resume book. This also automatically creates a Google Drive folder and stores * a reference on the resume book record. */ -export async function createResumeBook(input: CreateResumeBookInput) { - const googleDriveFolderId = await createGoogleDriveFolder({ - folderId: GOOGLE_DRIVE_RESUME_BOOKS_FOLDER_ID, - name: `${input.name} Resume Book`, - }); +export async function createResumeBook({ + endDate, + name, + sponsors, + 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', + }, + ], + }); + }), + + createGoogleDriveFolder({ + folderId: GOOGLE_DRIVE_RESUME_BOOKS_FOLDER_ID, + name: `${name} Resume Book`, + }), + ]); await db.transaction().execute(async (trx) => { const resumeBookId = id(); @@ -133,19 +363,19 @@ export async function createResumeBook(input: CreateResumeBookInput) { .insertInto('resumeBooks') .values({ airtableBaseId: AIRTABLE_RESUME_BOOKS_BASE_ID, - airtableTableId: input.airtableTableId, - endDate: input.endDate, + airtableTableId, + endDate, googleDriveFolderId, id: resumeBookId, - name: input.name, - startDate: input.startDate, + name, + startDate, }) .execute(); await trx .insertInto('resumeBookSponsors') .values( - input.sponsors.map((sponsor) => { + sponsors.map((sponsor) => { return { companyId: sponsor, resumeBookId, diff --git a/packages/core/src/modules/resume-book/resume-book.types.ts b/packages/core/src/modules/resume-book/resume-book.types.ts index 6742390e..8157d1bb 100644 --- a/packages/core/src/modules/resume-book/resume-book.types.ts +++ b/packages/core/src/modules/resume-book/resume-book.types.ts @@ -3,6 +3,47 @@ import { z } from 'zod'; import { Entity, Student } from '@oyster/types'; +export const RESUME_BOOK_CODING_LANGUAGES = [ + 'C', + 'C++', + 'C#', + 'Go', + 'Java', + 'JavaScript', + 'Kotlin', + 'Matlab', + 'Objective-C', + 'PHP', + 'Python', + 'Ruby', + 'Rust', + 'Scala', + 'Solidity', + 'SQL', + 'Swift', + 'TypeScript', +]; + +export const RESUME_BOOK_JOB_SEARCH_STATUSES = [ + 'I am actively searching for a position.', + 'I have accepted an offer.', + 'I am between offers, but still searching.', +]; + +export const RESUME_BOOK_ROLES = [ + 'Software Engineering', + 'Data Science', + 'Web Development', + 'AI/Machine Learning', + 'iOS Developer', + 'Android Developer', + 'Product Management', + 'Product Design (UI/UX)', + 'Developer Advocacy', + 'Network Architecture', + 'Cybersecurity Engineer/Analyst', +]; + // Domain const ResumeBook = z.object({ @@ -25,7 +66,6 @@ const ResumeBook = z.object({ // Use Case(s) export const CreateResumeBookInput = ResumeBook.pick({ - airtableTableId: true, endDate: true, name: true, startDate: true,