echos-ocr/src/lib/integrations/providers/airtable.ts
Randall Stillwell a975043670 Add onboarding wizard, email verification, integration architecture, and settings restructure
- Auto-sign-in after registration instead of redirect to login
- Email verification system with token generation, send/confirm API routes, and persistent banner
- 7-step onboarding wizard (org, location, services, upload source, AI, integrations, complete)
- Middleware redirects owners with incomplete onboarding to /onboarding
- Integration provider plugin architecture with registry and 6 providers (Planning Center, Monday.com, Airtable, Google Sheets, Webhook, CSV Export)
- Full integration CRUD API with test, sync, fields, and OAuth authorize/callback routes
- Refactored fireIntegrationEvent to use Integration model with legacy AppSettings fallback
- Migration script for existing Monday.com/webhook config to Integration rows
- Settings page restructured from monolithic 1290-line file into focused sub-routes with section navigation
- Integration hub UI with provider tiles, connect flow, and individual config pages
- Post-onboarding contextual guidance cards on dashboard with dismissible hints
- Schema: Integration model, onboardingComplete/onboardingStep on Organization, dismissedHints on OrgMember

Made-with: Cursor
2026-04-15 01:29:13 -05:00

173 lines
4.5 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",
},
],
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",
};
}
},
};