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,