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