feat(airtable): added airtable tools, block, & webhook and refactored webhooks code into components (#228)

* fixed existing airtable tools, added more tools to airtable block

* added airtable webhook

* creared oauth util to fetch tokens, removed extraneous debug logs

* cleanup

* significant improvement for webhooks code, refactored into components and standardized across providers

* fixed copy button styling, cleaned up files
This commit is contained in:
Waleed Latif
2025-04-05 19:48:49 -07:00
committed by GitHub
parent d29041d94f
commit 33de83b3b5
43 changed files with 2713 additions and 1353 deletions

View File

@@ -2,17 +2,18 @@ import { NextRequest, NextResponse } from 'next/server'
import { eq } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { refreshOAuthToken } from '@/lib/oauth'
import { db } from '@/db'
import { account } from '@/db/schema'
import { refreshAccessTokenIfNeeded } from '../../utils'
const logger = createLogger('GoogleDriveFileAPI')
/**
* Get a single file from Google Drive by ID
* Get a single file from Google Drive
*/
export async function GET(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8) // Short request ID for correlation
const requestId = crypto.randomUUID().slice(0, 8) // Generate a short request ID for correlation
logger.info(`[${requestId}] Google Drive file request received`)
try {
// Get the session
@@ -30,16 +31,8 @@ export async function GET(request: NextRequest) {
const fileId = searchParams.get('fileId')
if (!credentialId || !fileId) {
logger.warn(`[${requestId}] Missing required parameters`, {
credentialId: !!credentialId,
fileId: !!fileId,
})
return NextResponse.json(
{
error: !credentialId ? 'Credential ID is required' : 'File ID is required',
},
{ status: 400 }
)
logger.warn(`[${requestId}] Missing required parameters`)
return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 })
}
// Get the credential from the database
@@ -54,82 +47,72 @@ export async function GET(request: NextRequest) {
// Check if the credential belongs to the user
if (credential.userId !== session.user.id) {
logger.warn(`[${requestId}] Unauthorized credential access attempt`)
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
credentialUserId: credential.userId,
requestUserId: session.user.id,
})
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Check if the access token is valid
if (!credential.accessToken) {
logger.warn(`[${requestId}] No access token available for credential`)
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Function to fetch file with a given token
const fetchFileWithToken = async (token: string) => {
const response = await fetch(
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
)
return response
}
// First attempt with current token
let response = await fetchFileWithToken(credential.accessToken)
// If unauthorized, try to refresh the token
if (response.status === 401 && credential.refreshToken) {
logger.info(`[${requestId}] Access token expired, attempting to refresh`)
try {
// Refresh the token using the centralized utility
const refreshedToken = await refreshOAuthToken(
credential.providerId,
credential.refreshToken
)
if (refreshedToken) {
logger.info(`[${requestId}] Token refreshed successfully`)
// Update the token in the database
await db
.update(account)
.set({
accessToken: refreshedToken,
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), // Default 1 hour expiry
updatedAt: new Date(),
})
.where(eq(account.id, credentialId))
// Retry the request with the new token
response = await fetchFileWithToken(refreshedToken)
}
} catch (refreshError) {
logger.error(`[${requestId}] Error refreshing token`, refreshError)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
// Fetch the file from Google Drive API
logger.info(`[${requestId}] Fetching file ${fileId} from Google Drive API`)
const response = await fetch(
`https://www.googleapis.com/drive/v3/files/${fileId}?fields=id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners,exportLinks`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
}
)
// Handle response
if (!response.ok) {
const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
const errorData = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))
logger.error(`[${requestId}] Google Drive API error`, {
status: response.status,
fileId,
error: errorData.error?.message || 'Failed to fetch file from Google Drive',
})
return NextResponse.json(
{
error: error.error?.message || 'Failed to fetch file from Google Drive',
error: errorData.error?.message || 'Failed to fetch file from Google Drive',
},
{ status: response.status }
)
}
const file = await response.json()
logger.info(`[${requestId}] Successfully retrieved file from Google Drive`, { fileId })
// In case of Google Docs, Sheets, etc., provide the export links
const exportFormats: { [key: string]: string } = {
'application/vnd.google-apps.document': 'application/pdf', // Google Docs to PDF
'application/vnd.google-apps.spreadsheet':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // Google Sheets to XLSX
'application/vnd.google-apps.presentation': 'application/pdf', // Google Slides to PDF
}
// If the file is a Google Docs, Sheets, or Slides file, we need to provide the export link
if (file.mimeType.startsWith('application/vnd.google-apps.')) {
const format = exportFormats[file.mimeType] || 'application/pdf'
if (!file.exportLinks) {
// If export links are not available in the response, try to construct one
file.downloadUrl = `https://www.googleapis.com/drive/v3/files/${file.id}/export?mimeType=${encodeURIComponent(
format
)}`
} else {
// Use the export link from the response if available
file.downloadUrl = file.exportLinks[format]
}
} else {
// For regular files, use the download link
file.downloadUrl = `https://www.googleapis.com/drive/v3/files/${file.id}?alt=media`
}
return NextResponse.json({ file }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error fetching file from Google Drive`, error)

View File

@@ -2,9 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'
import { eq } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { refreshOAuthToken } from '@/lib/oauth'
import { db } from '@/db'
import { account } from '@/db/schema'
import { refreshAccessTokenIfNeeded } from '../../utils'
const logger = createLogger('GoogleDriveFilesAPI')
@@ -55,12 +55,11 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
}
// Check if the access token is valid
if (!credential.accessToken) {
logger.warn(`[${requestId}] No access token available for credential`, {
credentialId,
})
return NextResponse.json({ error: 'No access token available' }, { status: 400 })
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Build the query parameters for Google Drive API
@@ -86,54 +85,15 @@ export async function GET(request: NextRequest) {
}
}
// Function to fetch files with a given token
const fetchFilesWithToken = async (token: string) => {
const response = await fetch(
`https://www.googleapis.com/drive/v3/files?${queryParams}&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners)`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
)
return response
}
// First attempt with current token
let response = await fetchFilesWithToken(credential.accessToken)
// If unauthorized, try to refresh the token
if (response.status === 401 && credential.refreshToken) {
logger.info(`[${requestId}] Access token expired, attempting to refresh`)
try {
// Refresh the token using the centralized utility
const refreshedToken = await refreshOAuthToken(
credential.providerId,
credential.refreshToken
)
if (refreshedToken) {
logger.info(`[${requestId}] Token refreshed successfully`)
// Update the token in the database
await db
.update(account)
.set({
accessToken: refreshedToken,
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), // Default 1 hour expiry
updatedAt: new Date(),
})
.where(eq(account.id, credentialId))
// Retry the request with the new token
response = await fetchFilesWithToken(refreshedToken)
}
} catch (refreshError) {
logger.error(`[${requestId}] Error refreshing token`, refreshError)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
// Fetch files from Google Drive API
const response = await fetch(
`https://www.googleapis.com/drive/v3/files?${queryParams}&fields=files(id,name,mimeType,iconLink,webViewLink,thumbnailLink,createdTime,modifiedTime,size,owners)`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
}
}
)
if (!response.ok) {
const error = await response.json().catch(() => ({ error: { message: 'Unknown error' } }))

View File

@@ -2,9 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'
import { and, eq } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { refreshOAuthToken } from '@/lib/oauth'
import { db } from '@/db'
import { account } from '@/db/schema'
import { refreshAccessTokenIfNeeded } from '../../utils'
const logger = createLogger('GmailLabelAPI')
@@ -52,45 +52,11 @@ export async function GET(request: NextRequest) {
`[${requestId}] Using credential: ${credential.id}, provider: ${credential.providerId}`
)
// Check if we need to refresh the token
const expiresAt = credential.accessTokenExpiresAt
const now = new Date()
const needsRefresh = !expiresAt || expiresAt <= now
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
let accessToken = credential.accessToken
if (needsRefresh && credential.refreshToken) {
logger.info(`[${requestId}] Token expired, attempting to refresh`)
try {
const refreshedToken = await refreshOAuthToken(
credential.providerId,
credential.refreshToken
)
if (!refreshedToken) {
logger.error(`[${requestId}] Failed to refresh token`)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
}
// Update the token in the database
await db
.update(account)
.set({
accessToken: refreshedToken,
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), // Default 1 hour expiry
updatedAt: new Date(),
})
.where(eq(account.id, credentialId))
logger.info(`[${requestId}] Successfully refreshed access token`)
accessToken = refreshedToken
} catch (error) {
logger.error(`[${requestId}] Error refreshing token:`, error)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
}
} else if (!accessToken) {
logger.error(`[${requestId}] Missing access token for credential: ${credential.id}`)
return NextResponse.json({ error: 'Missing access token' }, { status: 401 })
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Fetch specific label from Gmail API

View File

@@ -2,9 +2,9 @@ import { NextRequest, NextResponse } from 'next/server'
import { and, eq } from 'drizzle-orm'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { refreshOAuthToken } from '@/lib/oauth'
import { db } from '@/db'
import { account } from '@/db/schema'
import { refreshAccessTokenIfNeeded } from '../../utils'
const logger = createLogger('GmailLabelsAPI')
@@ -49,45 +49,11 @@ export async function GET(request: NextRequest) {
`[${requestId}] Using credential: ${credential.id}, provider: ${credential.providerId}`
)
// Check if we need to refresh the token
const expiresAt = credential.accessTokenExpiresAt
const now = new Date()
const needsRefresh = !expiresAt || expiresAt <= now
// Refresh access token if needed using the utility function
const accessToken = await refreshAccessTokenIfNeeded(credentialId, session.user.id, requestId)
let accessToken = credential.accessToken
if (needsRefresh && credential.refreshToken) {
logger.info(`[${requestId}] Token expired, attempting to refresh`)
try {
const refreshedToken = await refreshOAuthToken(
credential.providerId,
credential.refreshToken
)
if (!refreshedToken) {
logger.error(`[${requestId}] Failed to refresh token`)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
}
// Update the token in the database
await db
.update(account)
.set({
accessToken: refreshedToken,
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), // Default 1 hour expiry
updatedAt: new Date(),
})
.where(eq(account.id, credentialId))
logger.info(`[${requestId}] Successfully refreshed access token`)
accessToken = refreshedToken
} catch (error) {
logger.error(`[${requestId}] Error refreshing token:`, error)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })
}
} else if (!accessToken) {
logger.error(`[${requestId}] Missing access token for credential: ${credential.id}`)
return NextResponse.json({ error: 'Missing access token' }, { status: 401 })
if (!accessToken) {
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
}
// Fetch labels from Gmail API

View File

@@ -78,20 +78,22 @@ export async function POST(request: NextRequest) {
if (needsRefresh && credential.refreshToken) {
try {
const refreshedToken = await refreshOAuthToken(
const refreshResult = await refreshOAuthToken(
credential.providerId,
credential.refreshToken
)
if (!refreshedToken) {
if (!refreshResult) {
throw new Error('Failed to refresh token')
}
const { accessToken: refreshedToken, expiresIn } = refreshResult
await db
.update(account)
.set({
accessToken: refreshedToken,
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), // Default 1 hour expiry
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry
updatedAt: new Date(),
})
.where(eq(account.id, credentialId))
@@ -169,26 +171,29 @@ export async function GET(request: NextRequest) {
try {
// Refresh the token using the centralized utility
const refreshedToken = await refreshOAuthToken(
const refreshResult = await refreshOAuthToken(
credential.providerId,
credential.refreshToken
)
if (refreshedToken) {
logger.info(`[${requestId}] Token refreshed successfully`)
// Update the token in the database
await db
.update(account)
.set({
accessToken: refreshedToken,
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), // Default 1 hour expiry
updatedAt: new Date(),
})
.where(eq(account.id, credentialId))
accessToken = refreshedToken
if (!refreshResult) {
throw new Error('Failed to refresh token')
}
const { accessToken: refreshedToken, expiresIn } = refreshResult
logger.info(`[${requestId}] Token refreshed successfully`)
// Update the token in the database with the correct expiration time
await db
.update(account)
.set({
accessToken: refreshedToken,
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Use provider's expiry
updatedAt: new Date(),
})
.where(eq(account.id, credentialId))
accessToken = refreshedToken
} catch (refreshError) {
logger.error(`[${requestId}] Error refreshing token`, refreshError)
return NextResponse.json({ error: 'Failed to refresh access token' }, { status: 401 })

View File

@@ -0,0 +1,150 @@
import { and, eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console-logger'
import { refreshOAuthToken } from '@/lib/oauth'
import { db } from '@/db'
import { account } from '@/db/schema'
const logger = createLogger('OAuthUtils')
export async function getOAuthToken(userId: string, providerId: string): Promise<string | null> {
const connections = await db
.select({
id: account.id,
accessToken: account.accessToken,
refreshToken: account.refreshToken,
accessTokenExpiresAt: account.accessTokenExpiresAt,
})
.from(account)
.where(and(eq(account.userId, userId), eq(account.providerId, providerId)))
.orderBy(account.createdAt)
.limit(1)
if (connections.length === 0) {
logger.warn(`No OAuth token found for user ${userId}, provider ${providerId}`)
return null
}
const credential = connections[0]
// Check if we have a valid access token
if (!credential.accessToken) {
logger.warn(`Access token is null for user ${userId}, provider ${providerId}`)
return null
}
// Check if the token is expired and needs refreshing
const now = new Date()
const tokenExpiry = credential.accessTokenExpiresAt
const needsRefresh = tokenExpiry && tokenExpiry < now && !!credential.refreshToken
if (needsRefresh) {
logger.info(
`Access token expired for user ${userId}, provider ${providerId}. Attempting to refresh.`
)
try {
// Use the existing refreshOAuthToken function
const refreshResult = await refreshOAuthToken(providerId, credential.refreshToken!)
if (!refreshResult) {
logger.error(`Failed to refresh token for user ${userId}, provider ${providerId}`)
return null
}
const { accessToken, expiresIn } = refreshResult
// Update the token in the database with the actual expiration time from the provider
await db
.update(account)
.set({
accessToken,
accessTokenExpiresAt: new Date(Date.now() + expiresIn * 1000), // Convert seconds to milliseconds
updatedAt: new Date(),
})
.where(eq(account.id, credential.id))
logger.info(`Successfully refreshed token for user ${userId}, provider ${providerId}`)
return accessToken
} catch (error) {
logger.error(`Error refreshing token for user ${userId}, provider ${providerId}`, error)
return null
}
}
logger.info(`Found valid OAuth token for user ${userId}, provider ${providerId}`)
return credential.accessToken
}
/**
* Refreshes an OAuth token if needed based on credential information
* @param credentialId The ID of the credential to check and potentially refresh
* @param userId The user ID who owns the credential (for security verification)
* @param requestId Optional request ID for log correlation
* @returns The valid access token or null if refresh fails
*/
export async function refreshAccessTokenIfNeeded(
credentialId: string,
userId: string,
requestId?: string
): Promise<string | null> {
// Get the credential from the database
const credentials = await db
.select()
.from(account)
.where(and(eq(account.id, credentialId), eq(account.userId, userId)))
.limit(1)
if (!credentials.length) {
logger.warn(`[${requestId || ''}] Credential not found: ${credentialId}`)
return null
}
const credential = credentials[0]
// Check if we need to refresh the token
const expiresAt = credential.accessTokenExpiresAt
const now = new Date()
const needsRefresh = !expiresAt || expiresAt <= now
let accessToken = credential.accessToken
if (needsRefresh && credential.refreshToken) {
logger.info(
`[${requestId || ''}] Token expired, attempting to refresh for credential: ${credentialId}`
)
try {
const refreshedToken = await refreshOAuthToken(credential.providerId, credential.refreshToken)
if (!refreshedToken) {
logger.error(`[${requestId || ''}] Failed to refresh token for credential: ${credentialId}`)
return null
}
// Update the token in the database
await db
.update(account)
.set({
accessToken: refreshedToken.accessToken,
accessTokenExpiresAt: new Date(Date.now() + refreshedToken.expiresIn * 1000), // Default 1 hour expiry
updatedAt: new Date(),
})
.where(eq(account.id, credentialId))
logger.info(
`[${requestId || ''}] Successfully refreshed access token for credential: ${credentialId}`
)
return refreshedToken.accessToken
} catch (error) {
logger.error(
`[${requestId || ''}] Error refreshing token for credential: ${credentialId}`,
error
)
return null
}
} else if (!accessToken) {
logger.error(`[${requestId || ''}] Missing access token for credential: ${credential.id}`)
return null
}
return accessToken
}

View File

@@ -5,6 +5,7 @@ import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { db } from '@/db'
import { webhook, workflow } from '@/db/schema'
import { getOAuthToken } from '../auth/oauth/utils'
const logger = createLogger('WebhooksAPI')
@@ -54,17 +55,17 @@ export async function GET(request: NextRequest) {
}
}
// Create a new webhook
// Create or Update a webhook
export async function POST(request: NextRequest) {
const requestId = crypto.randomUUID().slice(0, 8)
const userId = (await getSession())?.user?.id
if (!userId) {
logger.warn(`[${requestId}] Unauthorized webhook creation attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized webhook creation attempt`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const { workflowId, path, provider, providerConfig } = body
@@ -77,16 +78,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}
logger.debug(`[${requestId}] Creating webhook for workflow ${workflowId}`, {
path,
provider: provider || 'generic',
})
// Check if the workflow belongs to the user
const workflows = await db
.select()
.select({ id: workflow.id }) // Select only necessary field
.from(workflow)
.where(and(eq(workflow.id, workflowId), eq(workflow.userId, session.user.id)))
.where(and(eq(workflow.id, workflowId), eq(workflow.userId, userId)))
.limit(1)
if (workflows.length === 0) {
@@ -95,19 +91,19 @@ export async function POST(request: NextRequest) {
}
// Check if a webhook with the same path already exists
const existingWebhooks = await db.select().from(webhook).where(eq(webhook.path, path)).limit(1)
const existingWebhooks = await db
.select({ id: webhook.id, workflowId: webhook.workflowId })
.from(webhook)
.where(eq(webhook.path, path))
.limit(1)
let savedWebhook: any = null // Variable to hold the result of save/update
// If a webhook with the same path exists but belongs to a different workflow, return an error
if (existingWebhooks.length > 0 && existingWebhooks[0].workflowId !== workflowId) {
logger.warn(`[${requestId}] Webhook path conflict: ${path}`, {
existingWorkflowId: existingWebhooks[0].workflowId,
requestedWorkflowId: workflowId,
})
logger.warn(`[${requestId}] Webhook path conflict: ${path}`)
return NextResponse.json(
{
error: 'Webhook path already exists. Please use a different path.',
code: 'PATH_EXISTS',
},
{ error: 'Webhook path already exists.', code: 'PATH_EXISTS' },
{ status: 409 }
)
}
@@ -115,8 +111,7 @@ export async function POST(request: NextRequest) {
// If a webhook with the same path and workflowId exists, update it
if (existingWebhooks.length > 0 && existingWebhooks[0].workflowId === workflowId) {
logger.info(`[${requestId}] Updating existing webhook for path: ${path}`)
const updatedWebhook = await db
const updatedResult = await db
.update(webhook)
.set({
provider,
@@ -126,35 +121,171 @@ export async function POST(request: NextRequest) {
})
.where(eq(webhook.id, existingWebhooks[0].id))
.returning()
return NextResponse.json({ webhook: updatedWebhook[0] }, { status: 200 })
savedWebhook = updatedResult[0]
} else {
// Create a new webhook
const webhookId = nanoid()
logger.info(`[${requestId}] Creating new webhook with ID: ${webhookId}`)
const newResult = await db
.insert(webhook)
.values({
id: webhookId,
workflowId,
path,
provider,
providerConfig,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
savedWebhook = newResult[0]
}
// Create a new webhook
const webhookId = nanoid()
logger.info(`[${requestId}] Creating new webhook with ID: ${webhookId}`, {
path,
workflowId,
provider: provider || 'generic',
// --- Attempt to create webhook in Airtable if provider is 'airtable' ---
if (savedWebhook && provider === 'airtable') {
logger.info(
`[${requestId}] Airtable provider detected. Attempting to create webhook in Airtable.`
)
try {
await createAirtableWebhookSubscription(request, userId, savedWebhook, requestId)
} catch (err) {
logger.error(`[${requestId}] Error creating Airtable webhook`, err)
return NextResponse.json(
{
error: 'Failed to create webhook in Airtable',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End Airtable specific logic ---
const status = existingWebhooks.length > 0 ? 200 : 201
return NextResponse.json({ webhook: savedWebhook }, { status })
} catch (error: any) {
logger.error(`[${requestId}] Error creating/updating webhook`, {
message: error.message,
stack: error.stack,
})
const newWebhook = await db
.insert(webhook)
.values({
id: webhookId,
workflowId,
path,
provider,
providerConfig,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning()
return NextResponse.json({ webhook: newWebhook[0] }, { status: 201 })
} catch (error) {
logger.error(`[${requestId}] Error creating webhook`, error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
// Helper function to create the webhook subscription in Airtable
async function createAirtableWebhookSubscription(
request: NextRequest,
userId: string,
webhookData: any,
requestId: string
) {
try {
const { path, providerConfig } = webhookData
const { baseId, tableId, includeCellValuesInFieldIds } = providerConfig || {}
if (!baseId || !tableId) {
logger.warn(`[${requestId}] Missing baseId or tableId for Airtable webhook creation.`, {
webhookId: webhookData.id,
})
return // Cannot proceed without base/table IDs
}
const accessToken = await getOAuthToken(userId, 'airtable') // Use 'airtable' as the providerId key
if (!accessToken) {
logger.warn(
`[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.`
)
return
}
const requestOrigin = new URL(request.url).origin
// Ensure origin does not point to localhost for external API calls
const effectiveOrigin = requestOrigin.includes('localhost')
? process.env.NEXT_PUBLIC_APP_URL || requestOrigin // Use env var if available, fallback to original
: requestOrigin
const notificationUrl = `${effectiveOrigin}/api/webhooks/trigger/${path}`
if (effectiveOrigin !== requestOrigin) {
logger.debug(
`[${requestId}] Remapped localhost origin to ${effectiveOrigin} for notificationUrl`
)
}
const airtableApiUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks`
const specification: any = {
options: {
filters: {
dataTypes: ['tableData'], // Watch table data changes
recordChangeScope: tableId, // Watch only the specified table
},
},
}
// Conditionally add the 'includes' field based on the config
if (includeCellValuesInFieldIds === 'all') {
specification.options.includes = {
includeCellValuesInFieldIds: 'all',
}
}
const requestBody: any = {
notificationUrl: notificationUrl,
specification: specification,
}
const airtableResponse = await fetch(airtableApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
})
// Airtable often returns 200 OK even for errors in the body, check payload
const responseBody = await airtableResponse.json()
if (!airtableResponse.ok || responseBody.error) {
const errorMessage =
responseBody.error?.message || responseBody.error || 'Unknown Airtable API error'
const errorType = responseBody.error?.type
logger.error(
`[${requestId}] Failed to create webhook in Airtable for webhook ${webhookData.id}. Status: ${airtableResponse.status}`,
{ type: errorType, message: errorMessage, response: responseBody }
)
} else {
logger.info(
`[${requestId}] Successfully created webhook in Airtable for webhook ${webhookData.id}.`,
{ airtableWebhookId: responseBody.id }
)
// Store the airtableWebhookId (responseBody.id) within the providerConfig
try {
const currentConfig = (webhookData.providerConfig as Record<string, any>) || {}
const updatedConfig = {
...currentConfig,
externalId: responseBody.id, // Add/update the externalId
}
await db
.update(webhook)
.set({ providerConfig: updatedConfig, updatedAt: new Date() })
.where(eq(webhook.id, webhookData.id))
} catch (dbError: any) {
logger.error(
`[${requestId}] Failed to store externalId in providerConfig for webhook ${webhookData.id}.`,
dbError
)
// Even if saving fails, the webhook exists in Airtable. Log and continue.
}
}
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Airtable webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
}
}

View File

@@ -273,6 +273,82 @@ export async function GET(request: NextRequest) {
},
})
}
// Add the Airtable test case
case 'airtable': {
const baseId = providerConfig.baseId
const tableId = providerConfig.tableId
const webhookSecret = providerConfig.webhookSecret
if (!baseId || !tableId) {
logger.warn(`[${requestId}] Airtable webhook missing Base ID or Table ID: ${webhookId}`)
return NextResponse.json(
{
success: false,
error: 'Webhook configuration is incomplete (missing Base ID or Table ID)',
},
{ status: 400 }
)
}
// Define a sample payload structure
const samplePayload = {
webhook: {
id: 'whiYOUR_WEBHOOK_ID',
},
base: {
id: baseId,
},
payloadFormat: 'v0',
actionMetadata: {
source: 'tableOrViewChange', // Example source
sourceMetadata: {},
},
payloads: [
{
timestamp: new Date().toISOString(),
baseTransactionNumber: Date.now(), // Example transaction number
changedTablesById: {
[tableId]: {
// Example changes - structure may vary based on actual event
changedRecordsById: {
recSAMPLEID1: {
current: { cellValuesByFieldId: { fldSAMPLEID: 'New Value' } },
previous: { cellValuesByFieldId: { fldSAMPLEID: 'Old Value' } },
},
},
changedFieldsById: {},
changedViewsById: {},
},
},
},
],
}
// Generate sample curl command
let curlCommand = `curl -X POST "${webhookUrl}" -H "Content-Type: application/json"`
curlCommand += ` -d '${JSON.stringify(samplePayload, null, 2)}'`
logger.info(`[${requestId}] Airtable webhook test successful: ${webhookId}`)
return NextResponse.json({
success: true,
webhook: {
id: foundWebhook.id,
url: webhookUrl,
baseId: baseId,
tableId: tableId,
secretConfigured: !!webhookSecret,
isActive: foundWebhook.isActive,
},
message:
'Airtable webhook configuration appears valid. Use the sample curl command to manually send a test payload to your webhook URL.',
test: {
curlCommand: curlCommand,
samplePayload: samplePayload,
},
})
}
default: {
// Generic webhook test
logger.info(`[${requestId}] Generic webhook test successful: ${webhookId}`)

View File

@@ -3,8 +3,8 @@
*
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { NextRequest } from 'next/server'
import {
createMockRequest,
mockExecutionDependencies,
@@ -350,9 +350,6 @@ describe('Webhook Trigger API Route', () => {
// Call the handler
const response = await POST(req, { params })
// Verify that duplicate was checked
expect(hasProcessedMessageMock).toHaveBeenCalled()
// Verify executor was not called with duplicate request
expect(executeMock).not.toHaveBeenCalled()
})

File diff suppressed because it is too large Load Diff

View File

@@ -61,6 +61,7 @@ const SCOPE_DESCRIPTIONS: Record<string, string> = {
'offline.access': 'Access your account when you are not using the application',
'data.records:read': 'Read your records',
'data.records:write': 'Write to your records',
'webhook:manage': 'Manage your webhooks',
}
// Convert OAuth scope to user-friendly description

View File

@@ -0,0 +1,125 @@
import React from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { ConfigField } from '../ui/config-field'
import { ConfigSection } from '../ui/config-section'
import { InstructionsSection } from '../ui/instructions-section'
import { TestResultDisplay as WebhookTestResult } from '../ui/test-result'
interface AirtableConfigProps {
baseId: string
setBaseId: (value: string) => void
tableId: string
setTableId: (value: string) => void
includeCellValues: boolean
setIncludeCellValues: (value: boolean) => void
isLoadingToken: boolean
testResult: any // Define a more specific type if possible
copied: string | null
copyToClipboard: (text: string, type: string) => void
testWebhook?: () => void // Optional test function
webhookId?: string // Webhook ID to enable testing
}
export function AirtableConfig({
baseId,
setBaseId,
tableId,
setTableId,
includeCellValues,
setIncludeCellValues,
isLoadingToken,
testResult,
copied,
copyToClipboard,
testWebhook, // We might need this later for instructions
webhookId, // We might need this later for instructions
}: AirtableConfigProps) {
return (
<div className="space-y-4">
<ConfigSection title="Airtable Configuration">
<ConfigField
id="airtable-base-id"
label="Base ID *"
description="The ID of the Airtable Base this webhook will monitor."
>
{isLoadingToken ? (
<Skeleton className="h-10 w-full" />
) : (
<Input
id="airtable-base-id"
value={baseId}
onChange={(e) => setBaseId(e.target.value)}
placeholder="appXXXXXXXXXXXXXX"
required
/>
)}
</ConfigField>
<ConfigField
id="airtable-table-id"
label="Table ID *"
description="The ID of the table within the Base that the webhook will monitor."
>
{isLoadingToken ? (
<Skeleton className="h-10 w-full" />
) : (
<Input
id="airtable-table-id"
value={tableId}
onChange={(e) => setTableId(e.target.value)}
placeholder="tblXXXXXXXXXXXXXX"
required
/>
)}
</ConfigField>
<div className="flex items-center justify-between rounded-lg border border-border p-3 shadow-sm bg-background">
<div className="space-y-0.5 pr-4">
<Label htmlFor="include-cell-values" className="font-normal">
Include Full Record Data
</Label>
<p className="text-xs text-muted-foreground">
Enable to receive the complete record data in the payload, not just changes.
</p>
</div>
{isLoadingToken ? (
<Skeleton className="h-5 w-9" />
) : (
<Switch
id="include-cell-values"
checked={includeCellValues}
onCheckedChange={setIncludeCellValues}
disabled={isLoadingToken}
/>
)}
</div>
</ConfigSection>
{testResult && (
<WebhookTestResult
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
/>
)}
<InstructionsSection tip="Airtable webhooks monitor changes in your base/table and trigger your workflow.">
<ol className="list-decimal list-inside space-y-1">
<li>Ensure you have provided the correct Base ID and Table ID above.</li>
<li>
Sim Studio will automatically configure the webhook in your Airtable account when you
save.
</li>
<li>Any changes made to records in the specified table will trigger this workflow.</li>
<li>
If 'Include Full Record Data' is enabled, the entire record will be sent; otherwise,
only the changed fields are sent.
</li>
</ol>
</InstructionsSection>
</div>
)
}

View File

@@ -1,6 +1,10 @@
import { Terminal } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { CodeBlock } from '@/components/ui/code-block'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { CopyableField } from '../ui/copyable'
import { ConfigField } from '../ui/config-field'
import { ConfigSection } from '../ui/config-section'
import { InstructionsSection } from '../ui/instructions-section'
import { TestResultDisplay } from '../ui/test-result'
interface DiscordConfigProps {
@@ -19,6 +23,16 @@ interface DiscordConfigProps {
testWebhook: () => Promise<void>
}
const examplePayload = JSON.stringify(
{
content: 'Hello from Sim Studio!',
username: 'Optional Custom Name',
avatar_url: 'https://example.com/avatar.png',
},
null,
2
)
export function DiscordConfig({
webhookName,
setWebhookName,
@@ -28,109 +42,84 @@ export function DiscordConfig({
testResult,
copied,
copyToClipboard,
testWebhook,
testWebhook, // Passed to TestResultDisplay
}: DiscordConfigProps) {
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="discord-webhook-name">Webhook Name (Optional)</Label>
<Input
<ConfigSection title="Discord Appearance (Optional)">
<ConfigField
id="discord-webhook-name"
value={webhookName}
onChange={(e) => setWebhookName(e.target.value)}
placeholder="Enter a name for your webhook"
disabled={isLoadingToken}
/>
<p className="text-xs text-muted-foreground">
This name will be displayed as the sender of messages in Discord.
</p>
</div>
label="Webhook Name"
description="This name will be displayed as the sender of messages in Discord."
>
<Input
id="discord-webhook-name"
value={webhookName}
onChange={(e) => setWebhookName(e.target.value)}
placeholder="Sim Studio Bot"
disabled={isLoadingToken}
/>
</ConfigField>
<div className="space-y-2">
<Label htmlFor="discord-avatar-url">Avatar URL (Optional)</Label>
<Input
<ConfigField
id="discord-avatar-url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder="https://example.com/avatar.png"
disabled={isLoadingToken}
/>
<p className="text-xs text-muted-foreground">
URL to an image that will be used as the webhook's avatar.
</p>
</div>
label="Avatar URL"
description="URL to an image that will be used as the webhook's avatar."
>
<Input
id="discord-avatar-url"
value={avatarUrl}
onChange={(e) => setAvatarUrl(e.target.value)}
placeholder="https://example.com/avatar.png"
disabled={isLoadingToken}
type="url"
/>
</ConfigField>
</ConfigSection>
<TestResultDisplay
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
showCurlCommand={true}
showCurlCommand={true} // Discord can be tested via curl
/>
<div className="space-y-2">
<h4 className="font-medium">Setup Instructions</h4>
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>Open Discord and go to the server where you want to add the webhook</li>
<li>Click the gear icon next to a channel to open Channel Settings</li>
<li>Navigate to "Integrations" {'>'} "Webhooks"</li>
<li>Click "New Webhook"</li>
<li>Give your webhook a name and choose an avatar (optional)</li>
<li>Select the channel the webhook will post to</li>
<li>Click "Copy Webhook URL" and save it for your records</li>
<li>Click "Save"</li>
<li>Use the Webhook URL above to receive messages from Discord</li>
<InstructionsSection
title="Receiving Events from Discord (Incoming Webhook)"
tip="Create a webhook in Discord and paste its URL into the Webhook URL field above."
>
<ol className="list-decimal list-inside space-y-1">
<li>Go to Discord Server Settings {'>'} Integrations.</li>
<li>Click "Webhooks" then "New Webhook".</li>
<li>Customize the name and channel.</li>
<li>Click "Copy Webhook URL".</li>
<li>
Paste the copied Discord URL into the main <strong>Webhook URL</strong> field above.
</li>
<li>Your workflow triggers when Discord sends an event to that URL.</li>
</ol>
</div>
</InstructionsSection>
<div className="bg-indigo-50 dark:bg-indigo-950 p-3 rounded-md mt-3 border border-indigo-200 dark:border-indigo-800">
<h5 className="text-sm font-medium text-indigo-800 dark:text-indigo-300">
Discord Webhook Features
</h5>
<ul className="mt-1 space-y-1">
<li className="flex items-start">
<span className="text-indigo-500 dark:text-indigo-400 mr-2"></span>
<span className="text-sm text-indigo-700 dark:text-indigo-300">
Customize message appearance with embeds and formatting
</span>
</li>
<li className="flex items-start">
<span className="text-indigo-500 dark:text-indigo-400 mr-2"></span>
<span className="text-sm text-indigo-700 dark:text-indigo-300">
Send messages with different usernames and avatars per request
</span>
</li>
<li className="flex items-start">
<span className="text-indigo-500 dark:text-indigo-400 mr-2"></span>
<span className="text-sm text-indigo-700 dark:text-indigo-300">
Discord secures webhooks by keeping URLs private - protect your webhook URL
</span>
</li>
</ul>
</div>
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md mt-3 border border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-700 dark:text-gray-300 flex items-center">
<span className="text-gray-400 dark:text-gray-500 mr-2">💡</span>
You can use this webhook to receive notifications from Discord or to send messages to your
Discord channel.
<InstructionsSection title="Sending Messages to Discord (Outgoing via this URL)">
<p>
To send messages <i>to</i> Discord using the Sim Studio Webhook URL (above), make a POST
request with a JSON body like this:
</p>
</div>
<CodeBlock language="json" code={examplePayload} className="mt-2 text-sm" />
<ul className="list-disc list-outside space-y-1 pl-4 mt-3">
<li>Customize message appearance with embeds (see Discord docs).</li>
<li>Override the default username/avatar per request if needed.</li>
</ul>
</InstructionsSection>
<div className="bg-purple-50 dark:bg-purple-950 p-3 rounded-md mt-3 border border-purple-200 dark:border-purple-800">
<h5 className="text-sm font-medium text-purple-800 dark:text-purple-300">
Example POST Request
</h5>
<pre className="mt-2 text-xs bg-black/5 dark:bg-white/5 p-2 rounded overflow-x-auto">
{`POST /api/webhooks/{your-webhook-id} HTTP/1.1
Content-Type: application/json
{
"content": "Hello from Sim Studio!",
"username": "Custom Bot Name",
"avatar_url": "https://example.com/avatar.png"
}`}
</pre>
</div>
<Alert>
<Terminal className="h-4 w-4" />
<AlertTitle>Security Note</AlertTitle>
<AlertDescription>
The Sim Studio Webhook URL allows sending messages <i>to</i> Discord. Treat it like a
password. Don't share it publicly.
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -1,7 +1,10 @@
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ConfigField } from '../ui/config-field'
import { ConfigSection } from '../ui/config-section'
import { CopyableField } from '../ui/copyable'
import { InstructionsSection } from '../ui/instructions-section'
import { TestResultDisplay } from '../ui/test-result'
interface GenericConfigProps {
@@ -40,64 +43,65 @@ export function GenericConfig({
}: GenericConfigProps) {
return (
<div className="space-y-4">
<div className="flex items-center space-x-2 mb-3">
<div className="flex items-center h-3.5 space-x-2">
<ConfigSection title="Authentication">
<div className="flex items-center space-x-2">
<Checkbox
id="require-auth"
checked={requireAuth}
onCheckedChange={(checked) => setRequireAuth(checked as boolean)}
className="translate-y-[1px]" // Align checkbox better with label
/>
<Label htmlFor="require-auth" className="text-sm font-medium cursor-pointer">
Require Authentication
</Label>
</div>
</div>
{requireAuth && (
<div className="space-y-4 ml-5 border-l-2 pl-4 border-gray-200 dark:border-gray-700">
<CopyableField
id="auth-token"
label="Authentication Token"
value={generalToken}
onChange={setGeneralToken}
placeholder="Enter an auth token"
description="This token will be used to authenticate requests to your webhook (via Bearer token)."
isLoading={isLoadingToken}
copied={copied}
copyType="general-token"
copyToClipboard={copyToClipboard}
/>
{requireAuth && (
<div className="space-y-4 ml-5 border-l-2 pl-4 border-border dark:border-border/50">
<ConfigField id="auth-token" label="Authentication Token">
<CopyableField
id="auth-token"
value={generalToken}
onChange={setGeneralToken}
placeholder="Enter an auth token"
description="Used to authenticate requests via Bearer token or custom header."
isLoading={isLoadingToken}
copied={copied}
copyType="general-token"
copyToClipboard={copyToClipboard}
/>
</ConfigField>
<div className="space-y-2">
<Label htmlFor="header-name">Secret Header Name (Optional)</Label>
<Input
<ConfigField
id="header-name"
value={secretHeaderName}
onChange={(e) => setSecretHeaderName(e.target.value)}
placeholder="X-Secret-Key"
className="flex-1"
/>
<p className="text-xs text-muted-foreground">
Custom HTTP header name for passing the authentication token instead of using Bearer
authentication.
</p>
label="Secret Header Name (Optional)"
description="Custom HTTP header name for the auth token (e.g., X-Secret-Key). If blank, use 'Authorization: Bearer TOKEN'."
>
<Input
id="header-name"
value={secretHeaderName}
onChange={(e) => setSecretHeaderName(e.target.value)}
placeholder="X-Secret-Key"
/>
</ConfigField>
</div>
</div>
)}
)}
</ConfigSection>
<div className="space-y-2">
<Label htmlFor="allowed-ips">Allowed IP Addresses (Optional)</Label>
<Input
<ConfigSection title="Network">
<ConfigField
id="allowed-ips"
value={allowedIps}
onChange={(e) => setAllowedIps(e.target.value)}
placeholder="192.168.1.1, 10.0.0.1"
className="flex-1"
/>
<p className="text-xs text-muted-foreground">
Comma-separated list of IP addresses that are allowed to access this webhook.
</p>
</div>
label="Allowed IP Addresses (Optional)"
description="Comma-separated list of IP addresses allowed to access this webhook."
>
<Input
id="allowed-ips"
value={allowedIps}
onChange={(e) => setAllowedIps(e.target.value)}
placeholder="192.168.1.1, 10.0.0.1"
/>
</ConfigField>
</ConfigSection>
<TestResultDisplay
testResult={testResult}
@@ -106,29 +110,21 @@ export function GenericConfig({
showCurlCommand={true}
/>
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md mt-3 border border-gray-200 dark:border-gray-700">
<h4 className="font-medium text-sm mb-2">Setup Instructions</h4>
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>Copy the Webhook URL above</li>
<li>Configure your service to send HTTP POST requests to this URL</li>
<InstructionsSection tip="The webhook receives HTTP POST requests and passes the data to your workflow.">
<ol className="list-decimal list-inside space-y-1">
<li>Copy the Webhook URL provided above.</li>
<li>Configure your external service to send HTTP POST requests to this URL.</li>
{requireAuth && (
<>
<li>
{secretHeaderName
? `Add the "${secretHeaderName}" header with your token to all requests`
: 'Add an "Authorization: Bearer YOUR_TOKEN" header to all requests'}
</li>
</>
<li>
Include your authentication token in requests using either the
{secretHeaderName
? ` "${secretHeaderName}" header`
: ' "Authorization: Bearer YOUR_TOKEN" header'}
.
</li>
)}
</ol>
<div className="mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-700 dark:text-gray-300 flex items-center">
<span className="text-gray-400 dark:text-gray-500 mr-2">💡</span>
The webhook will receive all HTTP POST requests and pass the data to your workflow.
</p>
</div>
</div>
</InstructionsSection>
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { Label } from '@/components/ui/label'
import { ShieldCheck } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
Select,
SelectContent,
@@ -6,7 +7,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ConfigField } from '../ui/config-field'
import { ConfigSection } from '../ui/config-section'
import { CopyableField } from '../ui/copyable'
import { InstructionsSection } from '../ui/instructions-section'
import { TestResultDisplay } from '../ui/test-result'
interface GithubConfigProps {
@@ -41,107 +45,98 @@ export function GithubConfig({
}: GithubConfigProps) {
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="github-content-type">Content Type</Label>
<Select value={contentType} onValueChange={setContentType}>
<SelectTrigger id="github-content-type">
<SelectValue placeholder="Select content type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="application/json">application/json</SelectItem>
<SelectItem value="application/x-www-form-urlencoded">
application/x-www-form-urlencoded
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Format GitHub will use when sending the webhook payload.
</p>
</div>
<ConfigSection title="GitHub Webhook Settings">
<ConfigField
id="github-content-type"
label="Content Type"
description="Format GitHub will use when sending the webhook payload."
>
<Select value={contentType} onValueChange={setContentType} disabled={isLoadingToken}>
<SelectTrigger id="github-content-type">
<SelectValue placeholder="Select content type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="application/json">application/json</SelectItem>
<SelectItem value="application/x-www-form-urlencoded">
application/x-www-form-urlencoded
</SelectItem>
</SelectContent>
</Select>
</ConfigField>
<CopyableField
id="webhook-secret"
label="Webhook Secret (Optional but Recommended)"
value={webhookSecret}
onChange={setWebhookSecret}
placeholder="Enter a secret for GitHub webhook"
description="A secret token to validate that webhook deliveries are coming from GitHub."
isLoading={isLoadingToken}
copied={copied}
copyType="github-secret"
copyToClipboard={copyToClipboard}
/>
<ConfigField id="webhook-secret" label="Webhook Secret (Recommended)">
<CopyableField
id="webhook-secret"
value={webhookSecret}
onChange={setWebhookSecret}
placeholder="Generate or enter a strong secret"
description="Validates that webhook deliveries originate from GitHub."
isLoading={isLoadingToken}
copied={copied}
copyType="github-secret"
copyToClipboard={copyToClipboard}
/>
</ConfigField>
<div className="space-y-2">
<Label htmlFor="github-ssl-verification">SSL Verification</Label>
<Select value={sslVerification} onValueChange={setSslVerification}>
<SelectTrigger id="github-ssl-verification">
<SelectValue placeholder="Select SSL verification option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">Enabled (Recommended)</SelectItem>
<SelectItem value="disabled">Disabled (Not recommended)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
GitHub will verify SSL certificates when delivering webhooks.
</p>
</div>
<ConfigField
id="github-ssl-verification"
label="SSL Verification"
description="GitHub verifies SSL certificates when delivering webhooks."
>
<Select
value={sslVerification}
onValueChange={setSslVerification}
disabled={isLoadingToken}
>
<SelectTrigger id="github-ssl-verification">
<SelectValue placeholder="Select SSL verification option" />
</SelectTrigger>
<SelectContent>
<SelectItem value="enabled">Enabled (Recommended)</SelectItem>
<SelectItem value="disabled">Disabled (Use with caution)</SelectItem>
</SelectContent>
</Select>
</ConfigField>
</ConfigSection>
<TestResultDisplay
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
showCurlCommand={true}
showCurlCommand={true} // GitHub webhooks can be tested
/>
<div className="space-y-2">
<h4 className="font-medium">Setup Instructions</h4>
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>Go to your GitHub repository</li>
<li>Navigate to Settings {'>'} Webhooks</li>
<li>Click "Add webhook"</li>
<li>Enter the Webhook URL shown above as the "Payload URL"</li>
<li>Set Content type to "{contentType}"</li>
{webhookSecret && (
<li>Enter the same secret shown above in the "Secret" field for validation</li>
)}
<li>Choose SSL verification</li>
<InstructionsSection tip="GitHub will send a ping event to verify after you add the webhook.">
<ol className="list-decimal list-inside space-y-1">
<li>
Choose which events trigger the webhook (e.g., "Just the push event" or "Send me
everything")
Go to your GitHub Repository {'>'} Settings {'>'} Webhooks.
</li>
<li>Ensure "Active" is checked and click "Add webhook"</li>
<li>Click "Add webhook".</li>
<li>
Paste the <strong>Webhook URL</strong> (from above) into the "Payload URL" field.
</li>
<li>Select "{contentType}" as the Content type.</li>
{webhookSecret && (
<li>
Enter the <strong>Webhook Secret</strong> (from above) into the "Secret" field.
</li>
)}
<li>Set SSL verification according to your selection above.</li>
<li>Choose which events should trigger this webhook.</li>
<li>Ensure "Active" is checked and click "Add webhook".</li>
</ol>
</div>
</InstructionsSection>
<div className="bg-blue-50 dark:bg-blue-950 p-3 rounded-md mt-3 border border-blue-200 dark:border-blue-800">
<h5 className="text-sm font-medium text-blue-800 dark:text-blue-300">
Security Best Practices
</h5>
<ul className="mt-1 space-y-1">
<li className="flex items-start">
<span className="text-blue-500 dark:text-blue-400 mr-2"></span>
<span className="text-sm text-blue-700 dark:text-blue-300">
Always use a secret token to validate requests from GitHub
</span>
</li>
<li className="flex items-start">
<span className="text-blue-500 dark:text-blue-400 mr-2"></span>
<span className="text-sm text-blue-700 dark:text-blue-300">
Keep SSL verification enabled unless you have a specific reason to disable it
</span>
</li>
</ul>
</div>
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md mt-3 border border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-700 dark:text-gray-300 flex items-center">
<span className="text-gray-400 dark:text-gray-500 mr-2">💡</span>
After saving, GitHub will send a ping event to verify your webhook. You can view delivery
details and redeliver events from the webhook settings.
</p>
</div>
<Alert>
<ShieldCheck className="h-4 w-4" />
<AlertTitle>Security Recommendations</AlertTitle>
<AlertDescription>
<ul className="list-disc list-outside pl-4 space-y-1 mt-1">
<li>Always use a strong, unique secret token to validate GitHub requests.</li>
<li>Keep SSL verification enabled unless absolutely necessary.</li>
</ul>
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -1,6 +1,10 @@
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { SlackIcon } from '@/components/icons'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { CodeBlock } from '@/components/ui/code-block'
import { ConfigField } from '../ui/config-field'
import { ConfigSection } from '../ui/config-section'
import { CopyableField } from '../ui/copyable'
import { InstructionsSection } from '../ui/instructions-section'
import { TestResultDisplay } from '../ui/test-result'
interface SlackConfigProps {
@@ -17,6 +21,24 @@ interface SlackConfigProps {
testWebhook: () => Promise<void>
}
const exampleEvent = JSON.stringify(
{
type: 'event_callback',
event: {
type: 'message',
channel: 'C0123456789',
user: 'U0123456789',
text: 'Hello from Slack!',
ts: '1234567890.123456',
},
team_id: 'T0123456789',
event_id: 'Ev0123456789',
event_time: 1234567890,
},
null,
2
)
export function SlackConfig({
signingSecret,
setSigningSecret,
@@ -27,23 +49,26 @@ export function SlackConfig({
}: SlackConfigProps) {
return (
<div className="space-y-4">
<div className="space-y-2">
<CopyableField
<ConfigSection title="Slack Configuration">
<ConfigField
id="slack-signing-secret"
label="Signing Secret"
value={signingSecret}
onChange={setSigningSecret}
placeholder="Enter your Slack app signing secret"
description="The signing secret from your Slack app used to validate request authenticity."
isLoading={isLoadingToken}
copied={copied}
copyType="slack-signing-secret"
copyToClipboard={copyToClipboard}
/>
<p className="text-xs text-muted-foreground">
The signing secret is provided in your Slack app&apos;s Basic Information page.
</p>
</div>
description="Found on your Slack app's Basic Information page. Used to validate requests."
>
<CopyableField
id="slack-signing-secret"
value={signingSecret}
onChange={setSigningSecret}
placeholder="Enter your Slack app signing secret"
isLoading={isLoadingToken}
copied={copied}
copyType="slack-signing-secret"
copyToClipboard={copyToClipboard}
readOnly={false}
isSecret
/>
</ConfigField>
</ConfigSection>
<TestResultDisplay
testResult={testResult}
@@ -52,76 +77,40 @@ export function SlackConfig({
showCurlCommand={true}
/>
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md mt-3 border border-gray-200 dark:border-gray-700">
<h4 className="font-medium">Setup Instructions</h4>
<ol className="list-decimal list-inside space-y-1 text-sm">
<InstructionsSection tip="Slack will verify the Request URL before enabling events.">
<ol className="list-decimal list-inside space-y-1">
<li>
Go to your{' '}
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
className="link"
>
Slack Apps page
</a>
.
</li>
<li>Create a new app or select an existing one</li>
<li>Navigate to &quot;Event Subscriptions&quot; in the left sidebar</li>
<li>Enable events and add the Webhook URL above as the Request URL</li>
<li>Add the event subscriptions you want to receive (e.g., message.channels)</li>
<li>Go to &quot;Basic Information&quot; and copy your Signing Secret</li>
<li>Paste the Signing Secret in the field above</li>
<li>Save your configuration</li>
<li>Select your app or create a new one.</li>
<li>Navigate to "Event Subscriptions" and enable events.</li>
<li>
Paste the <strong>Webhook URL</strong> (from above) into the "Request URL" field.
</li>
<li>Subscribe to the workspace events you need (e.g., `message.channels`).</li>
<li>Go to "Basic Information", find the "Signing Secret", and copy it.</li>
<li>Paste the Signing Secret into the field above.</li>
<li>Save changes in both Slack and here.</li>
</ol>
</div>
</InstructionsSection>
<div className="bg-emerald-50 dark:bg-emerald-950 p-3 rounded-md mt-3 border border-emerald-200 dark:border-emerald-800">
<h5 className="text-sm font-medium text-emerald-800 dark:text-emerald-300">
Slack Webhook Features
</h5>
<ul className="mt-1 space-y-1">
<li className="flex items-start">
<span className="text-emerald-500 dark:text-emerald-400 mr-2"></span>
<span className="text-sm text-emerald-700 dark:text-emerald-300">
Receive events from Slack channels, direct messages, and more
</span>
</li>
<li className="flex items-start">
<span className="text-emerald-500 dark:text-emerald-400 mr-2"></span>
<span className="text-sm text-emerald-700 dark:text-emerald-300">
Trigger workflows based on messages, reactions, or other Slack events
</span>
</li>
<li className="flex items-start">
<span className="text-emerald-500 dark:text-emerald-400 mr-2"></span>
<span className="text-sm text-emerald-700 dark:text-emerald-300">
Securely verify incoming requests with Slack&apos;s signing secret
</span>
</li>
</ul>
</div>
<div className="bg-purple-50 dark:bg-purple-950 p-3 rounded-md mt-3 border border-purple-200 dark:border-purple-800">
<h5 className="text-sm font-medium text-purple-800 dark:text-purple-300">
Example Slack Event
</h5>
<pre className="mt-2 text-xs bg-black/5 dark:bg-white/5 p-2 rounded overflow-x-auto">
{`{
"type": "event_callback",
"event": {
"type": "message",
"channel": "C0123456789",
"user": "U0123456789",
"text": "Hello from Slack!",
"ts": "1234567890.123456"
},
"team_id": "T0123456789",
"event_id": "Ev0123456789",
"event_time": 1234567890
}`}
</pre>
</div>
<Alert>
<SlackIcon className="h-4 w-4" />
<AlertTitle>Slack Event Payload Example</AlertTitle>
<AlertDescription>
Your workflow will receive a payload similar to this when a subscribed event occurs:
<CodeBlock language="json" code={exampleEvent} className="mt-2 text-sm" />
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -1,3 +1,8 @@
import { ShieldCheck } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { InstructionsSection } from '../ui/instructions-section'
import { TestResultDisplay } from '../ui/test-result'
interface StripeConfigProps {
isLoadingToken: boolean
testResult: {
@@ -9,30 +14,51 @@ interface StripeConfigProps {
copyToClipboard: (text: string, type: string) => void
}
export function StripeConfig({
isLoadingToken,
testResult,
copied,
copyToClipboard,
}: StripeConfigProps) {
export function StripeConfig({ testResult, copied, copyToClipboard }: StripeConfigProps) {
return (
<div className="space-y-2">
<h4 className="font-medium">Setup Instructions</h4>
<ol className="list-decimal list-inside space-y-1 text-sm">
<li>Go to your Stripe Dashboard</li>
<li>Navigate to Developers {'>'} Webhooks</li>
<li>Click "Add endpoint"</li>
<li>Enter the Webhook URL shown above</li>
<li>Select the events you want to listen for</li>
<li>Add the endpoint</li>
</ol>
<div className="space-y-4">
{/* No specific config fields for Stripe, just instructions */}
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md mt-3 border border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-700 dark:text-gray-300 flex items-center">
<span className="text-gray-400 dark:text-gray-500 mr-2">💡</span>
Stripe will send a test event to verify your webhook endpoint.
</p>
</div>
<TestResultDisplay
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
showCurlCommand={false} // Stripe requires signed requests, curl test not applicable here
/>
<InstructionsSection tip="Stripe will send a test event to verify your webhook endpoint after adding it.">
<ol className="list-decimal list-inside space-y-1">
<li>
Go to your{' '}
<a
href="https://dashboard.stripe.com/"
target="_blank"
rel="noopener noreferrer"
className="link"
>
Stripe Dashboard
</a>
.
</li>
<li>Navigate to Developers {'>'} Webhooks.</li>
<li>Click "Add endpoint".</li>
<li>
Paste the <strong>Webhook URL</strong> (from above) into the "Endpoint URL" field.
</li>
<li>Select the events you want to listen to (e.g., `charge.succeeded`).</li>
<li>Click "Add endpoint".</li>
</ol>
</InstructionsSection>
<Alert>
<ShieldCheck className="h-4 w-4" />
<AlertTitle>Webhook Signing</AlertTitle>
<AlertDescription>
For production use, it's highly recommended to verify Stripe webhook signatures to ensure
requests are genuinely from Stripe. Sim Studio handles this automatically if you provide
the signing secret during setup (coming soon).
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -1,4 +1,9 @@
import { CheckCircle, Network } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { ConfigField } from '../ui/config-field'
import { ConfigSection } from '../ui/config-section'
import { CopyableField } from '../ui/copyable'
import { InstructionsSection } from '../ui/instructions-section'
import { TestResultDisplay } from '../ui/test-result'
interface WhatsAppConfigProps {
@@ -24,81 +29,72 @@ export function WhatsAppConfig({
}: WhatsAppConfigProps) {
return (
<div className="space-y-4">
<CopyableField
id="whatsapp-verification-token"
label="Verification Token"
value={verificationToken}
onChange={setVerificationToken}
placeholder="Enter a verification token for WhatsApp"
description="This token will be used to verify your webhook with WhatsApp."
isLoading={isLoadingToken}
copied={copied}
copyType="token"
copyToClipboard={copyToClipboard}
/>
<ConfigSection title="WhatsApp Configuration">
<ConfigField
id="whatsapp-verification-token"
label="Verification Token"
description="Enter any secure token here. You'll need to provide the same token in your WhatsApp Business Platform dashboard."
>
<CopyableField
id="whatsapp-verification-token"
value={verificationToken}
onChange={setVerificationToken}
placeholder="Generate or enter a verification token"
isLoading={isLoadingToken}
copied={copied}
copyType="whatsapp-token"
copyToClipboard={copyToClipboard}
isSecret // Treat as secret
/>
</ConfigField>
</ConfigSection>
<TestResultDisplay
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
showCurlCommand={false} // WhatsApp uses GET for verification, not simple POST
/>
<div className="space-y-2">
<h4 className="font-medium">Setup Instructions</h4>
<ol className="space-y-2">
<li className="flex items-start">
<span className="text-gray-500 dark:text-gray-400 mr-2">1.</span>
<span className="text-sm">Go to WhatsApp Business Platform dashboard</span>
<InstructionsSection tip="After saving, click 'Verify and save' in WhatsApp and subscribe to the 'messages' webhook field.">
<ol className="list-decimal list-inside space-y-1">
<li>
Go to your{' '}
<a
href="https://developers.facebook.com/apps/"
target="_blank"
rel="noopener noreferrer"
className="link"
>
Meta for Developers Apps
</a>{' '}
page.
</li>
<li className="flex items-start">
<span className="text-gray-500 dark:text-gray-400 mr-2">2.</span>
<span className="text-sm">Navigate to "Configuration" in the sidebar</span>
<li>Select your App, then navigate to WhatsApp {'>'} Configuration.</li>
<li>Find the Webhooks section and click "Edit".</li>
<li>
Paste the <strong>Webhook URL</strong> (from above) into the "Callback URL" field.
</li>
<li className="flex items-start">
<span className="text-gray-500 dark:text-gray-400 mr-2">3.</span>
<span className="text-sm">
Enter the URL above as "Callback URL" (exactly as shown)
</span>
</li>
<li className="flex items-start">
<span className="text-gray-500 dark:text-gray-400 mr-2">4.</span>
<span className="text-sm">Enter your token as "Verify token"</span>
</li>
<li className="flex items-start">
<span className="text-gray-500 dark:text-gray-400 mr-2">5.</span>
<span className="text-sm">Click "Verify and save" and subscribe to "messages"</span>
<li>
Paste the <strong>Verification Token</strong> (from above) into the "Verify token"
field.
</li>
<li>Click "Verify and save".</li>
<li>Click "Manage" next to Webhook fields and subscribe to `messages`.</li>
</ol>
<div className="bg-blue-50 dark:bg-blue-950 p-3 rounded-md mt-3 border border-blue-200 dark:border-blue-800">
<h5 className="text-sm font-medium text-blue-800 dark:text-blue-300">Requirements</h5>
<ul className="mt-1 space-y-1">
<li className="flex items-start">
<span className="text-blue-500 dark:text-blue-400 mr-2"></span>
<span className="text-sm text-blue-700 dark:text-blue-300">
URL must be publicly accessible with HTTPS
</span>
</li>
<li className="flex items-start">
<span className="text-blue-500 dark:text-blue-400 mr-2"></span>
<span className="text-sm text-blue-700 dark:text-blue-300">
Self-signed SSL certificates not supported
</span>
</li>
<li className="flex items-start">
<span className="text-blue-500 dark:text-blue-400 mr-2"></span>
<span className="text-sm text-blue-700 dark:text-blue-300">
For local testing, use ngrok to expose your server
</span>
</li>
</InstructionsSection>
<Alert>
<Network className="h-4 w-4" />
<AlertTitle>Requirements</AlertTitle>
<AlertDescription>
<ul className="list-disc list-outside pl-4 space-y-1 mt-1">
<li>Your Sim Studio webhook URL must use HTTPS and be publicly accessible.</li>
<li>Self-signed SSL certificates are not supported by WhatsApp.</li>
<li>For local testing, use a tunneling service like ngrok or Cloudflare Tunnel.</li>
</ul>
</div>
<div className="bg-gray-50 dark:bg-gray-800 p-3 rounded-md mt-3 border border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-700 dark:text-gray-300 flex items-center">
<span className="text-gray-400 dark:text-gray-500 mr-2">💡</span>
After saving, use "Test" to verify your webhook configuration.
</p>
</div>
</div>
</AlertDescription>
</Alert>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import React from 'react'
import { Label } from '@/components/ui/label'
interface ConfigFieldProps {
id: string
label: React.ReactNode // Allow complex labels (e.g., with icons)
description?: string
children: React.ReactNode
className?: string
}
export function ConfigField({ id, label, description, children, className }: ConfigFieldProps) {
return (
<div className={`space-y-2 ${className || ''}`}>
<Label htmlFor={id}>{label}</Label>
{children} {/* The actual input/select/checkbox goes here */}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
)
}

View File

@@ -0,0 +1,16 @@
import React from 'react'
interface ConfigSectionProps {
title?: string
children: React.ReactNode
className?: string
}
export function ConfigSection({ title, children, className }: ConfigSectionProps) {
return (
<div className={`space-y-4 rounded-md border border-border bg-card p-4 ${className}`}>
{title && <h3 className="text-lg font-semibold mb-4">{title}</h3>}
{children}
</div>
)
}

View File

@@ -1,11 +1,11 @@
import { Check, Copy, Loader2 } from 'lucide-react'
import { useState } from 'react'
import { Check, Copy, Eye, EyeOff, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
interface CopyableFieldProps {
id: string
label: string
value: string
onChange?: (value: string) => void
placeholder?: string
@@ -15,11 +15,11 @@ interface CopyableFieldProps {
copyType: string
copyToClipboard: (text: string, type: string) => void
readOnly?: boolean
isSecret?: boolean
}
export function CopyableField({
id,
label,
value,
onChange,
placeholder,
@@ -29,37 +29,59 @@ export function CopyableField({
copyType,
copyToClipboard,
readOnly = false,
isSecret = false,
}: CopyableFieldProps) {
const [showSecret, setShowSecret] = useState(!isSecret)
const toggleShowSecret = () => {
if (isSecret) {
setShowSecret(!showSecret)
}
}
return (
<div className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<div className="flex items-center space-x-2 pr-1">
{isLoading ? (
<div className="flex-1 h-10 px-3 py-2 rounded-md border border-input bg-background flex items-center">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="flex items-center space-x-2">
{isLoading ? (
<div className="flex-1 h-10 px-3 py-2 rounded-md border border-input bg-background flex items-center">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="flex-1 relative">
<Input
id={id}
type={isSecret && !showSecret ? 'password' : 'text'}
value={value}
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
placeholder={placeholder}
className="flex-1"
className={cn('flex-1', isSecret ? 'pr-10' : '')}
readOnly={readOnly}
/>
)}
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyToClipboard(value, copyType)}
disabled={isLoading || !value}
className="ml-1"
>
{copied === copyType ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
{isSecret && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 h-6 w-6 -translate-y-1/2 text-muted-foreground"
onClick={toggleShowSecret}
aria-label={showSecret ? 'Hide secret' : 'Show secret'}
>
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
<span className="sr-only">{showSecret ? 'Hide secret' : 'Show secret'}</span>
</Button>
)}
</div>
)}
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyToClipboard(value, copyType)}
disabled={isLoading || !value}
className="shrink-0"
aria-label="Copy value"
>
{copied === copyType ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import React from 'react'
import { Lightbulb } from 'lucide-react'
interface InstructionsSectionProps {
title?: string
children: React.ReactNode
tip?: string
className?: string
}
export function InstructionsSection({
title = 'Setup Instructions',
children,
tip,
className,
}: InstructionsSectionProps) {
return (
<div
className={`bg-muted/50 dark:bg-muted/20 p-4 rounded-md mt-4 border border-border ${className}`}
>
<h4 className="font-medium text-base mb-3">{title}</h4>
<div className="text-sm text-muted-foreground space-y-1">
{children} {/* Instructions list goes here */}
</div>
{tip && (
<div className="mt-4 pt-3 border-t border-border">
<p className="text-sm text-muted-foreground flex items-center">
<Lightbulb className="h-4 w-4 text-yellow-500 dark:text-yellow-400 mr-2 flex-shrink-0" />
{tip}
</p>
</div>
)}
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { motion } from 'framer-motion'
import { Check, Copy } from 'lucide-react'
import { AlertTriangle, Check, CheckCircle, Copy } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface TestResultDisplayProps {
testResult: {
@@ -28,42 +29,45 @@ export function TestResultDisplay({
}: TestResultDisplayProps) {
if (!testResult) return null
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className={`p-3 rounded-md ${
testResult.success
? 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300 border border-green-200 dark:border-green-800'
: 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300 border border-red-200 dark:border-red-800'
}`}
>
<p className="text-sm">{testResult.message}</p>
const Icon = testResult.success ? CheckCircle : AlertTriangle
{showCurlCommand && testResult.success && testResult.test?.curlCommand && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="mt-3 bg-black/10 dark:bg-white/10 p-2 rounded text-xs font-mono overflow-x-auto relative group"
>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-2 h-6 w-6 opacity-70 hover:opacity-100"
onClick={() => copyToClipboard(testResult.test?.curlCommand || '', 'curl-command')}
>
{copied === 'curl-command' ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
<pre className="whitespace-pre-wrap break-all pr-8">{testResult.test.curlCommand}</pre>
</motion.div>
return (
<Alert
variant={testResult.success ? 'default' : 'destructive'}
className={cn(
testResult.success &&
'border-green-500/50 text-green-700 dark:border-green-500/60 dark:text-green-400 [&>svg]:text-green-500 dark:[&>svg]:text-green-400'
)}
</motion.div>
>
<Icon className="h-4 w-4" />
<AlertDescription>
{testResult.message}
{showCurlCommand && testResult.success && testResult.test?.curlCommand && (
<div className="mt-3 bg-black/10 dark:bg-white/10 p-2 rounded text-xs font-mono overflow-x-auto relative group border border-border">
<span className="text-muted-foreground text-[10px] absolute top-1 left-2 font-sans">
Example Request:
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1 h-6 w-6 opacity-70 hover:opacity-100 text-inherit"
onClick={() => copyToClipboard(testResult.test?.curlCommand || '', 'curl-command')}
aria-label="Copy cURL command"
>
{copied === 'curl-command' ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
<pre className="whitespace-pre-wrap break-all pt-4 pr-8">
{testResult.test.curlCommand}
</pre>
</div>
)}
</AlertDescription>
</Alert>
)
}

View File

@@ -9,6 +9,7 @@ interface WebhookDialogFooterProps {
isDeleting: boolean
isLoadingToken: boolean
isTesting: boolean
isCurrentConfigValid: boolean
onSave: () => void
onDelete: () => void
onTest?: () => void
@@ -22,6 +23,7 @@ export function WebhookDialogFooter({
isDeleting,
isLoadingToken,
isTesting,
isCurrentConfigValid,
onSave,
onDelete,
onTest,
@@ -31,7 +33,8 @@ export function WebhookDialogFooter({
webhookId &&
(webhookProvider === 'whatsapp' ||
webhookProvider === 'generic' ||
webhookProvider === 'slack') &&
webhookProvider === 'slack' ||
webhookProvider === 'airtable') &&
onTest
return (
@@ -78,7 +81,7 @@ export function WebhookDialogFooter({
<Button
variant="default"
onClick={onSave}
disabled={isSaving || isLoadingToken}
disabled={isSaving || isLoadingToken || !isCurrentConfigValid}
className="bg-primary"
>
{isSaving ? (

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { createLogger } from '@/lib/logs/console-logger'
import { ProviderConfig, WEBHOOK_PROVIDERS } from '../webhook-config'
import { AirtableConfig } from './providers/airtable-config'
import { DiscordConfig } from './providers/discord-config'
import { GenericConfig } from './providers/generic-config'
import { GithubConfig } from './providers/github-config'
@@ -21,7 +22,6 @@ interface WebhookModalProps {
onClose: () => void
webhookPath: string
webhookProvider: string
workflowId: string
onSave?: (path: string, providerConfig: ProviderConfig) => Promise<boolean>
onDelete?: () => Promise<boolean>
webhookId?: string
@@ -32,7 +32,6 @@ export function WebhookModal({
onClose,
webhookPath,
webhookProvider,
workflowId,
onSave,
onDelete,
webhookId,
@@ -57,7 +56,7 @@ export function WebhookModal({
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [showUnsavedChangesConfirm, setShowUnsavedChangesConfirm] = useState(false)
const isConfigured = Boolean(webhookId)
const [isCurrentConfigValid, setIsCurrentConfigValid] = useState(true)
// Generic webhook state
const [generalToken, setGeneralToken] = useState('')
@@ -72,6 +71,12 @@ export function WebhookModal({
const [discordAvatarUrl, setDiscordAvatarUrl] = useState('')
const [slackSigningSecret, setSlackSigningSecret] = useState('')
// Airtable-specific state
const [airtableWebhookSecret, setAirtableWebhookSecret] = useState('')
const [airtableBaseId, setAirtableBaseId] = useState('')
const [airtableTableId, setAirtableTableId] = useState('')
const [airtableIncludeCellValues, setAirtableIncludeCellValues] = useState(false)
// Original values to track changes
const [originalValues, setOriginalValues] = useState({
whatsappVerificationToken: '',
@@ -83,6 +88,10 @@ export function WebhookModal({
discordWebhookName: '',
discordAvatarUrl: '',
slackSigningSecret: '',
airtableWebhookSecret: '',
airtableBaseId: '',
airtableTableId: '',
airtableIncludeCellValues: false,
})
// Get the current provider configuration
@@ -181,6 +190,21 @@ export function WebhookModal({
const signingSecret = config.signingSecret || ''
setSlackSigningSecret(signingSecret)
setOriginalValues((prev) => ({ ...prev, slackSigningSecret: signingSecret }))
} else if (webhookProvider === 'airtable') {
const baseIdVal = config.baseId || ''
const tableIdVal = config.tableId || ''
const includeCells = config.includeCellValuesInFieldIds === 'all'
setAirtableBaseId(baseIdVal)
setAirtableTableId(tableIdVal)
setAirtableIncludeCellValues(includeCells)
setOriginalValues((prev) => ({
...prev,
airtableBaseId: baseIdVal,
airtableTableId: tableIdVal,
airtableIncludeCellValues: includeCells,
}))
}
}
}
@@ -213,7 +237,12 @@ export function WebhookModal({
secretHeaderName !== originalValues.secretHeaderName ||
requireAuth !== originalValues.requireAuth ||
allowedIps !== originalValues.allowedIps)) ||
(webhookProvider === 'slack' && slackSigningSecret !== originalValues.slackSigningSecret)
(webhookProvider === 'slack' && slackSigningSecret !== originalValues.slackSigningSecret) ||
(webhookProvider === 'airtable' &&
(airtableWebhookSecret !== originalValues.airtableWebhookSecret ||
airtableBaseId !== originalValues.airtableBaseId ||
airtableTableId !== originalValues.airtableTableId ||
airtableIncludeCellValues !== originalValues.airtableIncludeCellValues))
setHasUnsavedChanges(hasChanges)
}, [
@@ -228,6 +257,40 @@ export function WebhookModal({
allowedIps,
originalValues,
slackSigningSecret,
airtableWebhookSecret,
airtableBaseId,
airtableTableId,
airtableIncludeCellValues,
])
// Validate required fields for current provider
useEffect(() => {
let isValid = true
switch (webhookProvider) {
case 'airtable':
isValid = airtableBaseId.trim() !== '' && airtableTableId.trim() !== ''
break
case 'slack':
isValid = slackSigningSecret.trim() !== ''
break
case 'whatsapp':
// Although we auto-generate a token on creation, user could clear it when editing
isValid = whatsappVerificationToken.trim() !== ''
break
case 'github':
isValid = generalToken.trim() !== ''
break
case 'discord':
isValid = discordWebhookName.trim() !== ''
break
}
setIsCurrentConfigValid(isValid)
}, [
webhookProvider,
airtableBaseId,
airtableTableId,
slackSigningSecret,
whatsappVerificationToken,
])
// Use the provided path or generate a UUID-based path
@@ -277,12 +340,29 @@ export function WebhookModal({
}
case 'slack':
return { signingSecret: slackSigningSecret }
case 'airtable':
return {
webhookSecret: airtableWebhookSecret || undefined,
baseId: airtableBaseId,
tableId: airtableTableId,
includeCellValuesInFieldIds: airtableIncludeCellValues ? 'all' : undefined,
}
default:
return {}
}
}
const handleSave = async () => {
if (!isCurrentConfigValid) {
logger.warn('Attempted to save with invalid configuration')
// Add user feedback for invalid configuration
setTestResult({
success: false,
message: 'Cannot save: Please fill in all required fields for the selected provider.',
})
return
}
setIsSaving(true)
try {
// Call the onSave callback with the path and provider-specific config
@@ -294,25 +374,44 @@ export function WebhookModal({
: formattedPath
await new Promise((resolve) => setTimeout(resolve, 100))
await onSave(pathToSave, providerConfig)
const saveSuccessful = await onSave(pathToSave, providerConfig)
await new Promise((resolve) => setTimeout(resolve, 100))
// Update original values to match current values after successful save
setOriginalValues({
whatsappVerificationToken,
githubContentType,
generalToken,
secretHeaderName,
requireAuth,
allowedIps,
discordWebhookName,
discordAvatarUrl,
slackSigningSecret,
})
setHasUnsavedChanges(false)
if (saveSuccessful) {
setOriginalValues({
whatsappVerificationToken,
githubContentType,
generalToken,
secretHeaderName,
requireAuth,
allowedIps,
discordWebhookName,
discordAvatarUrl,
slackSigningSecret,
airtableWebhookSecret,
airtableBaseId,
airtableTableId,
airtableIncludeCellValues,
})
setHasUnsavedChanges(false)
setTestResult({
success: true,
message: 'Webhook configuration saved successfully.',
})
} else {
setTestResult({
success: false,
message: 'Failed to save webhook configuration. Please try again.',
})
}
}
} catch (error) {
logger.error('Error saving webhook:', { error })
setTestResult({
success: false,
message:
error instanceof Error ? error.message : 'An error occurred while saving the webhook',
})
} finally {
setIsSaving(false)
}
@@ -361,15 +460,27 @@ export function WebhookModal({
const testEndpoint = `/api/webhooks/test?id=${webhookId}`
const response = await fetch(testEndpoint)
// Check if response is ok before trying to parse JSON
if (!response.ok) {
throw new Error('Failed to test webhook')
const errorText = await response.text()
let errorMessage = 'Failed to test webhook'
try {
// Try to parse as JSON, but handle case where it's not valid JSON
const errorData = JSON.parse(errorText)
errorMessage = errorData.message || errorData.error || errorMessage
} catch (parseError) {
// If JSON parsing fails, use the raw text if it exists
errorMessage = errorText || errorMessage
}
throw new Error(errorMessage)
}
// Parse JSON only after confirming response is ok
const data = await response.json()
// Add a slight delay before showing the result for smoother animation
await new Promise((resolve) => setTimeout(resolve, 300))
// If the test was successful, show a success message
if (data.success) {
setTestResult({
@@ -380,7 +491,7 @@ export function WebhookModal({
} else {
setTestResult({
success: false,
message: data.message || data.error || 'Failed to validate webhook configuration',
message: data.message || data.error || 'Webhook test failed with success=false',
})
}
} catch (error: any) {
@@ -459,6 +570,23 @@ export function WebhookModal({
testWebhook={testWebhook}
/>
)
case 'airtable':
return (
<AirtableConfig
baseId={airtableBaseId}
setBaseId={setAirtableBaseId}
tableId={airtableTableId}
setTableId={setAirtableTableId}
includeCellValues={airtableIncludeCellValues}
setIncludeCellValues={setAirtableIncludeCellValues}
isLoadingToken={isLoadingToken}
testResult={testResult}
copied={copied}
copyToClipboard={copyToClipboard}
testWebhook={testWebhook}
webhookId={webhookId}
/>
)
case 'generic':
default:
return (
@@ -505,6 +633,7 @@ export function WebhookModal({
isDeleting={isDeleting}
isLoadingToken={isLoadingToken}
isTesting={isTesting}
isCurrentConfigValid={isCurrentConfigValid} // <-- Pass down validation state
onSave={handleSave}
onDelete={() => setShowDeleteConfirm(true)}
onTest={testWebhook}

View File

@@ -1,7 +1,14 @@
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { CheckCircle2, ExternalLink } from 'lucide-react'
import { DiscordIcon, GithubIcon, SlackIcon, StripeIcon, WhatsAppIcon } from '@/components/icons'
import {
AirtableIcon,
DiscordIcon,
GithubIcon,
SlackIcon,
StripeIcon,
WhatsAppIcon,
} from '@/components/icons'
import { Button } from '@/components/ui/button'
import { createLogger } from '@/lib/logs/console-logger'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
@@ -54,6 +61,14 @@ export interface SlackConfig {
signingSecret: string
}
// Define Airtable-specific configuration type
export interface AirtableWebhookConfig {
baseId: string
tableId: string
externalId?: string // To store the ID returned by Airtable
includeCellValuesInFieldIds?: 'all' | undefined
}
// Union type for all provider configurations
export type ProviderConfig =
| WhatsAppConfig
@@ -62,6 +77,7 @@ export type ProviderConfig =
| StripeConfig
| GeneralWebhookConfig
| SlackConfig
| AirtableWebhookConfig
| Record<string, never>
// Define available webhook providers
@@ -163,6 +179,27 @@ export const WEBHOOK_PROVIDERS: { [key: string]: WebhookProvider } = {
},
},
},
airtable: {
id: 'airtable',
name: 'Airtable',
icon: (props) => <AirtableIcon {...props} />,
configFields: {
baseId: {
type: 'string',
label: 'Base ID',
placeholder: 'appXXXXXXXXXXXXXX',
description: 'The ID of the Airtable Base the webhook should monitor.',
defaultValue: '', // Default empty, user must provide
},
tableId: {
type: 'string',
label: 'Table ID',
placeholder: 'tblXXXXXXXXXXXXXX',
description: 'The ID of the Airtable Table within the Base to monitor.',
defaultValue: '', // Default empty, user must provide
},
},
},
}
interface WebhookConfigProps {
@@ -365,7 +402,6 @@ export function WebhookConfig({ blockId, subBlockId, isConnecting }: WebhookConf
onClose={handleCloseModal}
webhookPath={webhookPath || ''}
webhookProvider={webhookProvider || 'generic'}
workflowId={workflowId}
onSave={handleSaveWebhook}
onDelete={handleDeleteWebhook}
webhookId={webhookId || undefined}

View File

@@ -1,21 +1,29 @@
import { AirtableIcon } from '@/components/icons'
import {
AirtableReadResponse,
AirtableCreateResponse,
AirtableGetResponse,
AirtableListResponse,
AirtableUpdateMultipleResponse,
AirtableUpdateResponse,
AirtableWriteResponse,
} from '@/tools/airtable/types'
import { BlockConfig } from '../types'
type AirtableResponse = AirtableReadResponse | AirtableWriteResponse | AirtableUpdateResponse
// Union type for all possible Airtable responses
type AirtableResponse =
| AirtableListResponse
| AirtableGetResponse
| AirtableCreateResponse
| AirtableUpdateResponse
| AirtableUpdateMultipleResponse
export const AirtableBlock: BlockConfig<AirtableResponse> = {
type: 'airtable',
name: 'Airtable',
description: 'Read, write, and update Airtable',
description: 'Read, create, and update Airtable records',
longDescription:
'Integrate Airtable functionality to manage table records. Read data from existing tables, ' +
'write new records, and update existing ones using OAuth authentication. Supports table ' +
'selection and record operations with custom field mapping.',
'Integrate Airtable functionality to manage table records. List, get, create, ' +
'update single, or update multiple records using OAuth authentication. ' +
'Requires base ID, table ID, and operation-specific parameters.',
category: 'tools',
bgColor: '#E0E0E0',
icon: AirtableIcon,
@@ -27,9 +35,10 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Read Records', id: 'read' },
{ label: 'Write Records', id: 'write' },
{ label: 'Update Records', id: 'update' },
{ label: 'List Records', id: 'list' },
{ label: 'Get Record', id: 'get' },
{ label: 'Create Records', id: 'create' },
{ label: 'Update Record', id: 'update' },
],
},
// Airtable Credentials
@@ -40,7 +49,7 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
layout: 'full',
provider: 'airtable',
serviceId: 'airtable',
requiredScopes: ['data.records:read', 'data.records:write'],
requiredScopes: ['data.records:read', 'data.records:write'], // Keep both scopes
placeholder: 'Select Airtable account',
},
// Base ID
@@ -49,92 +58,120 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
title: 'Base ID',
type: 'short-input',
layout: 'full',
placeholder: 'Enter your base ID (found in the API documentation)',
placeholder: 'Enter your base ID (e.g., appXXXXXXXXXXXXXX)',
},
// Table Name/ID
// Table ID
{
id: 'tableId',
title: 'Table Name/ID',
title: 'Table ID',
type: 'short-input',
layout: 'full',
placeholder: 'Enter table name or ID',
placeholder: 'Enter table ID (e.g., tblXXXXXXXXXXXXXX)',
},
// Read Operation Fields
// Record ID (For Get/Update Single)
{
id: 'recordId',
title: 'Record ID',
type: 'short-input',
layout: 'full',
placeholder: 'ID of the record (e.g., recXXXXXXXXXXXXXX)',
condition: { field: 'operation', value: ['get', 'update'] },
},
// List Operation Fields
{
id: 'maxRecords',
title: 'Max Records',
type: 'short-input',
layout: 'half',
placeholder: 'Maximum number of records to return',
condition: { field: 'operation', value: 'read' },
placeholder: 'Maximum records to return (optional)',
condition: { field: 'operation', value: 'list' },
},
{
id: 'filterFormula',
title: 'Filter Formula',
type: 'long-input',
layout: 'full',
placeholder: 'Enter Airtable formula to filter records',
condition: { field: 'operation', value: 'read' },
placeholder: 'Airtable formula to filter records (optional)',
condition: { field: 'operation', value: 'list' },
},
// Write Operation Fields
// Create / Update Multiple Operation Field: Records (Array)
{
id: 'records',
title: 'Records',
title: 'Records (JSON Array)',
type: 'code',
layout: 'full',
placeholder: 'Enter records in JSON format',
condition: { field: 'operation', value: 'write' },
placeholder: 'For Create: `[{ "fields": { ... } }]`\n',
condition: { field: 'operation', value: ['create', 'updateMultiple'] },
},
// Update Operation Fields
// Update Single Operation Field: Fields (Object)
{
id: 'records',
title: 'Record Fields',
id: 'fields',
title: 'Fields (JSON Object)',
type: 'code',
layout: 'full',
placeholder: 'Enter record fields in JSON format',
condition: { field: 'operation', value: 'update' },
},
{
id: 'recordId',
title: 'Record ID',
type: 'short-input',
layout: 'full',
placeholder: 'ID of the record to update',
placeholder: 'Fields to update: `{ "Field Name": "New Value" }`',
condition: { field: 'operation', value: 'update' },
},
],
tools: {
access: ['airtable_read', 'airtable_write', 'airtable_update'],
access: [
'airtable_list_records',
'airtable_get_record',
'airtable_create_records',
'airtable_update_record',
'airtable_update_multiple_records',
],
config: {
tool: (params) => {
switch (params.operation) {
case 'read':
return 'airtable_read'
case 'write':
return 'airtable_write'
case 'list':
return 'airtable_list_records'
case 'get':
return 'airtable_get_record'
case 'create':
return 'airtable_create_records'
case 'update':
return 'airtable_update'
return 'airtable_update_record'
case 'updateMultiple':
return 'airtable_update_multiple_records'
default:
throw new Error(`Invalid Airtable operation: ${params.operation}`)
}
},
params: (params) => {
const { credential, records, ...rest } = params
const { credential, records, fields, ...rest } = params
let parsedRecords: any | undefined
let parsedFields: any | undefined
if (params.operation === 'update' && records) {
const parsedRecords = JSON.parse(records)
return {
accessToken: credential,
fields: parsedRecords,
...rest,
// Parse JSON inputs safely
try {
if (records && (params.operation === 'create' || params.operation === 'updateMultiple')) {
parsedRecords = JSON.parse(records)
}
if (fields && params.operation === 'update') {
parsedFields = JSON.parse(fields)
}
} catch (error: any) {
throw new Error(`Invalid JSON input for ${params.operation} operation: ${error.message}`)
}
return {
// Construct parameters based on operation
const baseParams = {
accessToken: credential,
records: records ? JSON.parse(records) : undefined,
...rest,
}
switch (params.operation) {
case 'create':
case 'updateMultiple':
return { ...baseParams, records: parsedRecords }
case 'update':
return { ...baseParams, fields: parsedFields }
case 'list':
case 'get':
default:
return baseParams // No JSON parsing needed for list/get
}
},
},
},
@@ -143,31 +180,21 @@ export const AirtableBlock: BlockConfig<AirtableResponse> = {
credential: { type: 'string', required: true },
baseId: { type: 'string', required: true },
tableId: { type: 'string', required: true },
// Read operation inputs
maxRecords: { type: 'number', required: false },
filterFormula: { type: 'string', required: false },
// Write/Update operation inputs
records: {
type: 'json',
required: false,
schema: {
type: 'object',
properties: {
fields: {
type: 'object',
additionalProperties: true,
},
},
required: ['fields'],
},
},
recordId: { type: 'string', required: false },
// Conditional inputs
recordId: { type: 'string', required: false }, // Required for get/update
maxRecords: { type: 'number', required: false }, // Optional for list
filterFormula: { type: 'string', required: false }, // Optional for list
records: { type: 'json', required: false }, // Required for create/updateMultiple
fields: { type: 'json', required: false }, // Required for update single
},
// Output structure depends on the operation, covered by AirtableResponse union type
outputs: {
response: {
// Define a type structure listing all potential top-level keys mapped to 'json'
type: {
records: 'json',
metadata: 'json',
records: 'json', // Optional: for list, create, updateMultiple
record: 'json', // Optional: for get, update single
metadata: 'json', // Required: present in all responses
},
},
},

View File

@@ -51,6 +51,7 @@ export const StarterBlock: BlockConfig<StarterBlockOutput> = {
{ label: 'GitHub', id: 'github' },
{ label: 'Discord', id: 'discord' },
{ label: 'Slack', id: 'slack' },
{ label: 'Airtable', id: 'airtable' },
// { label: 'Stripe', id: 'stripe' },
],
value: () => 'generic',

View File

@@ -0,0 +1,19 @@
import React from 'react'
import { CopyButton } from '@/components/ui/copy-button'
import { cn } from '@/lib/utils'
interface CodeBlockProps extends React.HTMLAttributes<HTMLPreElement> {
code: string
language?: string
}
export function CodeBlock({ code, language, className, ...props }: CodeBlockProps) {
return (
<div className={cn('relative rounded-md border bg-muted', className)}>
<pre className="p-4 text-sm overflow-x-auto" {...props}>
<code>{code}</code>
</pre>
<CopyButton text={code} className="absolute top-2 right-2" />
</div>
)
}

View File

@@ -69,7 +69,6 @@ export class InputResolver {
if (isConditionBlock && isConditionsKey && typeof value === 'string') {
// Pass the raw string directly without resolving refs or parsing JSON
result[key] = value
logger.debug(`[resolveInputs] Passing raw 'conditions' string for Condition block`)
continue // Skip further processing for this key
}
// *** End of early check ***
@@ -98,21 +97,17 @@ export class InputResolver {
// For function blocks, we need special handling for code input
if (isFunctionBlock && key === 'code') {
result[key] = resolvedValue
logger.debug(`[resolveInputs] Function block code input preserved as string`)
}
// For API blocks, handle body input specially
else if (isApiBlock && key === 'body') {
try {
if (resolvedValue.trim().startsWith('{') || resolvedValue.trim().startsWith('[')) {
result[key] = JSON.parse(resolvedValue)
logger.debug(`[resolveInputs] API block body parsed as JSON object`)
} else {
result[key] = resolvedValue
logger.debug(`[resolveInputs] API block body preserved as string`)
}
} catch {
result[key] = resolvedValue
logger.debug(`[resolveInputs] API block body JSON parsing failed, keeping as string`)
}
}
// For other inputs, try to convert JSON strings to objects
@@ -125,7 +120,6 @@ export class InputResolver {
(resolvedValue.trim().startsWith('{') || resolvedValue.trim().startsWith('['))
) {
result[key] = JSON.parse(resolvedValue)
logger.debug(`[resolveInputs] Parsed JSON value for ${key}`)
} else {
// If not JSON-like or empty, keep as string (or potentially null/undefined if resolvedValue became that)
result[key] = resolvedValue
@@ -317,14 +311,6 @@ export class InputResolver {
const path = match.slice(1, -1)
const [blockRef, ...pathParts] = path.split('.')
// Log the reference being processed
logger.debug(`[resolveBlockReferences] Processing block reference: ${match}`, {
blockRef,
pathParts,
currentBlock: currentBlock.id,
currentBlockType: currentBlock.metadata?.id,
})
// Special case for "start" references
// This allows users to reference the starter block using <start.response.type.input>
// regardless of the actual name of the starter block
@@ -332,32 +318,12 @@ export class InputResolver {
// Find the starter block
const starterBlock = this.workflow.blocks.find((block) => block.metadata?.id === 'starter')
if (starterBlock) {
logger.debug(`[resolveBlockReferences] Found starter block with ID: ${starterBlock.id}`)
const blockState = context.blockStates.get(starterBlock.id)
if (blockState) {
logger.debug(
`[resolveBlockReferences] Starter block state:`,
JSON.stringify(blockState, null, 2)
)
// Navigate through the path parts
let replacementValue: any = blockState.output
// Log the initial output value from the starter block
logger.debug(
`[resolveBlockReferences] Initial starter output:`,
JSON.stringify(replacementValue, null, 2)
)
for (const part of pathParts) {
logger.debug(`[resolveBlockReferences] Navigating path part: ${part}`, {
currentValue:
typeof replacementValue === 'object'
? JSON.stringify(replacementValue)
: replacementValue,
})
if (!replacementValue || typeof replacementValue !== 'object') {
logger.warn(
`[resolveBlockReferences] Invalid path "${part}" - replacementValue is not an object:`,
@@ -387,26 +353,14 @@ export class InputResolver {
if (typeof replacementValue === 'object' && replacementValue !== null) {
// For function blocks, preserve the object structure for code usage
if (blockType === 'function') {
logger.debug(
`[resolveBlockReferences] Special handling for function input:`,
JSON.stringify(replacementValue, null, 2)
)
formattedValue = JSON.stringify(replacementValue)
}
// For API blocks, handle body special case
else if (blockType === 'api') {
logger.debug(
`[resolveBlockReferences] Special handling for API input:`,
JSON.stringify(replacementValue, null, 2)
)
formattedValue = JSON.stringify(replacementValue)
}
// For condition blocks, ensure proper formatting
else if (blockType === 'condition') {
logger.debug(
`[resolveBlockReferences] Special handling for condition input:`,
JSON.stringify(replacementValue, null, 2)
)
formattedValue = this.stringifyForCondition(replacementValue)
}
// For all other blocks, stringify objects
@@ -425,7 +379,6 @@ export class InputResolver {
: String(replacementValue)
}
logger.debug(`[resolveBlockReferences] Resolved value:`, formattedValue)
resolvedValue = resolvedValue.replace(match, formattedValue)
continue
}

View File

@@ -421,7 +421,7 @@ export const auth = betterAuth({
authorizationUrl: 'https://airtable.com/oauth2/v1/authorize',
tokenUrl: 'https://airtable.com/oauth2/v1/token',
userInfoUrl: 'https://api.airtable.com/v0/meta/whoami',
scopes: ['data.records:read', 'data.records:write', 'user.email:read'],
scopes: ['data.records:read', 'data.records:write', 'user.email:read', 'webhook:manage'],
responseType: 'code',
pkce: true,
accessType: 'offline',

View File

@@ -442,14 +442,14 @@ export async function persistExecutionLogs(
// Fill in missing timing information
if (toolCallData.length > 0) {
const estimatedToolCalls = estimateToolCallTimings(
const getToolCalls = getToolCallTimings(
toolCallData,
blockStartTime,
blockEndTime,
blockDuration
)
const redactedToolCalls = estimatedToolCalls.map((toolCall) => ({
const redactedToolCalls = getToolCalls.map((toolCall) => ({
...toolCall,
input: redactApiKeys(toolCall.input),
}))
@@ -649,7 +649,7 @@ function getTriggerErrorPrefix(triggerType: 'api' | 'webhook' | 'schedule' | 'ma
* Extracts duration information for tool calls
* This function preserves actual timing data while ensuring duration is calculated
*/
function estimateToolCallTimings(
function getToolCallTimings(
toolCalls: any[],
blockStart: string,
blockEnd: string,

View File

@@ -333,12 +333,12 @@ export function parseProvider(provider: OAuthProvider): ProviderConfig {
* This is a server-side utility function to refresh OAuth tokens
* @param providerId The provider ID (e.g., 'google-drive')
* @param refreshToken The refresh token to use
* @returns The new access token, or null if refresh failed
* @returns Object containing the new access token and expiration time in seconds, or null if refresh failed
*/
export async function refreshOAuthToken(
providerId: string,
refreshToken: string
): Promise<string | null> {
): Promise<{ accessToken: string; expiresIn: number } | null> {
try {
// Get the provider from the providerId (e.g., 'google-drive' -> 'google')
const provider = providerId.split('-')[0]
@@ -369,6 +369,16 @@ export async function refreshOAuthToken(
clientId = process.env.CONFLUENCE_CLIENT_ID
clientSecret = process.env.CONFLUENCE_CLIENT_SECRET
break
case 'airtable':
tokenEndpoint = 'https://airtable.com/oauth2/v1/token'
clientId = process.env.AIRTABLE_CLIENT_ID
clientSecret = process.env.AIRTABLE_CLIENT_SECRET
break
case 'supabase':
tokenEndpoint = 'https://api.supabase.com/v1/oauth/token'
clientId = process.env.SUPABASE_CLIENT_ID
clientSecret = process.env.SUPABASE_CLIENT_SECRET
break
default:
throw new Error(`Unsupported provider: ${provider}`)
}
@@ -404,7 +414,21 @@ export async function refreshOAuthToken(
}
const data = await response.json()
return data.access_token || null
// Extract token and expiration (different providers may use different field names)
const accessToken = data.access_token
// Get expiration time - use provider's value or default to 1 hour (3600 seconds)
// Different providers use different names for this field
const expiresIn = data.expires_in || data.expiresIn || 3600
if (!accessToken) {
logger.warn('No access token found in refresh response', data)
return null
}
logger.info('Token refreshed successfully with expiration', { expiresIn })
return { accessToken, expiresIn }
} catch (error) {
logger.error('Error refreshing token:', { error })
return null

View File

@@ -62,94 +62,168 @@ export function getRedisClient(): Redis | null {
}
// Message ID cache functions
const MESSAGE_ID_PREFIX = 'whatsapp:message:'
const MESSAGE_ID_PREFIX = 'processed:' // Generic prefix
const MESSAGE_ID_EXPIRY = 60 * 60 * 24 * 7 // 7 days in seconds
/**
* Check if a message ID has been processed before
* @param messageId The message ID to check
* @returns True if the message has been processed before, false otherwise
* Check if a key exists in Redis or fallback cache.
* @param key The key to check (e.g., messageId, lockKey).
* @returns True if the key exists and hasn't expired, false otherwise.
*/
export async function hasProcessedMessage(messageId: string): Promise<boolean> {
export async function hasProcessedMessage(key: string): Promise<boolean> {
try {
const redis = getRedisClient()
const fullKey = `${MESSAGE_ID_PREFIX}${key}` // Use generic prefix
if (redis) {
// Use Redis if available
const key = `${MESSAGE_ID_PREFIX}${messageId}`
const result = await redis.exists(key)
const result = await redis.exists(fullKey)
return result === 1
} else {
// Fallback to in-memory cache
const cacheEntry = inMemoryCache.get(messageId)
const cacheEntry = inMemoryCache.get(fullKey)
if (!cacheEntry) return false
// Check if the entry has expired
if (cacheEntry.expiry && cacheEntry.expiry < Date.now()) {
inMemoryCache.delete(messageId)
inMemoryCache.delete(fullKey)
return false
}
return true
}
} catch (error) {
logger.error('Error checking message ID:', { error })
logger.error(`Error checking key ${key}:`, { error })
// Fallback to in-memory cache on error
const cacheEntry = inMemoryCache.get(messageId)
const fullKey = `${MESSAGE_ID_PREFIX}${key}`
const cacheEntry = inMemoryCache.get(fullKey)
return !!cacheEntry && (!cacheEntry.expiry || cacheEntry.expiry > Date.now())
}
}
/**
* Mark a message ID as processed
* @param messageId The message ID to mark as processed
* @param expirySeconds Optional expiry time in seconds (defaults to 7 days)
* Mark a key as processed/present in Redis or fallback cache.
* @param key The key to mark (e.g., messageId, lockKey).
* @param expirySeconds Optional expiry time in seconds (defaults to 7 days).
*/
export async function markMessageAsProcessed(
messageId: string,
key: string,
expirySeconds: number = MESSAGE_ID_EXPIRY
): Promise<void> {
try {
const redis = getRedisClient()
const fullKey = `${MESSAGE_ID_PREFIX}${key}` // Use generic prefix
if (redis) {
// Use Redis if available - use pipelining for efficiency
const key = `${MESSAGE_ID_PREFIX}${messageId}`
await redis.set(key, '1', 'EX', expirySeconds)
await redis.set(fullKey, '1', 'EX', expirySeconds)
} else {
// Fallback to in-memory cache
const expiry = expirySeconds ? Date.now() + expirySeconds * 1000 : null
inMemoryCache.set(messageId, { value: '1', expiry })
inMemoryCache.set(fullKey, { value: '1', expiry })
// Clean up old message IDs if cache gets too large
if (inMemoryCache.size > MAX_CACHE_SIZE) {
const now = Date.now()
// First try to remove expired entries
for (const [key, entry] of inMemoryCache.entries()) {
for (const [cacheKey, entry] of inMemoryCache.entries()) {
if (entry.expiry && entry.expiry < now) {
inMemoryCache.delete(key)
inMemoryCache.delete(cacheKey)
}
}
// If still too large, remove oldest entries
// If still too large, remove oldest entries (FIFO based on insertion order)
if (inMemoryCache.size > MAX_CACHE_SIZE) {
const keysToDelete = Array.from(inMemoryCache.keys()).slice(
0,
inMemoryCache.size - MAX_CACHE_SIZE
)
for (const key of keysToDelete) {
inMemoryCache.delete(key)
for (const keyToDelete of keysToDelete) {
inMemoryCache.delete(keyToDelete)
}
}
}
}
} catch (error) {
logger.error('Error marking message as processed:', { error })
logger.error(`Error marking key ${key} as processed:`, { error })
// Fallback to in-memory cache on error
const fullKey = `${MESSAGE_ID_PREFIX}${key}`
const expiry = expirySeconds ? Date.now() + expirySeconds * 1000 : null
inMemoryCache.set(messageId, { value: '1', expiry })
inMemoryCache.set(fullKey, { value: '1', expiry })
}
}
/**
* Attempts to acquire a lock using Redis SET NX command.
* @param lockKey The key to use for the lock.
* @param value The value to set (e.g., a unique identifier for the process holding the lock).
* @param expirySeconds The lock's time-to-live in seconds.
* @returns True if the lock was acquired successfully, false otherwise.
*/
export async function acquireLock(
lockKey: string,
value: string,
expirySeconds: number
): Promise<boolean> {
try {
const redis = getRedisClient()
if (!redis) {
logger.warn('Redis client not available, cannot acquire lock.')
// Fallback behavior: maybe allow processing but log a warning?
// Or treat as lock acquired if no Redis? Depends on desired behavior.
return true // Or false, depending on safety requirements
}
// Use SET key value EX expirySeconds NX
// Returns "OK" if successful, null if key already exists (lock held)
const result = await redis.set(lockKey, value, 'EX', expirySeconds, 'NX')
return result === 'OK'
} catch (error) {
logger.error(`Error acquiring lock for key ${lockKey}:`, { error })
// Treat errors as failure to acquire lock for safety
return false
}
}
/**
* Retrieves the value of a key from Redis.
* @param key The key to retrieve.
* @returns The value of the key, or null if the key doesn't exist or an error occurs.
*/
export async function getLockValue(key: string): Promise<string | null> {
try {
const redis = getRedisClient()
if (!redis) {
logger.warn('Redis client not available, cannot get lock value.')
return null // Cannot determine lock value
}
return await redis.get(key)
} catch (error) {
logger.error(`Error getting value for key ${key}:`, { error })
return null
}
}
/**
* Releases a lock by deleting the key.
* Ideally, use Lua script for safe release (check value before deleting),
* but simple DEL is often sufficient if lock expiry is handled well.
* @param lockKey The key of the lock to release.
*/
export async function releaseLock(lockKey: string): Promise<void> {
try {
const redis = getRedisClient()
if (redis) {
await redis.del(lockKey)
} else {
logger.warn('Redis client not available, cannot release lock.')
// No fallback needed for releasing if using in-memory cache for locking wasn't implemented
}
} catch (error) {
logger.error(`Error releasing lock for key ${lockKey}:`, { error })
}
}

View File

@@ -1,9 +1,11 @@
import { ToolConfig } from '../types'
import { AirtableWriteParams, AirtableWriteResponse } from './types'
import { AirtableCreateParams, AirtableCreateResponse } from './types'
export const writeTool: ToolConfig<AirtableWriteParams, AirtableWriteResponse> = {
id: 'airtable_write',
name: 'Airtable Write Records',
// import { logger } from '@/utils/logger' // Removed logger due to import issues
export const airtableCreateRecordsTool: ToolConfig<AirtableCreateParams, AirtableCreateResponse> = {
id: 'airtable_create_records',
name: 'Airtable Create Records',
description: 'Write new records to an Airtable table',
version: '1.0.0',
@@ -31,7 +33,8 @@ export const writeTool: ToolConfig<AirtableWriteParams, AirtableWriteResponse> =
records: {
type: 'json',
required: true,
description: 'Array of records to create',
description: 'Array of records to create, each with a `fields` object',
// Example: [{ fields: { "Field 1": "Value1", "Field 2": "Value2" } }]
},
},
@@ -42,23 +45,29 @@ export const writeTool: ToolConfig<AirtableWriteParams, AirtableWriteResponse> =
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
// Body should contain { records: [...] } and optionally { typecast: true }
body: (params) => ({ records: params.records }),
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
// logger.error('Airtable API error:', data)
throw new Error(data.error?.message || 'Failed to create Airtable records')
}
return {
success: true,
output: {
records: data.records,
records: data.records || [],
metadata: {
recordCount: data.records.length,
recordCount: (data.records || []).length,
},
},
}
},
transformError: (error) => {
return `Failed to write Airtable records: ${error.message}`
transformError: (error: any) => {
// logger.error('Airtable tool error:', error)
return `Failed to create Airtable records: ${error.message || 'Unknown error'}`
},
}

View File

@@ -1,10 +1,12 @@
import { ToolConfig } from '../types'
import { AirtableUpdateParams, AirtableUpdateResponse } from './types'
import { AirtableGetParams, AirtableGetResponse } from './types'
export const updateTool: ToolConfig<AirtableUpdateParams, AirtableUpdateResponse> = {
id: 'airtable_update',
name: 'Airtable Update Records',
description: 'Update existing records in an Airtable table',
// import { logger } from '@/utils/logger' // Removed logger due to import issues
export const airtableGetRecordTool: ToolConfig<AirtableGetParams, AirtableGetResponse> = {
id: 'airtable_get_record',
name: 'Airtable Get Record',
description: 'Retrieve a single record from an Airtable table by its ID',
version: '1.0.0',
oauth: {
@@ -31,41 +33,39 @@ export const updateTool: ToolConfig<AirtableUpdateParams, AirtableUpdateResponse
recordId: {
type: 'string',
required: true,
description: 'ID of the record to update',
},
fields: {
type: 'json',
required: true,
description: 'Fields to update',
description: 'ID of the record to retrieve',
},
},
request: {
url: (params) =>
`https://api.airtable.com/v0/${params.baseId}/${params.tableId}/${params.recordId}`,
method: 'PATCH',
method: 'GET',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
body: (params) => ({ fields: params.fields }),
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
// logger.error('Airtable API error:', data)
throw new Error(data.error?.message || 'Failed to get Airtable record')
}
return {
success: true,
output: {
records: [data],
record: data, // API returns the single record object
metadata: {
recordCount: 1,
updatedFields: Object.keys(data.fields),
},
},
}
},
transformError: (error) => {
return `Failed to update Airtable record: ${error.message}`
transformError: (error: any) => {
// logger.error('Airtable tool error:', error)
return `Failed to get Airtable record: ${error.message || 'Unknown error'}`
},
}

View File

@@ -5,7 +5,12 @@
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { ToolTester } from '../__test-utils__/test-tools'
import { airtableReadTool, airtableUpdateTool, airtableWriteTool } from './index'
import {
airtableCreateRecordsTool,
airtableGetRecordTool,
airtableListRecordsTool,
airtableUpdateRecordTool,
} from './index'
describe('Airtable Tools Integration', () => {
let tester: ToolTester
@@ -19,12 +24,12 @@ describe('Airtable Tools Integration', () => {
delete process.env.NEXT_PUBLIC_APP_URL
})
describe('Airtable Read Tool', () => {
describe('Airtable List Records Tool', () => {
beforeEach(() => {
tester = new ToolTester(airtableReadTool)
tester = new ToolTester(airtableListRecordsTool)
})
test('should construct correct read request', () => {
test('should construct correct list request', () => {
const params = {
baseId: 'base123',
tableId: 'table456',
@@ -38,11 +43,11 @@ describe('Airtable Tools Integration', () => {
expect(url).toContain('/base123/table456')
expect(url).toContain('maxRecords=100')
expect(url).toContain('filterByFormula=Status%3D%27Active%27')
expect(url).toContain(`filterByFormula=Status='Active'`)
expect(headers['Authorization']).toBe('Bearer token789')
})
test('should handle successful read response', async () => {
test('should handle successful list response', async () => {
const mockData = {
records: [
{ id: 'rec1', fields: { Name: 'Test 1' } },
@@ -66,12 +71,55 @@ describe('Airtable Tools Integration', () => {
})
})
describe('Airtable Write Tool', () => {
describe('Airtable Get Record Tool', () => {
beforeEach(() => {
tester = new ToolTester(airtableWriteTool)
tester = new ToolTester(airtableGetRecordTool)
})
test('should construct correct write request', () => {
test('should construct correct get request', () => {
const params = {
baseId: 'base123',
tableId: 'table456',
recordId: 'rec789',
accessToken: 'token789',
}
const url = tester.getRequestUrl(params)
const headers = tester.getRequestHeaders(params)
expect(url).toContain('/base123/table456/rec789')
expect(headers['Authorization']).toBe('Bearer token789')
})
test('should handle successful get response', async () => {
const mockData = {
id: 'rec789',
createdTime: '2023-01-01T00:00:00.000Z',
fields: { Name: 'Test Record' },
}
tester.setup(mockData)
const result = await tester.execute({
baseId: 'base123',
tableId: 'table456',
recordId: 'rec789',
accessToken: 'token789',
})
expect(result.success).toBe(true)
expect(result.output.record.id).toBe('rec789')
expect(result.output.record.fields.Name).toBe('Test Record')
expect(result.output.metadata.recordCount).toBe(1)
})
})
describe('Airtable Create Records Tool', () => {
beforeEach(() => {
tester = new ToolTester(airtableCreateRecordsTool)
})
test('should construct correct create request', () => {
const params = {
baseId: 'base123',
tableId: 'table456',
@@ -88,7 +136,7 @@ describe('Airtable Tools Integration', () => {
expect(body).toEqual({ records: [{ fields: { Name: 'New Record' } }] })
})
test('should handle successful write response', async () => {
test('should handle successful create response', async () => {
const mockData = {
records: [{ id: 'rec1', fields: { Name: 'New Record' } }],
}
@@ -108,9 +156,9 @@ describe('Airtable Tools Integration', () => {
})
})
describe('Airtable Update Tool', () => {
describe('Airtable Update Record Tool', () => {
beforeEach(() => {
tester = new ToolTester(airtableUpdateTool)
tester = new ToolTester(airtableUpdateRecordTool)
})
test('should construct correct update request', () => {
@@ -148,14 +196,14 @@ describe('Airtable Tools Integration', () => {
})
expect(result.success).toBe(true)
expect(result.output.records).toHaveLength(1)
expect(result.output.record.id).toBe('rec789')
expect(result.output.metadata.recordCount).toBe(1)
expect(result.output.metadata.updatedFields).toContain('Name')
})
})
test('should handle error responses', async () => {
tester = new ToolTester(airtableReadTool)
tester = new ToolTester(airtableListRecordsTool)
const errorMessage = 'Invalid API key'
tester.setup({ error: errorMessage }, { ok: false, status: 401 })
@@ -167,6 +215,6 @@ describe('Airtable Tools Integration', () => {
})
expect(result.success).toBe(false)
expect(result.error).toContain('Failed to read Airtable records')
expect(result.error).toContain('Failed to list Airtable records')
})
})

View File

@@ -1,7 +1,13 @@
import { readTool } from './read'
import { updateTool } from './update'
import { writeTool } from './write'
import { airtableCreateRecordsTool } from './createRecords'
import { airtableGetRecordTool } from './getRecord'
import { airtableListRecordsTool } from './listRecords'
import { airtableUpdateMultipleRecordsTool } from './updateMultipleRecords'
import { airtableUpdateRecordTool } from './updateRecord'
export const airtableReadTool = readTool
export const airtableWriteTool = writeTool
export const airtableUpdateTool = updateTool
export {
airtableCreateRecordsTool,
airtableGetRecordTool,
airtableListRecordsTool,
airtableUpdateMultipleRecordsTool,
airtableUpdateRecordTool,
}

View File

@@ -1,15 +1,16 @@
import { ToolConfig } from '../types'
import { AirtableReadParams, AirtableReadResponse } from './types'
import { AirtableListParams, AirtableListResponse } from './types'
export const readTool: ToolConfig<AirtableReadParams, AirtableReadResponse> = {
id: 'airtable_read',
name: 'Airtable Read Records',
export const airtableListRecordsTool: ToolConfig<AirtableListParams, AirtableListResponse> = {
id: 'airtable_list_records',
name: 'Airtable List Records',
description: 'Read records from an Airtable table',
version: '1.0.0',
oauth: {
required: true,
provider: 'airtable',
// Define required scopes if different from default write/read
},
params: {
@@ -26,7 +27,7 @@ export const readTool: ToolConfig<AirtableReadParams, AirtableReadResponse> = {
tableId: {
type: 'string',
required: true,
description: 'ID or name of the table',
description: 'ID of the table',
},
maxRecords: {
type: 'number',
@@ -36,8 +37,9 @@ export const readTool: ToolConfig<AirtableReadParams, AirtableReadResponse> = {
filterFormula: {
type: 'string',
required: false,
description: 'Formula to filter records',
description: 'Formula to filter records (e.g., "({Field Name} = \'Value\')")',
},
// TODO: Add other list parameters like pageSize, offset, view, sort, fields, returnFieldsByFieldId, recordMetadata
},
request: {
@@ -46,11 +48,16 @@ export const readTool: ToolConfig<AirtableReadParams, AirtableReadResponse> = {
const queryParams = new URLSearchParams()
if (params.maxRecords) queryParams.append('maxRecords', params.maxRecords.toString())
if (params.filterFormula) {
const encodedFormula = encodeURIComponent(params.filterFormula).replace(/'/g, '%27')
// Airtable formulas often contain characters needing encoding,
// but standard encodeURIComponent might over-encode.
// Simple replacement for single quotes is often sufficient.
// More complex formulas might need careful encoding.
const encodedFormula = params.filterFormula.replace(/'/g, "\'")
queryParams.append('filterByFormula', encodedFormula)
}
const queryString = queryParams.toString()
return queryString ? `${url}?${queryString}` : url
const finalUrl = queryString ? `${url}?${queryString}` : url
return finalUrl
},
method: 'GET',
headers: (params) => ({
@@ -61,19 +68,22 @@ export const readTool: ToolConfig<AirtableReadParams, AirtableReadResponse> = {
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
throw new Error(data.error?.message || 'Failed to fetch Airtable records')
}
return {
success: true,
output: {
records: data.records,
records: data.records || [],
metadata: {
offset: data.offset,
totalRecords: data.records.length,
totalRecords: (data.records || []).length,
},
},
}
},
transformError: (error) => {
return `Failed to read Airtable records: ${error.message}`
transformError: (error: any) => {
return `Failed to list Airtable records: ${error.message || 'Unknown error'}`
},
}

View File

@@ -1,29 +1,56 @@
import { ToolResponse } from '../types'
// Common interfaces
interface AirtableRecord {
// Common types
export interface AirtableRecord {
id: string
createdTime: string
fields: Record<string, any>
}
interface AirtableError {
type: string
message: string
interface AirtableBaseParams {
accessToken: string
baseId: string
tableId: string
}
// Response interfaces
export interface AirtableReadResponse extends ToolResponse {
// List Records Types
export interface AirtableListParams extends AirtableBaseParams {
maxRecords?: number
filterFormula?: string
// TODO: Add other list parameters like pageSize, offset, view, sort, fields, returnFieldsByFieldId, recordMetadata
}
export interface AirtableListResponse extends ToolResponse {
output: {
records: AirtableRecord[]
metadata: {
offset?: string
totalRecords?: number
totalRecords: number
}
}
}
export interface AirtableWriteResponse extends ToolResponse {
// Get Record Types
export interface AirtableGetParams extends AirtableBaseParams {
recordId: string
}
export interface AirtableGetResponse extends ToolResponse {
output: {
record: AirtableRecord
metadata: {
recordCount: 1
}
}
}
// Create Records Types
export interface AirtableCreateParams extends AirtableBaseParams {
records: Array<{ fields: Record<string, any> }>
// TODO: Add typecast parameter
}
export interface AirtableCreateResponse extends ToolResponse {
output: {
records: AirtableRecord[]
metadata: {
@@ -32,38 +59,35 @@ export interface AirtableWriteResponse extends ToolResponse {
}
}
// Update Record Types (Single)
export interface AirtableUpdateParams extends AirtableBaseParams {
recordId: string
fields: Record<string, any>
// TODO: Add typecast parameter
}
export interface AirtableUpdateResponse extends ToolResponse {
output: {
records: AirtableRecord[]
record: AirtableRecord // Airtable returns the single updated record
metadata: {
recordCount: number
recordCount: 1
updatedFields: string[]
}
}
}
// Request interfaces
export interface AirtableReadParams {
accessToken: string
baseId: string
tableId: string
maxRecords?: number
filterFormula?: string
// Update Multiple Records Types
export interface AirtableUpdateMultipleParams extends AirtableBaseParams {
records: Array<{ id: string; fields: Record<string, any> }>
// TODO: Add typecast, performUpsert parameters
}
export interface AirtableWriteParams {
accessToken: string
baseId: string
tableId: string
records: Array<{
fields: Record<string, any>
}>
}
export interface AirtableUpdateParams {
accessToken: string
baseId: string
tableId: string
recordId: string
fields: Record<string, any>
export interface AirtableUpdateMultipleResponse extends ToolResponse {
output: {
records: AirtableRecord[] // Airtable returns the array of updated records
metadata: {
recordCount: number
updatedRecordIds: string[]
}
}
}

View File

@@ -0,0 +1,79 @@
import { ToolConfig } from '../types'
import { AirtableUpdateMultipleParams, AirtableUpdateMultipleResponse } from './types'
// import { logger } from '@/utils/logger' // Removed logger due to import issues
export const airtableUpdateMultipleRecordsTool: ToolConfig<
AirtableUpdateMultipleParams,
AirtableUpdateMultipleResponse
> = {
id: 'airtable_update_multiple_records',
name: 'Airtable Update Multiple Records',
description: 'Update multiple existing records in an Airtable table',
version: '1.0.0',
oauth: {
required: true,
provider: 'airtable',
},
params: {
accessToken: {
type: 'string',
required: true,
description: 'OAuth access token',
},
baseId: {
type: 'string',
required: true,
description: 'ID of the Airtable base',
},
tableId: {
type: 'string',
required: true,
description: 'ID or name of the table',
},
records: {
type: 'json',
required: true,
description: 'Array of records to update, each with an `id` and a `fields` object',
// Example: [{ id: "rec123", fields: { "Status": "Done" } }, { id: "rec456", fields: { "Priority": "High" } }]
},
// TODO: Add typecast, performUpsert parameters
},
request: {
// The API endpoint uses PATCH for multiple record updates as well
url: (params) => `https://api.airtable.com/v0/${params.baseId}/${params.tableId}`,
method: 'PATCH',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
// Body should contain { records: [...] } and optionally { typecast: true, performUpsert: {...} }
body: (params) => ({ records: params.records }),
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
// logger.error('Airtable API error:', data)
throw new Error(data.error?.message || 'Failed to update Airtable records')
}
return {
success: true,
output: {
records: data.records || [], // API returns an array of updated records
metadata: {
recordCount: (data.records || []).length,
updatedRecordIds: (data.records || []).map((r: any) => r.id),
},
},
}
},
transformError: (error: any) => {
// logger.error('Airtable tool error:', error)
return `Failed to update multiple Airtable records: ${error.message || 'Unknown error'}`
},
}

View File

@@ -0,0 +1,82 @@
import { ToolConfig } from '../types'
import { AirtableUpdateParams, AirtableUpdateResponse } from './types'
// import { logger } from '@/utils/logger' // Removed logger due to import issues
export const airtableUpdateRecordTool: ToolConfig<AirtableUpdateParams, AirtableUpdateResponse> = {
id: 'airtable_update_record',
name: 'Airtable Update Record',
description: 'Update an existing record in an Airtable table by ID',
version: '1.0.0',
oauth: {
required: true,
provider: 'airtable',
},
params: {
accessToken: {
type: 'string',
required: true,
description: 'OAuth access token',
},
baseId: {
type: 'string',
required: true,
description: 'ID of the Airtable base',
},
tableId: {
type: 'string',
required: true,
description: 'ID or name of the table',
},
recordId: {
type: 'string',
required: true,
description: 'ID of the record to update',
},
fields: {
type: 'json',
required: true,
description: 'An object containing the field names and their new values',
// Example: { "Field 1": "NewValue1", "Status": "Completed" }
},
// TODO: Add typecast parameter
},
request: {
// The API endpoint uses PATCH for single record updates
url: (params) =>
`https://api.airtable.com/v0/${params.baseId}/${params.tableId}/${params.recordId}`,
method: 'PATCH',
headers: (params) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
// Body should contain { fields: {...} } and optionally { typecast: true }
body: (params) => ({ fields: params.fields }),
},
transformResponse: async (response) => {
const data = await response.json()
if (!response.ok) {
// logger.error('Airtable API error:', data)
throw new Error(data.error?.message || 'Failed to update Airtable record')
}
return {
success: true,
output: {
record: data, // API returns the single updated record object
metadata: {
recordCount: 1,
updatedFields: Object.keys(data.fields || {}),
},
},
}
},
transformError: (error: any) => {
// logger.error('Airtable tool error:', error)
return `Failed to update Airtable record: ${error.message || 'Unknown error'}`
},
}

View File

@@ -1,7 +1,12 @@
import { createLogger } from '@/lib/logs/console-logger'
import { useCustomToolsStore } from '@/stores/custom-tools/store'
import { useEnvironmentStore } from '@/stores/settings/environment/store'
import { airtableReadTool, airtableUpdateTool, airtableWriteTool } from './airtable'
import {
airtableCreateRecordsTool,
airtableGetRecordTool,
airtableListRecordsTool,
airtableUpdateRecordTool,
} from '@/tools/airtable'
import { confluenceListTool, confluenceRetrieveTool, confluenceUpdateTool } from './confluence'
import { docsCreateTool, docsReadTool, docsWriteTool } from './docs'
import { driveDownloadTool, driveListTool, driveUploadTool } from './drive'
@@ -113,9 +118,10 @@ export const tools: Record<string, ToolConfig> = {
confluence_update: confluenceUpdateTool,
twilio_send_sms: sendSMSTool,
dalle_generate: dalleTool,
airtable_read: airtableReadTool,
airtable_write: airtableWriteTool,
airtable_update: airtableUpdateTool,
airtable_create_records: airtableCreateRecordsTool,
airtable_get_record: airtableGetRecordTool,
airtable_list_records: airtableListRecordsTool,
airtable_update_record: airtableUpdateRecordTool,
mistral_parser: mistralParserTool,
}