From 152c0925b27d42c4a1381f90ba28af626f327b07 Mon Sep 17 00:00:00 2001 From: shanoysinc Date: Wed, 19 Aug 2026 15:51:02 -0500 Subject: [PATCH] fix: updating airtable sponsor --- packages/core/src/modules/airtable.ts | 268 +++++++++++++++++- .../src/modules/resume-books/resume-books.ts | 73 +++++ 2 files changed, 333 insertions(+), 8 deletions(-) diff --git a/packages/core/src/modules/airtable.ts b/packages/core/src/modules/airtable.ts index c0b09c414..fdf051abc 100644 --- a/packages/core/src/modules/airtable.ts +++ b/packages/core/src/modules/airtable.ts @@ -8,6 +8,7 @@ import { FORMATTED_RACE, Student, } from '@oyster/types'; +import { id } from '@oyster/utils'; import { registerWorker } from '@/infrastructure/bull'; import { @@ -30,6 +31,11 @@ export const AIRTABLE_MEMBERS_TABLE_ID = process.env.AIRTABLE_MEMBERS_TABLE_ID; const AIRTABLE_API_URI = 'https://api.airtable.com/v0'; +/** + * @see https://airtable.com/developers/web/api/create-records + */ +const MAX_AIRTABLE_RECORDS_PER_REQUEST = 10; + // Rate Limiter /** @@ -320,15 +326,28 @@ type CreateAirtableTableInput = { }; /** - * @see https://airtable.com/developers/web/api/list-tables + * The schema of an existing Airtable table. Note that the choices of a select + * field include an `id`, which is required in order to keep that choice around + * when we update the field. */ -async function getAirtableTableByName({ - baseId, - name, -}: { - baseId: string; +type AirtableTableSchema = { + fields: { + id: string; + name: string; + options?: { + choices?: { color?: AirtableColor; id: string; name: string }[]; + }; + type: string; + }[]; + id: string; name: string; -}) { + primaryFieldId: string; +}; + +/** + * @see https://airtable.com/developers/web/api/list-tables + */ +async function listAirtableTables({ baseId }: { baseId: string }) { await airtableRateLimiter.process(); const response = await fetch( @@ -346,11 +365,33 @@ async function getAirtableTableByName({ .withContext({ baseId, response: json }); } - const tables = json.tables as { id: string; name: string }[]; + return json.tables as AirtableTableSchema[]; +} + +async function getAirtableTableByName({ + baseId, + name, +}: { + baseId: string; + name: string; +}) { + const tables = await listAirtableTables({ baseId }); return tables.find((table) => table.name === name); } +async function getAirtableTableById({ + baseId, + tableId, +}: { + baseId: string; + tableId: string; +}) { + const tables = await listAirtableTables({ baseId }); + + return tables.find((table) => table.id === tableId); +} + export async function createAirtableTable({ baseId, fields, @@ -412,6 +453,217 @@ export async function createAirtableTable({ return json.id as string; } +/** + * Creates records in a table, with `typecast` enabled, and returns the IDs of + * the records that were created. Up to 10 records can be created at a time. + * + * @see https://airtable.com/developers/web/api/create-records + */ +async function createAirtableRecords({ + baseId, + records, + tableId, +}: { + baseId: string; + records: Record[]; + tableId: string; +}) { + await airtableRateLimiter.process(); + + const response = await fetch(`${AIRTABLE_API_URI}/${baseId}/${tableId}`, { + body: JSON.stringify({ + records: records.map((fields) => { + return { fields }; + }), + + typecast: true, + }), + headers: getAirtableHeaders({ includeContentType: true }), + method: 'post', + }); + + const json = await response.json(); + + if (!response.ok) { + throw new ColorStackError() + .withMessage('Failed to create Airtable records.') + .withContext({ baseId, records, response: json, tableId }); + } + + return (json.records as { id: string }[]).map((record) => record.id); +} + +/** + * Deletes up to 10 records from a table at a time. + * + * @see https://airtable.com/developers/web/api/delete-multiple-records + */ +async function deleteAirtableRecords({ + baseId, + recordIds, + tableId, +}: { + baseId: string; + recordIds: string[]; + tableId: string; +}) { + await airtableRateLimiter.process(); + + const searchParams = new URLSearchParams( + recordIds.map((recordId) => { + return ['records[]', recordId]; + }) + ); + + const response = await fetch( + `${AIRTABLE_API_URI}/${baseId}/${tableId}?${searchParams}`, + { + headers: getAirtableHeaders(), + method: 'delete', + } + ); + + const json = await response.json(); + + if (!response.ok) { + throw new ColorStackError() + .withMessage('Failed to delete Airtable records.') + .withContext({ baseId, recordIds, response: json, tableId }); + } +} + +type SyncAirtableSelectFieldChoicesInput = { + baseId: string; + + /** + * The choices that the fields should have. Any choice that isn't on a field + * yet will be added to it. + */ + choices: string[]; + + /** + * The names of the select fields to sync. All of them will end up with the + * same set of choices. + */ + fieldNames: string[]; + + tableId: string; +}; + +/** + * Ensures that each of the given select fields has a choice for every choice + * name provided, adding any that are missing. + * + * There is no API for editing the choices of a select field -- the "update + * field" endpoint rejects any `options` we send with "Changing a field's type or + * number precision is not currently supported." The only way to add a choice is + * the `typecast` flag on a record write, which tells Airtable to create any + * value it doesn't recognize. So, to add choices, we write a throwaway record + * per missing choice and then delete it -- the choices it created stay behind on + * the field. + * + * Since a record can only hold one value per single select field, we need one + * record per missing choice (each one filling in every field that is missing + * that choice). + * + * Choices that are on a field but are no longer wanted are returned as `stale`. + * Those can't be deleted programmatically at all, so they have to be removed in + * the Airtable UI. + * + * @see https://airtable.com/developers/web/api/create-records + */ +export async function syncAirtableSelectFieldChoices({ + baseId, + choices, + fieldNames, + tableId, +}: SyncAirtableSelectFieldChoicesInput) { + const table = await getAirtableTableById({ baseId, tableId }); + + if (!table) { + throw new ColorStackError() + .withMessage('Could not find the Airtable table.') + .withContext({ baseId, tableId }); + } + + const desiredNames = new Set(choices); + + // The fields that are missing a given choice, keyed by the choice's name. + const missing = new Map(); + + const stale = new Set(); + + for (const fieldName of fieldNames) { + const field = table.fields.find((field) => field.name === fieldName); + + if (!field) { + throw new ColorStackError() + .withMessage('Could not find the Airtable field.') + .withContext({ baseId, fieldName, tableId }); + } + + if (field.type !== 'singleSelect' && field.type !== 'multipleSelects') { + throw new ColorStackError() + .withMessage('Cannot sync choices of a non-select Airtable field.') + .withContext({ baseId, fieldName, tableId, type: field.type }); + } + + const existingChoices = field.options?.choices || []; + const existingNames = new Set(); + + for (const choice of existingChoices) { + existingNames.add(choice.name); + + if (!desiredNames.has(choice.name)) { + stale.add(choice.name); + } + } + + for (const choice of choices) { + if (!existingNames.has(choice)) { + missing.set(choice, [...(missing.get(choice) || []), fieldName]); + } + } + } + + // One throwaway record per missing choice, which sets that choice on every + // field that doesn't have it yet. + const primaryField = table.fields.find((field) => { + return field.id === table.primaryFieldId; + }); + + const records = Array.from(missing.entries()).map(([choice, fields]) => { + const record = Object.fromEntries( + fields.map((fieldName) => { + return [fieldName, choice]; + }) + ); + + if (primaryField && record[primaryField.name] === undefined) { + record[primaryField.name] = `sponsor-sync-${id()}@colorstack.org`; + } + + return record; + }); + + for (let i = 0; i < records.length; i += MAX_AIRTABLE_RECORDS_PER_REQUEST) { + const batch = records.slice(i, i + MAX_AIRTABLE_RECORDS_PER_REQUEST); + + const recordIds = await createAirtableRecords({ + baseId, + records: batch, + tableId, + }); + + await deleteAirtableRecords({ baseId, recordIds, tableId }); + } + + return { + added: Array.from(missing.keys()), + stale: Array.from(stale), + }; +} + /** * @see https://airtable.com/developers/web/api/delete-record */ diff --git a/packages/core/src/modules/resume-books/resume-books.ts b/packages/core/src/modules/resume-books/resume-books.ts index 1b0be8200..d4137fea4 100644 --- a/packages/core/src/modules/resume-books/resume-books.ts +++ b/packages/core/src/modules/resume-books/resume-books.ts @@ -8,10 +8,12 @@ import { id, run } from '@oyster/utils'; import { job } from '@/infrastructure/bull'; import { getPresignedURL, putObject } from '@/infrastructure/s3'; +import { reportException } from '@/infrastructure/sentry'; import { type AirtableField, createAirtableRecord, createAirtableTable, + syncAirtableSelectFieldChoices, updateAirtableRecord, } from '@/modules/airtable'; import { type DegreeType } from '@/modules/education/education.types'; @@ -262,6 +264,71 @@ export async function removeResumeBookSponsor({ return success({}); } +/** + * The Airtable fields (in a resume book's table) whose choices are the sponsors + * of that resume book. + */ +const RESUME_BOOK_SPONSOR_AIRTABLE_FIELDS = [ + 'Sponsor Interest #1', + 'Sponsor Interest #2', + 'Sponsor Interest #3', +]; + +/** + * Syncs the sponsors of a resume book to the "Sponsor Interest" fields in its + * Airtable table, so that newly added sponsors are selectable in Airtable. + * + * The Airtable API doesn't support deleting the choices of a select field, so + * a sponsor that was removed will still be listed as a choice in Airtable -- + * we log those so they can be cleaned up in the Airtable UI. Removing a sponsor + * isn't allowed once a member has selected it, so a leftover choice is always + * an unused one. + * + * Airtable is a mirror of what we store, so a failure here is reported but not + * thrown -- the sponsors have already been updated in our database, and some + * older resume books point to Airtable tables that no longer exist. + */ +async function syncResumeBookSponsorsToAirtable(resumeBookId: string) { + const resumeBook = await getResumeBook({ + select: ['airtableBaseId', 'airtableTableId'], + where: { id: resumeBookId }, + }); + + if (!resumeBook?.airtableBaseId || !resumeBook.airtableTableId) { + return; + } + + const sponsors = await listResumeBookSponsors({ where: { resumeBookId } }); + + try { + const { stale } = await syncAirtableSelectFieldChoices({ + baseId: resumeBook.airtableBaseId, + choices: sponsors.map((sponsor) => sponsor.name), + fieldNames: RESUME_BOOK_SPONSOR_AIRTABLE_FIELDS, + tableId: resumeBook.airtableTableId, + }); + + if (stale.length) { + console.warn({ + code: 'airtable_stale_sponsor_choices', + message: + 'Sponsors were removed from a resume book, but the Airtable API does ' + + 'not support deleting the choices of a select field. These need to ' + + 'be deleted in the Airtable UI.', + data: { resumeBookId, sponsors: stale }, + }); + } + } catch (e) { + reportException(e, { resumeBookId }); + + console.error({ + code: 'airtable_sponsor_sync_failed', + message: 'Failed to sync the resume book sponsors to Airtable.', + data: { error: e, resumeBookId }, + }); + } +} + /** * Syncs which resume books a company sponsors by diffing the desired list * against the current list. @@ -314,6 +381,10 @@ export async function updateCompanyResumeBookSponsorships({ } } + for (const resumeBookId of resumeBookIds) { + await syncResumeBookSponsorsToAirtable(resumeBookId); + } + return success({}); } @@ -369,6 +440,8 @@ export async function updateResumeBookSponsors({ } } + await syncResumeBookSponsorsToAirtable(resumeBookId); + return success({}); }