diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 6a41f9937..5b45022db 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1802,38 +1802,38 @@ export function StripeIcon(props: SVGProps) { fillRule='evenodd' clipRule='evenodd' d='M360 78.0002C360 52.4002 347.6 32.2002 323.9 32.2002C300.1 32.2002 285.7 52.4002 285.7 77.8002C285.7 107.9 302.7 123.1 327.1 123.1C339 123.1 348 120.4 354.8 116.6V96.6002C348 100 340.2 102.1 330.3 102.1C320.6 102.1 312 98.7002 310.9 86.9002H359.8C359.8 85.6002 360 80.4002 360 78.0002ZM310.6 68.5002C310.6 57.2002 317.5 52.5002 323.8 52.5002C329.9 52.5002 336.4 57.2002 336.4 68.5002H310.6Z' - fill='white' + fill='currentColor' /> - + ) @@ -4032,3 +4032,55 @@ export function ApolloIcon(props: SVGProps) { ) } + +export function Neo4jIcon(props: SVGProps) { + return ( + + + + + ) +} + +export function CalendlyIcon(props: SVGProps) { + return ( + + + + + + + + + + ) +} diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index 294e5214e..50f5c8406 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -10,6 +10,7 @@ import { AsanaIcon, BrainIcon, BrowserUseIcon, + CalendlyIcon, ClayIcon, ConfluenceIcon, DiscordIcon, @@ -44,6 +45,7 @@ import { MistralIcon, MongoDBIcon, MySQLIcon, + Neo4jIcon, NotionIcon, OpenAIIcon, OutlookIcon, @@ -118,6 +120,7 @@ export const blockTypeToIconMap: Record = { openai: OpenAIIcon, onedrive: MicrosoftOneDriveIcon, notion: NotionIcon, + neo4j: Neo4jIcon, mysql: MySQLIcon, mongodb: MongoDBIcon, mistral_parse: MistralIcon, @@ -151,6 +154,7 @@ export const blockTypeToIconMap: Record = { discord: DiscordIcon, confluence: ConfluenceIcon, clay: ClayIcon, + calendly: CalendlyIcon, browser_use: BrowserUseIcon, asana: AsanaIcon, arxiv: ArxivIcon, diff --git a/apps/docs/content/docs/en/tools/calendly.mdx b/apps/docs/content/docs/en/tools/calendly.mdx new file mode 100644 index 000000000..ad053ec11 --- /dev/null +++ b/apps/docs/content/docs/en/tools/calendly.mdx @@ -0,0 +1,163 @@ +--- +title: Calendly +description: Manage Calendly scheduling and events +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate Calendly into your workflow. Manage event types, scheduled events, invitees, and webhooks. Can also trigger workflows based on Calendly webhook events (invitee scheduled, invitee canceled, routing form submitted). Requires Personal Access Token. + + + +## Tools + +### `calendly_get_current_user` + +Get information about the currently authenticated Calendly user + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Calendly Personal Access Token | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `resource` | object | Current user information | + +### `calendly_list_event_types` + +Retrieve a list of all event types for a user or organization + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Calendly Personal Access Token | +| `user` | string | No | Return only event types that belong to this user \(URI format\) | +| `organization` | string | No | Return only event types that belong to this organization \(URI format\) | +| `count` | number | No | Number of results per page \(default: 20, max: 100\) | +| `pageToken` | string | No | Page token for pagination | +| `sort` | string | No | Sort order for results \(e.g., "name:asc", "name:desc"\) | +| `active` | boolean | No | When true, show only active event types. When false or unchecked, show all event types \(both active and inactive\). | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `collection` | array | Array of event type objects | + +### `calendly_get_event_type` + +Get detailed information about a specific event type + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Calendly Personal Access Token | +| `eventTypeUuid` | string | Yes | Event type UUID \(can be full URI or just the UUID\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `resource` | object | Event type details | + +### `calendly_list_scheduled_events` + +Retrieve a list of scheduled events for a user or organization + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Calendly Personal Access Token | +| `user` | string | No | Return events that belong to this user \(URI format\). Either "user" or "organization" must be provided. | +| `organization` | string | No | Return events that belong to this organization \(URI format\). Either "user" or "organization" must be provided. | +| `invitee_email` | string | No | Return events where invitee has this email | +| `count` | number | No | Number of results per page \(default: 20, max: 100\) | +| `max_start_time` | string | No | Return events with start time before this time \(ISO 8601 format\) | +| `min_start_time` | string | No | Return events with start time after this time \(ISO 8601 format\) | +| `pageToken` | string | No | Page token for pagination | +| `sort` | string | No | Sort order for results \(e.g., "start_time:asc", "start_time:desc"\) | +| `status` | string | No | Filter by status \("active" or "canceled"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `collection` | array | Array of scheduled event objects | + +### `calendly_get_scheduled_event` + +Get detailed information about a specific scheduled event + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Calendly Personal Access Token | +| `eventUuid` | string | Yes | Scheduled event UUID \(can be full URI or just the UUID\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `resource` | object | Scheduled event details | + +### `calendly_list_event_invitees` + +Retrieve a list of invitees for a scheduled event + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Calendly Personal Access Token | +| `eventUuid` | string | Yes | Scheduled event UUID \(can be full URI or just the UUID\) | +| `count` | number | No | Number of results per page \(default: 20, max: 100\) | +| `email` | string | No | Filter invitees by email address | +| `pageToken` | string | No | Page token for pagination | +| `sort` | string | No | Sort order for results \(e.g., "created_at:asc", "created_at:desc"\) | +| `status` | string | No | Filter by status \("active" or "canceled"\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `collection` | array | Array of invitee objects | + +### `calendly_cancel_event` + +Cancel a scheduled event + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Calendly Personal Access Token | +| `eventUuid` | string | Yes | Scheduled event UUID to cancel \(can be full URI or just the UUID\) | +| `reason` | string | No | Reason for cancellation \(will be sent to invitees\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `resource` | object | Cancellation details | + + + +## Notes + +- Category: `tools` +- Type: `calendly` diff --git a/apps/docs/content/docs/en/tools/memory.mdx b/apps/docs/content/docs/en/tools/memory.mdx index 9171389df..e24ca0671 100644 --- a/apps/docs/content/docs/en/tools/memory.mdx +++ b/apps/docs/content/docs/en/tools/memory.mdx @@ -26,9 +26,11 @@ Add a new memory to the database or append to existing memory with the same ID. | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | string | Yes | Identifier for the memory. If a memory with this ID already exists, the new data will be appended to it. | +| `conversationId` | string | No | Conversation identifier \(e.g., user-123, session-abc\). If a memory with this conversationId already exists for this block, the new message will be appended to it. | +| `id` | string | No | Legacy parameter for conversation identifier. Use conversationId instead. Provided for backwards compatibility. | | `role` | string | Yes | Role for agent memory \(user, assistant, or system\) | | `content` | string | Yes | Content for agent memory | +| `blockId` | string | No | Optional block ID. If not provided, uses the current block ID from execution context, or defaults to "default". | #### Output @@ -40,20 +42,23 @@ Add a new memory to the database or append to existing memory with the same ID. ### `memory_get` -Retrieve a specific memory by its ID +Retrieve memory by conversationId, blockId, blockName, or a combination. Returns all matching memories. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | string | Yes | Identifier for the memory to retrieve | +| `conversationId` | string | No | Conversation identifier \(e.g., user-123, session-abc\). If provided alone, returns all memories for this conversation across all blocks. | +| `id` | string | No | Legacy parameter for conversation identifier. Use conversationId instead. Provided for backwards compatibility. | +| `blockId` | string | No | Block identifier. If provided alone, returns all memories for this block across all conversations. If provided with conversationId, returns memories for that specific conversation in this block. | +| `blockName` | string | No | Block name. Alternative to blockId. If provided alone, returns all memories for blocks with this name. If provided with conversationId, returns memories for that conversation in blocks with this name. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `success` | boolean | Whether the memory was retrieved successfully | -| `memories` | array | Array of memory data for the requested ID | +| `memories` | array | Array of memory objects with conversationId, blockId, blockName, and data fields | | `message` | string | Success or error message | | `error` | string | Error message if operation failed | @@ -71,19 +76,22 @@ Retrieve all memories from the database | Parameter | Type | Description | | --------- | ---- | ----------- | | `success` | boolean | Whether all memories were retrieved successfully | -| `memories` | array | Array of all memory objects with keys, types, and data | +| `memories` | array | Array of all memory objects with key, conversationId, blockId, blockName, and data fields | | `message` | string | Success or error message | | `error` | string | Error message if operation failed | ### `memory_delete` -Delete a specific memory by its ID +Delete memories by conversationId, blockId, blockName, or a combination. Supports bulk deletion. #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | string | Yes | Identifier for the memory to delete | +| `conversationId` | string | No | Conversation identifier \(e.g., user-123, session-abc\). If provided alone, deletes all memories for this conversation across all blocks. | +| `id` | string | No | Legacy parameter for conversation identifier. Use conversationId instead. Provided for backwards compatibility. | +| `blockId` | string | No | Block identifier. If provided alone, deletes all memories for this block across all conversations. If provided with conversationId, deletes memories for that specific conversation in this block. | +| `blockName` | string | No | Block name. Alternative to blockId. If provided alone, deletes all memories for blocks with this name. If provided with conversationId, deletes memories for that conversation in blocks with this name. | #### Output diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index 2da0cafbe..ab3280ed6 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -6,6 +6,7 @@ "arxiv", "asana", "browser_use", + "calendly", "clay", "confluence", "discord", @@ -39,6 +40,7 @@ "mistral_parse", "mongodb", "mysql", + "neo4j", "notion", "onedrive", "openai", diff --git a/apps/docs/content/docs/en/tools/neo4j.mdx b/apps/docs/content/docs/en/tools/neo4j.mdx new file mode 100644 index 000000000..f3f9bce9d --- /dev/null +++ b/apps/docs/content/docs/en/tools/neo4j.mdx @@ -0,0 +1,176 @@ +--- +title: Neo4j +description: Connect to Neo4j graph database +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Integrate Neo4j graph database into the workflow. Can query, create, merge, update, and delete nodes and relationships. + + + +## Tools + +### `neo4j_query` + +Execute MATCH queries to read nodes and relationships from Neo4j graph database. For best performance and to prevent large result sets, include LIMIT in your query (e.g., + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Neo4j server hostname or IP address | +| `port` | number | Yes | Neo4j server port \(default: 7687 for Bolt protocol\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Neo4j username | +| `password` | string | Yes | Neo4j password | +| `encryption` | string | No | Connection encryption mode \(enabled, disabled\) | +| `cypherQuery` | string | Yes | Cypher query to execute \(typically MATCH statements\) | +| `parameters` | object | No | Parameters for the Cypher query as a JSON object. Use for any dynamic values including LIMIT \(e.g., query: "MATCH \(n\) RETURN n LIMIT $limit", parameters: \{limit: 100\}\). | +| `parameters` | string | No | No description | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `records` | array | Array of records returned from the query | +| `recordCount` | number | Number of records returned | +| `summary` | json | Query execution summary with timing and counters | + +### `neo4j_create` + +Execute CREATE statements to add new nodes and relationships to Neo4j graph database + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Neo4j server hostname or IP address | +| `port` | number | Yes | Neo4j server port \(default: 7687 for Bolt protocol\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Neo4j username | +| `password` | string | Yes | Neo4j password | +| `encryption` | string | No | Connection encryption mode \(enabled, disabled\) | +| `cypherQuery` | string | Yes | Cypher CREATE statement to execute | +| `parameters` | object | No | Parameters for the Cypher query as a JSON object | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `summary` | json | Creation summary with counters for nodes and relationships created | + +### `neo4j_merge` + +Execute MERGE statements to find or create nodes and relationships in Neo4j (upsert operation) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Neo4j server hostname or IP address | +| `port` | number | Yes | Neo4j server port \(default: 7687 for Bolt protocol\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Neo4j username | +| `password` | string | Yes | Neo4j password | +| `encryption` | string | No | Connection encryption mode \(enabled, disabled\) | +| `cypherQuery` | string | Yes | Cypher MERGE statement to execute | +| `parameters` | object | No | Parameters for the Cypher query as a JSON object | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `summary` | json | Merge summary with counters for nodes/relationships created or matched | + +### `neo4j_update` + +Execute SET statements to update properties of existing nodes and relationships in Neo4j + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Neo4j server hostname or IP address | +| `port` | number | Yes | Neo4j server port \(default: 7687 for Bolt protocol\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Neo4j username | +| `password` | string | Yes | Neo4j password | +| `encryption` | string | No | Connection encryption mode \(enabled, disabled\) | +| `cypherQuery` | string | Yes | Cypher query with MATCH and SET statements to update properties | +| `parameters` | object | No | Parameters for the Cypher query as a JSON object | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `summary` | json | Update summary with counters for properties set | + +### `neo4j_delete` + +Execute DELETE or DETACH DELETE statements to remove nodes and relationships from Neo4j + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Neo4j server hostname or IP address | +| `port` | number | Yes | Neo4j server port \(default: 7687 for Bolt protocol\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Neo4j username | +| `password` | string | Yes | Neo4j password | +| `encryption` | string | No | Connection encryption mode \(enabled, disabled\) | +| `cypherQuery` | string | Yes | Cypher query with MATCH and DELETE/DETACH DELETE statements | +| `parameters` | object | No | Parameters for the Cypher query as a JSON object | +| `detach` | boolean | No | Whether to use DETACH DELETE to remove relationships before deleting nodes | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `summary` | json | Delete summary with counters for nodes and relationships deleted | + +### `neo4j_execute` + +Execute arbitrary Cypher queries on Neo4j graph database for complex operations + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | Yes | Neo4j server hostname or IP address | +| `port` | number | Yes | Neo4j server port \(default: 7687 for Bolt protocol\) | +| `database` | string | Yes | Database name to connect to | +| `username` | string | Yes | Neo4j username | +| `password` | string | Yes | Neo4j password | +| `encryption` | string | No | Connection encryption mode \(enabled, disabled\) | +| `cypherQuery` | string | Yes | Cypher query to execute \(any valid Cypher statement\) | +| `parameters` | object | No | Parameters for the Cypher query as a JSON object | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Operation status message | +| `records` | array | Array of records returned from the query | +| `recordCount` | number | Number of records returned | +| `summary` | json | Execution summary with timing and counters | + + + +## Notes + +- Category: `tools` +- Type: `neo4j` diff --git a/apps/sim/app/(landing)/components/integrations/integrations.tsx b/apps/sim/app/(landing)/components/integrations/integrations.tsx index 975f58a5c..df29c8d97 100644 --- a/apps/sim/app/(landing)/components/integrations/integrations.tsx +++ b/apps/sim/app/(landing)/components/integrations/integrations.tsx @@ -1,7 +1,6 @@ import * as Icons from '@/components/icons' import { inter } from '@/app/fonts/inter/inter' -// AI models and providers const modelProviderIcons = [ { icon: Icons.OpenAIIcon, label: 'OpenAI' }, { icon: Icons.AnthropicIcon, label: 'Anthropic' }, @@ -16,7 +15,6 @@ const modelProviderIcons = [ { icon: Icons.ElevenLabsIcon, label: 'ElevenLabs' }, ] -// Communication and productivity tools const communicationIcons = [ { icon: Icons.SlackIcon, label: 'Slack' }, { icon: Icons.GmailIcon, label: 'Gmail' }, @@ -28,6 +26,7 @@ const communicationIcons = [ { icon: Icons.ConfluenceIcon, label: 'Confluence' }, { icon: Icons.TelegramIcon, label: 'Telegram' }, { icon: Icons.GoogleCalendarIcon, label: 'Google Calendar' }, + { icon: Icons.CalendlyIcon, label: 'Calendly' }, { icon: Icons.GoogleDocsIcon, label: 'Google Docs' }, { icon: Icons.BrowserUseIcon, label: 'BrowserUse' }, { icon: Icons.TypeformIcon, label: 'Typeform' }, @@ -37,7 +36,6 @@ const communicationIcons = [ { icon: Icons.AirtableIcon, label: 'Airtable' }, ] -// Data, storage and search services const dataStorageIcons = [ { icon: Icons.PineconeIcon, label: 'Pinecone' }, { icon: Icons.SupabaseIcon, label: 'Supabase' }, diff --git a/apps/sim/app/api/tools/neo4j/create/route.ts b/apps/sim/app/api/tools/neo4j/create/route.ts new file mode 100644 index 000000000..a8d8ed12a --- /dev/null +++ b/apps/sim/app/api/tools/neo4j/create/route.ts @@ -0,0 +1,118 @@ +import { randomUUID } from 'crypto' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { + convertNeo4jTypesToJSON, + createNeo4jDriver, + validateCypherQuery, +} from '@/app/api/tools/neo4j/utils' + +const logger = createLogger('Neo4jCreateAPI') + +const CreateSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + encryption: z.enum(['enabled', 'disabled']).default('disabled'), + cypherQuery: z.string().min(1, 'Cypher query is required'), + parameters: z.record(z.unknown()).nullable().optional().default({}), +}) + +export async function POST(request: NextRequest) { + const requestId = randomUUID().slice(0, 8) + let driver = null + let session = null + + try { + const body = await request.json() + const params = CreateSchema.parse(body) + + logger.info( + `[${requestId}] Executing Neo4j create on ${params.host}:${params.port}/${params.database}` + ) + + const validation = validateCypherQuery(params.cypherQuery) + if (!validation.isValid) { + logger.warn(`[${requestId}] Cypher query validation failed: ${validation.error}`) + return NextResponse.json( + { error: `Query validation failed: ${validation.error}` }, + { status: 400 } + ) + } + + driver = await createNeo4jDriver({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + encryption: params.encryption, + }) + + session = driver.session({ database: params.database }) + + const result = await session.run(params.cypherQuery, params.parameters) + + const records = result.records.map((record) => { + const obj: Record = {} + record.keys.forEach((key) => { + if (typeof key === 'string') { + obj[key] = convertNeo4jTypesToJSON(record.get(key)) + } + }) + return obj + }) + + const summary = { + resultAvailableAfter: result.summary.resultAvailableAfter.toNumber(), + resultConsumedAfter: result.summary.resultConsumedAfter.toNumber(), + counters: { + nodesCreated: result.summary.counters.updates().nodesCreated, + nodesDeleted: result.summary.counters.updates().nodesDeleted, + relationshipsCreated: result.summary.counters.updates().relationshipsCreated, + relationshipsDeleted: result.summary.counters.updates().relationshipsDeleted, + propertiesSet: result.summary.counters.updates().propertiesSet, + labelsAdded: result.summary.counters.updates().labelsAdded, + labelsRemoved: result.summary.counters.updates().labelsRemoved, + indexesAdded: result.summary.counters.updates().indexesAdded, + indexesRemoved: result.summary.counters.updates().indexesRemoved, + constraintsAdded: result.summary.counters.updates().constraintsAdded, + constraintsRemoved: result.summary.counters.updates().constraintsRemoved, + }, + } + + logger.info( + `[${requestId}] Create executed successfully, created ${summary.counters.nodesCreated} nodes and ${summary.counters.relationshipsCreated} relationships, returned ${records.length} records` + ) + + return NextResponse.json({ + message: `Created ${summary.counters.nodesCreated} nodes and ${summary.counters.relationshipsCreated} relationships`, + records, + recordCount: records.length, + summary, + }) + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] Neo4j create failed:`, error) + + return NextResponse.json({ error: `Neo4j create failed: ${errorMessage}` }, { status: 500 }) + } finally { + if (session) { + await session.close() + } + if (driver) { + await driver.close() + } + } +} diff --git a/apps/sim/app/api/tools/neo4j/delete/route.ts b/apps/sim/app/api/tools/neo4j/delete/route.ts new file mode 100644 index 000000000..baa639b22 --- /dev/null +++ b/apps/sim/app/api/tools/neo4j/delete/route.ts @@ -0,0 +1,103 @@ +import { randomUUID } from 'crypto' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { createNeo4jDriver, validateCypherQuery } from '@/app/api/tools/neo4j/utils' + +const logger = createLogger('Neo4jDeleteAPI') + +const DeleteSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + encryption: z.enum(['enabled', 'disabled']).default('disabled'), + cypherQuery: z.string().min(1, 'Cypher query is required'), + parameters: z.record(z.unknown()).nullable().optional().default({}), + detach: z.boolean().optional().default(false), +}) + +export async function POST(request: NextRequest) { + const requestId = randomUUID().slice(0, 8) + let driver = null + let session = null + + try { + const body = await request.json() + const params = DeleteSchema.parse(body) + + logger.info( + `[${requestId}] Executing Neo4j delete on ${params.host}:${params.port}/${params.database}` + ) + + const validation = validateCypherQuery(params.cypherQuery) + if (!validation.isValid) { + logger.warn(`[${requestId}] Cypher query validation failed: ${validation.error}`) + return NextResponse.json( + { error: `Query validation failed: ${validation.error}` }, + { status: 400 } + ) + } + + driver = await createNeo4jDriver({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + encryption: params.encryption, + }) + + session = driver.session({ database: params.database }) + + const result = await session.run(params.cypherQuery, params.parameters) + + const summary = { + resultAvailableAfter: result.summary.resultAvailableAfter.toNumber(), + resultConsumedAfter: result.summary.resultConsumedAfter.toNumber(), + counters: { + nodesCreated: result.summary.counters.updates().nodesCreated, + nodesDeleted: result.summary.counters.updates().nodesDeleted, + relationshipsCreated: result.summary.counters.updates().relationshipsCreated, + relationshipsDeleted: result.summary.counters.updates().relationshipsDeleted, + propertiesSet: result.summary.counters.updates().propertiesSet, + labelsAdded: result.summary.counters.updates().labelsAdded, + labelsRemoved: result.summary.counters.updates().labelsRemoved, + indexesAdded: result.summary.counters.updates().indexesAdded, + indexesRemoved: result.summary.counters.updates().indexesRemoved, + constraintsAdded: result.summary.counters.updates().constraintsAdded, + constraintsRemoved: result.summary.counters.updates().constraintsRemoved, + }, + } + + logger.info( + `[${requestId}] Delete executed successfully, deleted ${summary.counters.nodesDeleted} nodes and ${summary.counters.relationshipsDeleted} relationships` + ) + + return NextResponse.json({ + message: `Deleted ${summary.counters.nodesDeleted} nodes and ${summary.counters.relationshipsDeleted} relationships`, + summary, + }) + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] Neo4j delete failed:`, error) + + return NextResponse.json({ error: `Neo4j delete failed: ${errorMessage}` }, { status: 500 }) + } finally { + if (session) { + await session.close() + } + if (driver) { + await driver.close() + } + } +} diff --git a/apps/sim/app/api/tools/neo4j/execute/route.ts b/apps/sim/app/api/tools/neo4j/execute/route.ts new file mode 100644 index 000000000..91eb8379b --- /dev/null +++ b/apps/sim/app/api/tools/neo4j/execute/route.ts @@ -0,0 +1,116 @@ +import { randomUUID } from 'crypto' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { + convertNeo4jTypesToJSON, + createNeo4jDriver, + validateCypherQuery, +} from '@/app/api/tools/neo4j/utils' + +const logger = createLogger('Neo4jExecuteAPI') + +const ExecuteSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + encryption: z.enum(['enabled', 'disabled']).default('disabled'), + cypherQuery: z.string().min(1, 'Cypher query is required'), + parameters: z.record(z.unknown()).nullable().optional().default({}), +}) + +export async function POST(request: NextRequest) { + const requestId = randomUUID().slice(0, 8) + let driver = null + let session = null + + try { + const body = await request.json() + const params = ExecuteSchema.parse(body) + + logger.info( + `[${requestId}] Executing Neo4j query on ${params.host}:${params.port}/${params.database}` + ) + + const validation = validateCypherQuery(params.cypherQuery) + if (!validation.isValid) { + logger.warn(`[${requestId}] Cypher query validation failed: ${validation.error}`) + return NextResponse.json( + { error: `Query validation failed: ${validation.error}` }, + { status: 400 } + ) + } + + driver = await createNeo4jDriver({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + encryption: params.encryption, + }) + + session = driver.session({ database: params.database }) + + const result = await session.run(params.cypherQuery, params.parameters) + + const records = result.records.map((record) => { + const obj: Record = {} + record.keys.forEach((key) => { + if (typeof key === 'string') { + obj[key] = convertNeo4jTypesToJSON(record.get(key)) + } + }) + return obj + }) + + const summary = { + resultAvailableAfter: result.summary.resultAvailableAfter.toNumber(), + resultConsumedAfter: result.summary.resultConsumedAfter.toNumber(), + counters: { + nodesCreated: result.summary.counters.updates().nodesCreated, + nodesDeleted: result.summary.counters.updates().nodesDeleted, + relationshipsCreated: result.summary.counters.updates().relationshipsCreated, + relationshipsDeleted: result.summary.counters.updates().relationshipsDeleted, + propertiesSet: result.summary.counters.updates().propertiesSet, + labelsAdded: result.summary.counters.updates().labelsAdded, + labelsRemoved: result.summary.counters.updates().labelsRemoved, + indexesAdded: result.summary.counters.updates().indexesAdded, + indexesRemoved: result.summary.counters.updates().indexesRemoved, + constraintsAdded: result.summary.counters.updates().constraintsAdded, + constraintsRemoved: result.summary.counters.updates().constraintsRemoved, + }, + } + + logger.info(`[${requestId}] Query executed successfully, returned ${records.length} records`) + + return NextResponse.json({ + message: `Query executed successfully, returned ${records.length} records`, + records, + recordCount: records.length, + summary, + }) + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] Neo4j execute failed:`, error) + + return NextResponse.json({ error: `Neo4j execute failed: ${errorMessage}` }, { status: 500 }) + } finally { + if (session) { + await session.close() + } + if (driver) { + await driver.close() + } + } +} diff --git a/apps/sim/app/api/tools/neo4j/merge/route.ts b/apps/sim/app/api/tools/neo4j/merge/route.ts new file mode 100644 index 000000000..3e43762bb --- /dev/null +++ b/apps/sim/app/api/tools/neo4j/merge/route.ts @@ -0,0 +1,118 @@ +import { randomUUID } from 'crypto' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { + convertNeo4jTypesToJSON, + createNeo4jDriver, + validateCypherQuery, +} from '@/app/api/tools/neo4j/utils' + +const logger = createLogger('Neo4jMergeAPI') + +const MergeSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + encryption: z.enum(['enabled', 'disabled']).default('disabled'), + cypherQuery: z.string().min(1, 'Cypher query is required'), + parameters: z.record(z.unknown()).nullable().optional().default({}), +}) + +export async function POST(request: NextRequest) { + const requestId = randomUUID().slice(0, 8) + let driver = null + let session = null + + try { + const body = await request.json() + const params = MergeSchema.parse(body) + + logger.info( + `[${requestId}] Executing Neo4j merge on ${params.host}:${params.port}/${params.database}` + ) + + const validation = validateCypherQuery(params.cypherQuery) + if (!validation.isValid) { + logger.warn(`[${requestId}] Cypher query validation failed: ${validation.error}`) + return NextResponse.json( + { error: `Query validation failed: ${validation.error}` }, + { status: 400 } + ) + } + + driver = await createNeo4jDriver({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + encryption: params.encryption, + }) + + session = driver.session({ database: params.database }) + + const result = await session.run(params.cypherQuery, params.parameters) + + const records = result.records.map((record) => { + const obj: Record = {} + record.keys.forEach((key) => { + if (typeof key === 'string') { + obj[key] = convertNeo4jTypesToJSON(record.get(key)) + } + }) + return obj + }) + + const summary = { + resultAvailableAfter: result.summary.resultAvailableAfter.toNumber(), + resultConsumedAfter: result.summary.resultConsumedAfter.toNumber(), + counters: { + nodesCreated: result.summary.counters.updates().nodesCreated, + nodesDeleted: result.summary.counters.updates().nodesDeleted, + relationshipsCreated: result.summary.counters.updates().relationshipsCreated, + relationshipsDeleted: result.summary.counters.updates().relationshipsDeleted, + propertiesSet: result.summary.counters.updates().propertiesSet, + labelsAdded: result.summary.counters.updates().labelsAdded, + labelsRemoved: result.summary.counters.updates().labelsRemoved, + indexesAdded: result.summary.counters.updates().indexesAdded, + indexesRemoved: result.summary.counters.updates().indexesRemoved, + constraintsAdded: result.summary.counters.updates().constraintsAdded, + constraintsRemoved: result.summary.counters.updates().constraintsRemoved, + }, + } + + logger.info( + `[${requestId}] Merge executed successfully, created ${summary.counters.nodesCreated} nodes, ${summary.counters.relationshipsCreated} relationships, returned ${records.length} records` + ) + + return NextResponse.json({ + message: `Merge completed: ${summary.counters.nodesCreated} nodes created, ${summary.counters.relationshipsCreated} relationships created`, + records, + recordCount: records.length, + summary, + }) + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] Neo4j merge failed:`, error) + + return NextResponse.json({ error: `Neo4j merge failed: ${errorMessage}` }, { status: 500 }) + } finally { + if (session) { + await session.close() + } + if (driver) { + await driver.close() + } + } +} diff --git a/apps/sim/app/api/tools/neo4j/query/route.ts b/apps/sim/app/api/tools/neo4j/query/route.ts new file mode 100644 index 000000000..f5b808495 --- /dev/null +++ b/apps/sim/app/api/tools/neo4j/query/route.ts @@ -0,0 +1,116 @@ +import { randomUUID } from 'crypto' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { + convertNeo4jTypesToJSON, + createNeo4jDriver, + validateCypherQuery, +} from '@/app/api/tools/neo4j/utils' + +const logger = createLogger('Neo4jQueryAPI') + +const QuerySchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + encryption: z.enum(['enabled', 'disabled']).default('disabled'), + cypherQuery: z.string().min(1, 'Cypher query is required'), + parameters: z.record(z.unknown()).nullable().optional().default({}), +}) + +export async function POST(request: NextRequest) { + const requestId = randomUUID().slice(0, 8) + let driver = null + let session = null + + try { + const body = await request.json() + const params = QuerySchema.parse(body) + + logger.info( + `[${requestId}] Executing Neo4j query on ${params.host}:${params.port}/${params.database}` + ) + + const validation = validateCypherQuery(params.cypherQuery) + if (!validation.isValid) { + logger.warn(`[${requestId}] Cypher query validation failed: ${validation.error}`) + return NextResponse.json( + { error: `Query validation failed: ${validation.error}` }, + { status: 400 } + ) + } + + driver = await createNeo4jDriver({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + encryption: params.encryption, + }) + + session = driver.session({ database: params.database }) + + const result = await session.run(params.cypherQuery, params.parameters) + + const records = result.records.map((record) => { + const obj: Record = {} + record.keys.forEach((key) => { + if (typeof key === 'string') { + obj[key] = convertNeo4jTypesToJSON(record.get(key)) + } + }) + return obj + }) + + const summary = { + resultAvailableAfter: result.summary.resultAvailableAfter.toNumber(), + resultConsumedAfter: result.summary.resultConsumedAfter.toNumber(), + counters: { + nodesCreated: result.summary.counters.updates().nodesCreated, + nodesDeleted: result.summary.counters.updates().nodesDeleted, + relationshipsCreated: result.summary.counters.updates().relationshipsCreated, + relationshipsDeleted: result.summary.counters.updates().relationshipsDeleted, + propertiesSet: result.summary.counters.updates().propertiesSet, + labelsAdded: result.summary.counters.updates().labelsAdded, + labelsRemoved: result.summary.counters.updates().labelsRemoved, + indexesAdded: result.summary.counters.updates().indexesAdded, + indexesRemoved: result.summary.counters.updates().indexesRemoved, + constraintsAdded: result.summary.counters.updates().constraintsAdded, + constraintsRemoved: result.summary.counters.updates().constraintsRemoved, + }, + } + + logger.info(`[${requestId}] Query executed successfully, returned ${records.length} records`) + + return NextResponse.json({ + message: `Found ${records.length} records`, + records, + recordCount: records.length, + summary, + }) + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] Neo4j query failed:`, error) + + return NextResponse.json({ error: `Neo4j query failed: ${errorMessage}` }, { status: 500 }) + } finally { + if (session) { + await session.close() + } + if (driver) { + await driver.close() + } + } +} diff --git a/apps/sim/app/api/tools/neo4j/update/route.ts b/apps/sim/app/api/tools/neo4j/update/route.ts new file mode 100644 index 000000000..1f0d84015 --- /dev/null +++ b/apps/sim/app/api/tools/neo4j/update/route.ts @@ -0,0 +1,118 @@ +import { randomUUID } from 'crypto' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { createLogger } from '@/lib/logs/console/logger' +import { + convertNeo4jTypesToJSON, + createNeo4jDriver, + validateCypherQuery, +} from '@/app/api/tools/neo4j/utils' + +const logger = createLogger('Neo4jUpdateAPI') + +const UpdateSchema = z.object({ + host: z.string().min(1, 'Host is required'), + port: z.coerce.number().int().positive('Port must be a positive integer'), + database: z.string().min(1, 'Database name is required'), + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), + encryption: z.enum(['enabled', 'disabled']).default('disabled'), + cypherQuery: z.string().min(1, 'Cypher query is required'), + parameters: z.record(z.unknown()).nullable().optional().default({}), +}) + +export async function POST(request: NextRequest) { + const requestId = randomUUID().slice(0, 8) + let driver = null + let session = null + + try { + const body = await request.json() + const params = UpdateSchema.parse(body) + + logger.info( + `[${requestId}] Executing Neo4j update on ${params.host}:${params.port}/${params.database}` + ) + + const validation = validateCypherQuery(params.cypherQuery) + if (!validation.isValid) { + logger.warn(`[${requestId}] Cypher query validation failed: ${validation.error}`) + return NextResponse.json( + { error: `Query validation failed: ${validation.error}` }, + { status: 400 } + ) + } + + driver = await createNeo4jDriver({ + host: params.host, + port: params.port, + database: params.database, + username: params.username, + password: params.password, + encryption: params.encryption, + }) + + session = driver.session({ database: params.database }) + + const result = await session.run(params.cypherQuery, params.parameters) + + const records = result.records.map((record) => { + const obj: Record = {} + record.keys.forEach((key) => { + if (typeof key === 'string') { + obj[key] = convertNeo4jTypesToJSON(record.get(key)) + } + }) + return obj + }) + + const summary = { + resultAvailableAfter: result.summary.resultAvailableAfter.toNumber(), + resultConsumedAfter: result.summary.resultConsumedAfter.toNumber(), + counters: { + nodesCreated: result.summary.counters.updates().nodesCreated, + nodesDeleted: result.summary.counters.updates().nodesDeleted, + relationshipsCreated: result.summary.counters.updates().relationshipsCreated, + relationshipsDeleted: result.summary.counters.updates().relationshipsDeleted, + propertiesSet: result.summary.counters.updates().propertiesSet, + labelsAdded: result.summary.counters.updates().labelsAdded, + labelsRemoved: result.summary.counters.updates().labelsRemoved, + indexesAdded: result.summary.counters.updates().indexesAdded, + indexesRemoved: result.summary.counters.updates().indexesRemoved, + constraintsAdded: result.summary.counters.updates().constraintsAdded, + constraintsRemoved: result.summary.counters.updates().constraintsRemoved, + }, + } + + logger.info( + `[${requestId}] Update executed successfully, ${summary.counters.propertiesSet} properties set, returned ${records.length} records` + ) + + return NextResponse.json({ + message: `Updated ${summary.counters.propertiesSet} properties`, + records, + recordCount: records.length, + summary, + }) + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid request data`, { errors: error.errors }) + return NextResponse.json( + { error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred' + logger.error(`[${requestId}] Neo4j update failed:`, error) + + return NextResponse.json({ error: `Neo4j update failed: ${errorMessage}` }, { status: 500 }) + } finally { + if (session) { + await session.close() + } + if (driver) { + await driver.close() + } + } +} diff --git a/apps/sim/app/api/tools/neo4j/utils.ts b/apps/sim/app/api/tools/neo4j/utils.ts new file mode 100644 index 000000000..c02e584a9 --- /dev/null +++ b/apps/sim/app/api/tools/neo4j/utils.ts @@ -0,0 +1,163 @@ +import neo4j from 'neo4j-driver' +import type { Neo4jConnectionConfig } from '@/tools/neo4j/types' + +export async function createNeo4jDriver(config: Neo4jConnectionConfig) { + const isAuraHost = config.host.includes('.databases.neo4j.io') + + let protocol: string + if (isAuraHost) { + protocol = 'neo4j+s' + } else { + protocol = config.encryption === 'enabled' ? 'bolt+s' : 'bolt' + } + + const uri = `${protocol}://${config.host}:${config.port}` + + const driverConfig: any = { + maxConnectionPoolSize: 1, + connectionTimeout: 10000, + } + + if (!protocol.endsWith('+s')) { + driverConfig.encrypted = config.encryption === 'enabled' ? 'ENCRYPTION_ON' : 'ENCRYPTION_OFF' + } + + const driver = neo4j.driver(uri, neo4j.auth.basic(config.username, config.password), driverConfig) + + await driver.verifyConnectivity() + + return driver +} + +export function validateCypherQuery( + query: string, + allowDangerousOps = false +): { isValid: boolean; error?: string } { + if (!query || typeof query !== 'string') { + return { + isValid: false, + error: 'Query must be a non-empty string', + } + } + + if (!allowDangerousOps) { + const dangerousPatterns = [ + /DROP\s+DATABASE/i, + /DROP\s+CONSTRAINT/i, + /DROP\s+INDEX/i, + /CREATE\s+DATABASE/i, + /CREATE\s+CONSTRAINT/i, + /CREATE\s+INDEX/i, + /CALL\s+dbms\./i, + /CALL\s+db\./i, + /LOAD\s+CSV/i, + /apoc\.cypher\.run/i, + /apoc\.load/i, + /apoc\.periodic/i, + ] + + for (const pattern of dangerousPatterns) { + if (pattern.test(query)) { + return { + isValid: false, + error: + 'Query contains potentially dangerous operations (schema changes, system procedures, or external data loading)', + } + } + } + } + + const trimmedQuery = query.trim() + if (trimmedQuery.length === 0) { + return { + isValid: false, + error: 'Query cannot be empty', + } + } + + return { isValid: true } +} + +export function sanitizeLabelName(name: string): string { + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(name)) { + throw new Error( + 'Invalid label name. Must start with a letter and contain only letters, numbers, and underscores.' + ) + } + return name +} + +export function sanitizePropertyKey(key: string): string { + if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key)) { + throw new Error( + 'Invalid property key. Must start with a letter and contain only letters, numbers, and underscores.' + ) + } + return key +} + +export function sanitizeRelationshipType(type: string): string { + if (!/^[A-Z][A-Z0-9_]*$/.test(type)) { + throw new Error( + 'Invalid relationship type. Must start with an uppercase letter and contain only uppercase letters, numbers, and underscores.' + ) + } + return type +} + +export function convertNeo4jTypesToJSON(value: unknown): unknown { + if (value === null || value === undefined) { + return value + } + + if (typeof value === 'object' && value !== null && 'toNumber' in value) { + return (value as any).toNumber() + } + + if (Array.isArray(value)) { + return value.map(convertNeo4jTypesToJSON) + } + + if (typeof value === 'object') { + const obj = value as any + + if (obj.labels && obj.properties && obj.identity) { + return { + identity: obj.identity.toNumber ? obj.identity.toNumber() : obj.identity, + labels: obj.labels, + properties: convertNeo4jTypesToJSON(obj.properties), + } + } + + if (obj.type && obj.properties && obj.identity && obj.start && obj.end) { + return { + identity: obj.identity.toNumber ? obj.identity.toNumber() : obj.identity, + start: obj.start.toNumber ? obj.start.toNumber() : obj.start, + end: obj.end.toNumber ? obj.end.toNumber() : obj.end, + type: obj.type, + properties: convertNeo4jTypesToJSON(obj.properties), + } + } + + if (obj.start && obj.end && obj.segments) { + return { + start: convertNeo4jTypesToJSON(obj.start), + end: convertNeo4jTypesToJSON(obj.end), + segments: obj.segments.map((seg: any) => ({ + start: convertNeo4jTypesToJSON(seg.start), + relationship: convertNeo4jTypesToJSON(seg.relationship), + end: convertNeo4jTypesToJSON(seg.end), + })), + length: obj.length, + } + } + + const result: Record = {} + for (const [key, val] of Object.entries(obj)) { + result[key] = convertNeo4jTypesToJSON(val) + } + return result + } + + return value +} diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index 5f0d58a27..53b8d0c82 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -297,6 +297,31 @@ export async function POST(request: NextRequest) { } } + if (provider === 'calendly') { + logger.info(`[${requestId}] Creating Calendly subscription before saving to database`) + try { + externalSubscriptionId = await createCalendlyWebhookSubscription( + request, + userId, + createTempWebhookData(), + requestId + ) + if (externalSubscriptionId) { + resolvedProviderConfig.externalId = externalSubscriptionId + externalSubscriptionCreated = true + } + } catch (err) { + logger.error(`[${requestId}] Error creating Calendly webhook subscription`, err) + return NextResponse.json( + { + error: 'Failed to create webhook in Calendly', + details: err instanceof Error ? err.message : 'Unknown error', + }, + { status: 500 } + ) + } + } + if (provider === 'microsoft-teams') { const { createTeamsSubscription } = await import('@/lib/webhooks/webhook-helpers') logger.info(`[${requestId}] Creating Teams subscription before saving to database`) @@ -635,6 +660,140 @@ async function createAirtableWebhookSubscription( throw error } } + +// Helper function to create the webhook subscription in Calendly +async function createCalendlyWebhookSubscription( + request: NextRequest, + userId: string, + webhookData: any, + requestId: string +): Promise { + try { + const { path, providerConfig } = webhookData + const { apiKey, organization, triggerId } = providerConfig || {} + + if (!apiKey) { + logger.warn(`[${requestId}] Missing apiKey for Calendly webhook creation.`, { + webhookId: webhookData.id, + }) + throw new Error( + 'Personal Access Token is required to create Calendly webhook. Please provide your Calendly Personal Access Token.' + ) + } + + if (!organization) { + logger.warn(`[${requestId}] Missing organization URI for Calendly webhook creation.`, { + webhookId: webhookData.id, + }) + throw new Error( + 'Organization URI is required to create Calendly webhook. Please provide your Organization URI from the "Get Current User" operation.' + ) + } + + if (!triggerId) { + logger.warn(`[${requestId}] Missing triggerId for Calendly webhook creation.`, { + webhookId: webhookData.id, + }) + throw new Error('Trigger ID is required to create Calendly webhook') + } + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + // Map trigger IDs to Calendly event types + const eventTypeMap: Record = { + calendly_invitee_created: ['invitee.created'], + calendly_invitee_canceled: ['invitee.canceled'], + calendly_routing_form_submitted: ['routing_form_submission.created'], + calendly_webhook: ['invitee.created', 'invitee.canceled', 'routing_form_submission.created'], + } + + const events = eventTypeMap[triggerId] || ['invitee.created'] + + const calendlyApiUrl = 'https://api.calendly.com/webhook_subscriptions' + + const requestBody = { + url: notificationUrl, + events, + organization, + scope: 'organization', + } + + const calendlyResponse = await fetch(calendlyApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!calendlyResponse.ok) { + const errorBody = await calendlyResponse.json().catch(() => ({})) + const errorMessage = errorBody.message || errorBody.title || 'Unknown Calendly API error' + logger.error( + `[${requestId}] Failed to create webhook in Calendly for webhook ${webhookData.id}. Status: ${calendlyResponse.status}`, + { response: errorBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Calendly' + if (calendlyResponse.status === 401) { + userFriendlyMessage = + 'Calendly authentication failed. Please verify your Personal Access Token is correct.' + } else if (calendlyResponse.status === 403) { + userFriendlyMessage = + 'Calendly access denied. Please ensure you have appropriate permissions and a paid Calendly subscription.' + } else if (calendlyResponse.status === 404) { + userFriendlyMessage = + 'Calendly organization not found. Please verify the Organization URI is correct.' + } else if (errorMessage && errorMessage !== 'Unknown Calendly API error') { + userFriendlyMessage = `Calendly error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + const responseBody = await calendlyResponse.json() + const webhookUri = responseBody.resource?.uri + + if (!webhookUri) { + logger.error( + `[${requestId}] Calendly webhook created but no webhook URI returned for webhook ${webhookData.id}`, + { response: responseBody } + ) + throw new Error('Calendly webhook creation succeeded but no webhook URI was returned') + } + + // Extract the webhook ID from the URI (e.g., https://api.calendly.com/webhook_subscriptions/WEBHOOK_ID) + const webhookId = webhookUri.split('/').pop() + + if (!webhookId) { + logger.error(`[${requestId}] Could not extract webhook ID from Calendly URI: ${webhookUri}`, { + response: responseBody, + }) + throw new Error('Failed to extract webhook ID from Calendly response') + } + + logger.info( + `[${requestId}] Successfully created webhook in Calendly for webhook ${webhookData.id}.`, + { + calendlyWebhookUri: webhookUri, + calendlyWebhookId: webhookId, + } + ) + return webhookId + } catch (error: any) { + logger.error( + `[${requestId}] Exception during Calendly webhook creation for webhook ${webhookData.id}.`, + { + message: error.message, + stack: error.stack, + } + ) + // Re-throw the error so it can be caught by the outer try-catch + throw error + } +} + // Helper function to create the webhook subscription in Webflow async function createWebflowWebhookSubscription( request: NextRequest, diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 9c6aa6542..dabd563ca 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -577,10 +577,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: if (isStreamClosed) return try { - logger.info(`[${requestId}] 📤 Sending SSE event:`, { - type: event.type, - data: event.data, - }) controller.enqueue(encodeSSEEvent(event)) } catch { isStreamClosed = true diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index 3f18289b6..6e610427a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -359,7 +359,7 @@ export function OutputSelect({
{ @@ -378,7 +378,7 @@ export function OutputSelect({ align={align} sideOffset={4} maxHeight={maxHeight} - maxWidth={160} + maxWidth={300} minWidth={160} disablePortal={disablePopoverPortal} onKeyDown={handleKeyDown} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx index 734e9b42a..ce46f7250 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/components/deploy-modal/components/chat-deploy/chat-deploy.tsx @@ -2,23 +2,19 @@ import { useEffect, useRef, useState } from 'react' import { AlertTriangle, Loader2 } from 'lucide-react' -import { Input, Label, Textarea } from '@/components/emcn' import { - Alert, - AlertDescription, - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - Card, - CardContent, - ImageUpload, - Skeleton, -} from '@/components/ui' + Button, + Input, + Label, + Modal, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + ModalTitle, + Textarea, +} from '@/components/emcn' +import { Alert, AlertDescription, Skeleton } from '@/components/ui' import { createLogger } from '@/lib/logs/console/logger' import { getEmailDomain } from '@/lib/urls/utils' import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select' @@ -223,10 +219,6 @@ export function ChatDeploy({ throw new Error(error.error || 'Failed to delete chat') } - if (onUndeploy) { - await onUndeploy() - } - setExistingChat(null) setImageUrl(null) setImageUploadError(null) @@ -259,42 +251,42 @@ export function ChatDeploy({
{/* Delete Confirmation Dialog */} - - - - Delete Chat? - - This will permanently delete your chat deployment at{' '} - - {getEmailDomain()}/chat/{existingChat?.identifier} - {' '} - and undeploy the workflow. - - All users will lose access immediately, and this action cannot be undone. - - - - - + + + + Delete Chat? + + This will delete your chat deployment at "{getEmailDomain()}/chat/ + {existingChat?.identifier}". All users will lose access to the chat interface. You + can recreate this chat deployment at any time. + + + + + + + ) } @@ -335,13 +327,12 @@ export function ChatDeploy({ onChange={(e) => updateField('title', e.target.value)} required disabled={chatSubmitting} - className='h-10 rounded-[8px]' /> {errors.title &&

{errors.title}

}