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 f3de4922c..fc3c2c9e1 100644 --- a/apps/admin-dashboard/app/routes/_dashboard.resume-books.create.tsx +++ b/apps/admin-dashboard/app/routes/_dashboard.resume-books.create.tsx @@ -54,7 +54,14 @@ export async function action({ request }: ActionFunctionArgs) { return data(result, { status: 400 }); } - await createResumeBook(result.data); + const createResult = await createResumeBook(result.data); + + if (!createResult.ok) { + return data( + { errors: { name: createResult.error } }, + { status: createResult.code } + ); + } toast(session, { message: 'Created resume book.', diff --git a/packages/core/src/modules/airtable.ts b/packages/core/src/modules/airtable.ts index 07e6210b6..c0b09c414 100644 --- a/packages/core/src/modules/airtable.ts +++ b/packages/core/src/modules/airtable.ts @@ -319,11 +319,49 @@ type CreateAirtableTableInput = { name: string; }; +/** + * @see https://airtable.com/developers/web/api/list-tables + */ +async function getAirtableTableByName({ + baseId, + name, +}: { + baseId: string; + name: string; +}) { + await airtableRateLimiter.process(); + + const response = await fetch( + `${AIRTABLE_API_URI}/meta/bases/${baseId}/tables`, + { + headers: getAirtableHeaders(), + } + ); + + const json = await response.json(); + + if (!response.ok) { + throw new ColorStackError() + .withMessage('Failed to list Airtable tables.') + .withContext({ baseId, response: json }); + } + + const tables = json.tables as { id: string; name: string }[]; + + return tables.find((table) => table.name === name); +} + export async function createAirtableTable({ baseId, fields, name, }: CreateAirtableTableInput) { + const existingTable = await getAirtableTableByName({ baseId, name }); + + if (existingTable) { + return existingTable.id; + } + await airtableRateLimiter.process(); // The Airtable API doesn't automatically assign colors to select fields, that diff --git a/packages/core/src/modules/resume-books/resume-books.ts b/packages/core/src/modules/resume-books/resume-books.ts index e2b3feff0..422172c54 100644 --- a/packages/core/src/modules/resume-books/resume-books.ts +++ b/packages/core/src/modules/resume-books/resume-books.ts @@ -28,7 +28,7 @@ import { type UpdateResumeBookInput, } from '@/modules/resume-books/resume-books.types'; import { ColorStackError } from '@/shared/errors'; -import { success } from '@/shared/utils/core'; +import { fail, success } from '@/shared/utils/core'; // Environment Variables @@ -45,6 +45,7 @@ type GetResumeBookOptions = { where: Partial<{ hidden: false; id: string; + name: string; status: 'active'; }>; }; @@ -61,6 +62,9 @@ export async function getResumeBook< .$if(!!where.id, (eb) => { return eb.where('id', '=', where.id!); }) + .$if(!!where.name, (eb) => { + return eb.where('name', 'ilike', where.name!); + }) .$if(where.status === 'active', (eb) => { return eb .where('startDate', '<', new Date()) @@ -147,6 +151,19 @@ export async function createResumeBook({ sponsors, startDate, }: CreateResumeBookInput) { + const existingResumeBook = await getResumeBook({ + select: ['id'], + where: { name }, + }); + + if (existingResumeBook) { + return fail({ + code: 409, + context: { name }, + error: 'A resume book with this name already exists.', + }); + } + const [airtableTableId, googleDriveFolderId] = await Promise.all([ createAirtableTable({ baseId: AIRTABLE_RESUME_BOOKS_BASE_ID,