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; 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 { 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 { 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 { try { const { personalAccessToken, baseId, tableIdOrName } = parseConfig(config); const fieldMap = (mapping as Record) || {}; const fields: Record = {}; 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", }; } }, };