fix(grain): updated grain trigger to auto-establish trigger (#2666)

Co-authored-by: aadamgough <adam@sim.ai>
This commit is contained in:
Adam Gough
2026-01-02 17:56:06 -08:00
committed by GitHub
parent 385e93f4bb
commit 7515809df0
9 changed files with 419 additions and 87 deletions

View File

@@ -581,6 +581,56 @@ export async function POST(request: NextRequest) {
}
// --- End RSS specific logic ---
if (savedWebhook && provider === 'grain') {
logger.info(`[${requestId}] Grain provider detected. Creating Grain webhook subscription.`)
try {
const grainHookId = await createGrainWebhookSubscription(
request,
{
id: savedWebhook.id,
path: savedWebhook.path,
providerConfig: savedWebhook.providerConfig,
},
requestId
)
if (grainHookId) {
// Update the webhook record with the external Grain hook ID
const updatedConfig = {
...(savedWebhook.providerConfig as Record<string, any>),
externalId: grainHookId,
}
await db
.update(webhook)
.set({
providerConfig: updatedConfig,
updatedAt: new Date(),
})
.where(eq(webhook.id, savedWebhook.id))
savedWebhook.providerConfig = updatedConfig
logger.info(`[${requestId}] Successfully created Grain webhook`, {
grainHookId,
webhookId: savedWebhook.id,
})
}
} catch (err) {
logger.error(
`[${requestId}] Error creating Grain webhook subscription, rolling back webhook`,
err
)
await db.delete(webhook).where(eq(webhook.id, savedWebhook.id))
return NextResponse.json(
{
error: 'Failed to create webhook in Grain',
details: err instanceof Error ? err.message : 'Unknown error',
},
{ status: 500 }
)
}
}
// --- End Grain specific logic ---
const status = targetWebhookId ? 200 : 201
return NextResponse.json({ webhook: savedWebhook }, { status })
} catch (error: any) {
@@ -947,3 +997,103 @@ async function createWebflowWebhookSubscription(
throw error
}
}
// Helper function to create the webhook subscription in Grain
async function createGrainWebhookSubscription(
request: NextRequest,
webhookData: any,
requestId: string
): Promise<string | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, includeHighlights, includeParticipants, includeAiSummary } =
providerConfig || {}
if (!apiKey) {
logger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, {
webhookId: webhookData.id,
})
throw new Error(
'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.'
)
}
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
const grainApiUrl = 'https://api.grain.com/_/public-api/v2/hooks/create'
const requestBody: Record<string, any> = {
hook_url: notificationUrl,
}
// Build include object based on configuration
const include: Record<string, boolean> = {}
if (includeHighlights) {
include.highlights = true
}
if (includeParticipants) {
include.participants = true
}
if (includeAiSummary) {
include.ai_summary = true
}
if (Object.keys(include).length > 0) {
requestBody.include = include
}
const grainResponse = await fetch(grainApiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Public-Api-Version': '2025-10-31',
},
body: JSON.stringify(requestBody),
})
const responseBody = await grainResponse.json()
if (!grainResponse.ok || responseBody.error) {
const errorMessage =
responseBody.error?.message ||
responseBody.error ||
responseBody.message ||
'Unknown Grain API error'
logger.error(
`[${requestId}] Failed to create webhook in Grain for webhook ${webhookData.id}. Status: ${grainResponse.status}`,
{ message: errorMessage, response: responseBody }
)
let userFriendlyMessage = 'Failed to create webhook subscription in Grain'
if (grainResponse.status === 401) {
userFriendlyMessage =
'Invalid Grain API Key. Please verify your Personal Access Token is correct.'
} else if (grainResponse.status === 403) {
userFriendlyMessage =
'Access denied. Please ensure your Grain API Key has appropriate permissions.'
} else if (errorMessage && errorMessage !== 'Unknown Grain API error') {
userFriendlyMessage = `Grain error: ${errorMessage}`
}
throw new Error(userFriendlyMessage)
}
logger.info(
`[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`,
{
grainWebhookId: responseBody.id,
}
)
return responseBody.id
} catch (error: any) {
logger.error(
`[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`,
{
message: error.message,
stack: error.stack,
}
)
throw error
}
}

View File

