mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-28 03:00:29 -04:00
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:
@@ -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)
|
||||
|
||||
@@ -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' } }))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
150
sim/app/api/auth/oauth/utils.ts
Normal file
150
sim/app/api/auth/oauth/utils.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'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 "Event Subscriptions" 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 "Basic Information" 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'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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
19
sim/components/ui/code-block.tsx
Normal file
19
sim/components/ui/code-block.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
124
sim/lib/redis.ts
124
sim/lib/redis.ts
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'}`
|
||||
},
|
||||
}
|
||||
@@ -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'}`
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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'}`
|
||||
},
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
79
sim/tools/airtable/updateMultipleRecords.ts
Normal file
79
sim/tools/airtable/updateMultipleRecords.ts
Normal 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'}`
|
||||
},
|
||||
}
|
||||
82
sim/tools/airtable/updateRecord.ts
Normal file
82
sim/tools/airtable/updateRecord.ts
Normal 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'}`
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user