echos-ocr/src/lib/integrations/providers/airtable.ts
Randall Stillwell 3e0a4458fe Add unified field mapping UI for integrations
Adds a reusable FieldMappingEditor component to the integration
detail page that works across all providers (Monday.com, Google
Sheets, Airtable, Planning Center). Users can visually map card
fields (core + dynamic FormTemplate fields) to external columns.

- New GET /api/integrations/source-fields returns curated core
  card fields plus the org's default FormTemplate dynamic fields
  (prefixed with fieldData.)
- flattenCardFieldData helper hoists fieldData entries to top-
  level keys before pushCard so dynamic fields are mappable
- Monday provider now upserts: re-syncing a card updates the
  existing item instead of creating duplicates, and the
  mondayItemId is persisted back to the ResponseCard

Made-with: Cursor
2026-04-17 15:53:49 -05:00

209 lines
6.3 KiB
TypeScript

import type {
IntegrationProvider,
CardData,
TestResult,
PushResult,
ExternalField,
JsonValue,
} from "../types";
const AIRTABLE_API = "https://api.airtable.com/v0";
interface AirtableConfig {
personalAccessToken: string;
baseId: string;
tableIdOrName: string;
}
function parseConfig(config: JsonValue): AirtableConfig {
const c = config as Record<string, unknown>;
return {
personalAccessToken: (c.personalAccessToken as string) || "",
baseId: (c.baseId as string) || "",
tableIdOrName: (c.tableIdOrName as string) || "",
};
}
async function airtableFetch(
token: string,
path: string,
options: RequestInit = {}
) {
const res = await fetch(`${AIRTABLE_API}${path}`, {
...options,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(options.headers || {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Airtable API ${res.status}: ${text}`);
}
return res.json();
}
export const airtableProvider: IntegrationProvider = {
id: "airtable",
name: "Airtable",
description: "Push response cards as rows in Airtable bases",
icon: "airtable",
category: "spreadsheet",
supportsFieldMapping: true,
supportsOAuth: false,
configFields: [
{
key: "personalAccessToken",
label: "Personal Access Token",
type: "password",
required: true,
helpText: "Create at airtable.com/create/tokens",
},
{
key: "baseId",
label: "Base ID",
type: "text",
required: true,
placeholder: "appXXXXXXXXXXXXXX",
helpText: "Found in the Airtable API docs for your base",
},
{
key: "tableIdOrName",
label: "Table Name or ID",
type: "text",
required: true,
placeholder: "Response Cards",
},
],
setupGuide: {
summary:
"Airtable uses Personal Access Tokens (PATs) with explicit scopes. You'll create a token scoped to the specific base you want Echo OCR to write rows into, then paste that token with the base ID and table name.",
docsUrl: "https://airtable.com/developers/web/guides/personal-access-tokens",
steps: [
{
title: "Create a Personal Access Token",
body: "Go to Airtable's developer hub and click \"Create token\". Give it a descriptive name like \"Echo OCR\".",
linkUrl: "https://airtable.com/create/tokens",
linkLabel: "Create a token",
},
{
title: "Add the required scopes",
body: "Under Scopes, add data.records:read and data.records:write. If you plan to let Echo OCR auto-detect your table schema, also add schema.bases:read.",
},
{
title: "Grant access to your base",
body: "Under \"Access\", add the specific base you want Echo OCR to write to. Avoid granting access to \"All workspaces\" — scope it narrowly.",
},
{
title: "Copy the token",
body: "Click Create token, then copy the token that appears (it starts with pat...). You won't be able to see it again after closing the dialog.",
},
{
title: "Find the base ID",
body: "Open the base in a browser and click Help → API documentation. The base ID is shown at the top of the API docs page and starts with \"app\".",
linkUrl: "https://airtable.com/developers/web/api/introduction",
linkLabel: "Airtable API introduction",
},
{
title: "Enter the table name",
body: "Use the exact table name as it appears in Airtable (case-sensitive) or the table ID. Echo OCR appends a new row for every card pushed.",
},
],
},
async testConnection(config: JsonValue): Promise<TestResult> {
try {
const { personalAccessToken, baseId, tableIdOrName } =
parseConfig(config);
if (!personalAccessToken || !baseId || !tableIdOrName) {
return {
success: false,
message: "Token, base ID, and table name are all required",
};
}
const data = await airtableFetch(
personalAccessToken,
`/${baseId}/${encodeURIComponent(tableIdOrName)}?maxRecords=1`
);
return {
success: true,
message: `Connected to table (${data.records?.length ?? 0} sample records)`,
};
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : "Connection failed",
};
}
},
async getExternalFields(config: JsonValue): Promise<ExternalField[]> {
const { personalAccessToken, baseId, tableIdOrName } =
parseConfig(config);
const data = await airtableFetch(
personalAccessToken,
`/${baseId}/${encodeURIComponent(tableIdOrName)}?maxRecords=1`
);
if (data.records?.[0]?.fields) {
return Object.keys(data.records[0].fields).map((key) => ({
id: key,
name: key,
type: typeof data.records[0].fields[key],
}));
}
return [];
},
async pushCard(
card: CardData,
config: JsonValue,
mapping: JsonValue
): Promise<PushResult> {
try {
const { personalAccessToken, baseId, tableIdOrName } =
parseConfig(config);
const fieldMap = (mapping as Record<string, string>) || {};
const fields: Record<string, unknown> = {};
for (const [cardField, airtableField] of Object.entries(fieldMap)) {
if (!airtableField || cardField.startsWith("_")) continue;
const val = card[cardField];
if (val !== null && val !== undefined) {
fields[airtableField] = typeof val === "object" ? JSON.stringify(val) : val;
}
}
if (Object.keys(fields).length === 0 && card.name) {
fields["Name"] = card.name;
if (card.email) fields["Email"] = card.email;
if (card.cellPhone) fields["Phone"] = card.cellPhone;
}
const data = await airtableFetch(
personalAccessToken,
`/${baseId}/${encodeURIComponent(tableIdOrName)}`,
{
method: "POST",
body: JSON.stringify({ records: [{ fields }] }),
}
);
const recordId = data.records?.[0]?.id;
return {
success: true,
externalId: recordId,
message: `Created record ${recordId}`,
};
} catch (err) {
return {
success: false,
message: err instanceof Error ? err.message : "Push failed",
};
}
},
};