@@ -8,6 +8,7 @@ const telegramLogger = createLogger('TelegramWebhook')
const airtableLogger = createLogger('AirtableWebhook')
const typeformLogger = createLogger('TypeformWebhook')
const calendlyLogger = createLogger('CalendlyWebhook')
const grainLogger = createLogger('GrainWebhook')
function getProviderConfig(webhook: any): Record<string, any> {
return (webhook.providerConfig as Record<string, any>) || {}
@@ -661,9 +662,58 @@ export async function deleteCalendlyWebhook(webhook: any, requestId: string): Pr
}
}
/**
* Delete a Grain webhook
* Don't fail webhook deletion if cleanup fails
*/
export async function deleteGrainWebhook(webhook: any, requestId: string): Promise<void> {
try {
const config = getProviderConfig(webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey) {
grainLogger.warn(
`[${requestId}] Missing apiKey for Grain webhook deletion ${webhook.id}, skipping cleanup`
)
return
}
if (!externalId) {
grainLogger.warn(
`[${requestId}] Missing externalId for Grain webhook deletion ${webhook.id}, skipping cleanup`
)
return
}
const grainApiUrl = `https://api.grain.com/_/public-api/v2/hooks/${externalId}`
const grainResponse = await fetch(grainApiUrl, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Public-Api-Version': '2025-10-31',
},
})
if (!grainResponse.ok && grainResponse.status !== 404) {
const responseBody = await grainResponse.json().catch(() => ({}))
grainLogger.warn(
`[${requestId}] Failed to delete Grain webhook (non-fatal): ${grainResponse.status}`,
{ response: responseBody }
)
} else {
grainLogger.info(`[${requestId}] Successfully deleted Grain webhook ${externalId}`)
}
} catch (error) {
grainLogger.warn(`[${requestId}] Error deleting Grain webhook (non-fatal)`, error)
}
}
/**
* Clean up external webhook subscriptions for a webhook
* Handles Airtable, Teams, Telegram, Typeform, and Calendly cleanup
* Handles Airtable, Teams, Telegram, Typeform, Calendly, and Grain cleanup
* Don't fail deletion if cleanup fails
*/
export async function cleanupExternalWebhook(
@@ -681,5 +731,7 @@ export async function cleanupExternalWebhook(
await deleteTypeformWebhook(webhook, requestId)
} else if (webhook.provider === 'calendly') {
await deleteCalendlyWebhook(webhook, requestId)
} else if (webhook.provider === 'grain') {
await deleteGrainWebhook(webhook, requestId)
}
}

View File

