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
209 lines
6.3 KiB
TypeScript
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",
|
|
};
|
|
}
|
|
},
|
|
};
|