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 02:29:13 -04:00
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" ,
} ,
] ,
2026-04-17 16:53:49 -04:00
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." ,
} ,
] ,
} ,
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 02:29:13 -04:00
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" ,
} ;
}
} ,
} ;