@@ -12,13 +12,13 @@ export const grainHighlightCreatedTrigger: TriggerConfig = {
subBlocks: [
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
id: 'apiKey',
title: 'API Key',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
placeholder: 'Enter your Grain API key (Personal Access Token)',
description: 'Required to create the webhook in Grain.',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
@@ -26,13 +26,35 @@ export const grainHighlightCreatedTrigger: TriggerConfig = {
},
},
{
id: 'webhookSecret',
title: 'Webhook Secret',
type: 'short-input',
placeholder: 'Enter a strong secret',
description: 'Validates that webhook deliveries originate from Grain.',
password: true,
required: false,
id: 'includeHighlights',
title: 'Include Highlights',
type: 'switch',
description: 'Include highlights/clips in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_highlight_created',
},
},
{
id: 'includeParticipants',
title: 'Include Participants',
type: 'switch',
description: 'Include participant list in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_highlight_created',
},
},
{
id: 'includeAiSummary',
title: 'Include AI Summary',
type: 'switch',
description: 'Include AI-generated summary in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',

View File

@@ -12,13 +12,13 @@ export const grainHighlightUpdatedTrigger: TriggerConfig = {
subBlocks: [
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
id: 'apiKey',
title: 'API Key',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
placeholder: 'Enter your Grain API key (Personal Access Token)',
description: 'Required to create the webhook in Grain.',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
@@ -26,13 +26,35 @@ export const grainHighlightUpdatedTrigger: TriggerConfig = {
},
},
{
id: 'webhookSecret',
title: 'Webhook Secret',
type: 'short-input',
placeholder: 'Enter a strong secret',
description: 'Validates that webhook deliveries originate from Grain.',
password: true,
required: false,
id: 'includeHighlights',
title: 'Include Highlights',
type: 'switch',
description: 'Include highlights/clips in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_highlight_updated',
},
},
{
id: 'includeParticipants',
title: 'Include Participants',
type: 'switch',
description: 'Include participant list in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_highlight_updated',
},
},
{
id: 'includeAiSummary',
title: 'Include AI Summary',
type: 'switch',
description: 'Include AI-generated summary in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',

View File

@@ -12,13 +12,13 @@ export const grainRecordingCreatedTrigger: TriggerConfig = {
subBlocks: [
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
id: 'apiKey',
title: 'API Key',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
placeholder: 'Enter your Grain API key (Personal Access Token)',
description: 'Required to create the webhook in Grain.',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
@@ -26,13 +26,35 @@ export const grainRecordingCreatedTrigger: TriggerConfig = {
},
},
{
id: 'webhookSecret',
title: 'Webhook Secret',
type: 'short-input',
placeholder: 'Enter a strong secret',
description: 'Validates that webhook deliveries originate from Grain.',
password: true,
required: false,
id: 'includeHighlights',
title: 'Include Highlights',
type: 'switch',
description: 'Include highlights/clips in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_recording_created',
},
},
{
id: 'includeParticipants',
title: 'Include Participants',
type: 'switch',
description: 'Include participant list in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_recording_created',
},
},
{
id: 'includeAiSummary',
title: 'Include AI Summary',
type: 'switch',
description: 'Include AI-generated summary in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',

View File

@@ -12,13 +12,13 @@ export const grainRecordingUpdatedTrigger: TriggerConfig = {
subBlocks: [
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
id: 'apiKey',
title: 'API Key',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
placeholder: 'Enter your Grain API key (Personal Access Token)',
description: 'Required to create the webhook in Grain.',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
@@ -26,13 +26,35 @@ export const grainRecordingUpdatedTrigger: TriggerConfig = {
},
},
{
id: 'webhookSecret',
title: 'Webhook Secret',
type: 'short-input',
placeholder: 'Enter a strong secret',
description: 'Validates that webhook deliveries originate from Grain.',
password: true,
required: false,
id: 'includeHighlights',
title: 'Include Highlights',
type: 'switch',
description: 'Include highlights/clips in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_recording_updated',
},
},
{
id: 'includeParticipants',
title: 'Include Participants',
type: 'switch',
description: 'Include participant list in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_recording_updated',
},
},
{
id: 'includeAiSummary',
title: 'Include AI Summary',
type: 'switch',
description: 'Include AI-generated summary in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',

View File

@@ -12,13 +12,13 @@ export const grainStoryCreatedTrigger: TriggerConfig = {
subBlocks: [
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
id: 'apiKey',
title: 'API Key',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
placeholder: 'Enter your Grain API key (Personal Access Token)',
description: 'Required to create the webhook in Grain.',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
@@ -26,13 +26,35 @@ export const grainStoryCreatedTrigger: TriggerConfig = {
},
},
{
id: 'webhookSecret',
title: 'Webhook Secret',
type: 'short-input',
placeholder: 'Enter a strong secret',
description: 'Validates that webhook deliveries originate from Grain.',
password: true,
required: false,
id: 'includeHighlights',
title: 'Include Highlights',
type: 'switch',
description: 'Include highlights/clips in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_story_created',
},
},
{
id: 'includeParticipants',
title: 'Include Participants',
type: 'switch',
description: 'Include participant list in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_story_created',
},
},
{
id: 'includeAiSummary',
title: 'Include AI Summary',
type: 'switch',
description: 'Include AI-generated summary in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',

View File

@@ -17,19 +17,17 @@ export const grainTriggerOptions = [
*/
export function grainSetupInstructions(eventType: string): string {
const instructions = [
'<strong>Note:</strong> You need admin permissions in your Grain workspace to create webhooks.',
'In Grain, navigate to <strong>Settings > Integrations > Webhooks</strong>.',
'Click <strong>"Create webhook"</strong> or <strong>"Add webhook"</strong>.',
'Paste the <strong>Webhook URL</strong> from above into the URL field.',
'Optionally, enter the <strong>Webhook Secret</strong> from above for signature validation.',
`Select the event types this webhook should listen to. For this trigger, select <strong>${eventType}</strong>.`,
'Click <strong>"Save"</strong> to activate the webhook.',
'Enter your Grain API Key (Personal Access Token) above.',
'You can find or create your API key in Grain at <strong>Settings > Integrations > API</strong>.',
'Optionally configure filters to narrow which recordings trigger the webhook.',
`Click <strong>"Save Configuration"</strong> to automatically create the webhook in Grain for <strong>${eventType}</strong> events.`,
'The webhook will be automatically deleted when you remove this trigger.',
]
return instructions
.map(
(instruction, index) =>
`<div class="mb-3">${index === 0 ? instruction : `<strong>${index}.</strong> ${instruction}`}</div>`
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.join('')
}

View File

@@ -21,13 +21,13 @@ export const grainWebhookTrigger: TriggerConfig = {
required: true,
},
{
id: 'webhookUrlDisplay',
title: 'Webhook URL',
id: 'apiKey',
title: 'API Key',
type: 'short-input',
readOnly: true,
showCopyButton: true,
useWebhookUrl: true,
placeholder: 'Webhook URL will be generated',
placeholder: 'Enter your Grain API key (Personal Access Token)',
description: 'Required to create the webhook in Grain.',
password: true,
required: true,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
@@ -35,13 +35,35 @@ export const grainWebhookTrigger: TriggerConfig = {
},
},
{
id: 'webhookSecret',
title: 'Webhook Secret',
type: 'short-input',
placeholder: 'Enter a strong secret',
description: 'Validates that webhook deliveries originate from Grain.',
password: true,
required: false,
id: 'includeHighlights',
title: 'Include Highlights',
type: 'switch',
description: 'Include highlights/clips in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_webhook',
},
},
{
id: 'includeParticipants',
title: 'Include Participants',
type: 'switch',
description: 'Include participant list in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',
value: 'grain_webhook',
},
},
{
id: 'includeAiSummary',
title: 'Include AI Summary',
type: 'switch',
description: 'Include AI-generated summary in webhook payload.',
defaultValue: false,
mode: 'trigger',
condition: {
field: 'selectedTriggerId',